{#each processingDetails as detail (detail)}
{detail}
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreenWarning.svelte b/tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreenWarning.svelte
deleted file mode 100644
index 8b8d916889..0000000000
--- a/tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreenWarning.svelte
+++ /dev/null
@@ -1,38 +0,0 @@
-
-
-
-
-
-
-
-
- Server `/props` endpoint not available - using cached data
-
-
-
-
- {serverLoading() ? 'Checking...' : 'Retry'}
-
-
-
-
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettings.svelte b/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettings.svelte
index b6f345c241..5097a80db4 100644
--- a/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettings.svelte
+++ b/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettings.svelte
@@ -17,7 +17,7 @@
ChatSettingsFields
} from '$lib/components/app';
import { ScrollArea } from '$lib/components/ui/scroll-area';
- import { config, updateMultipleConfig } from '$lib/stores/settings.svelte';
+ import { config, settingsStore } from '$lib/stores/settings.svelte';
import { setMode } from 'mode-watcher';
import type { Component } from 'svelte';
@@ -79,19 +79,14 @@
title: 'Display',
icon: Monitor,
fields: [
- {
- key: 'showThoughtInProgress',
- label: 'Show thought in progress',
- type: 'checkbox'
- },
{
key: 'showMessageStats',
label: 'Show message generation statistics',
type: 'checkbox'
},
{
- key: 'showTokensPerSecond',
- label: 'Show tokens per second',
+ key: 'showThoughtInProgress',
+ label: 'Show thought in progress',
type: 'checkbox'
},
{
@@ -100,19 +95,20 @@
type: 'checkbox'
},
{
- key: 'showModelInfo',
- label: 'Show model information',
+ key: 'autoMicOnEmpty',
+ label: 'Show microphone on empty input',
+ type: 'checkbox',
+ isExperimental: true
+ },
+ {
+ key: 'renderUserContentAsMarkdown',
+ label: 'Render user content as Markdown',
type: 'checkbox'
},
{
key: 'disableAutoScroll',
label: 'Disable automatic scroll',
type: 'checkbox'
- },
- {
- key: 'renderUserContentAsMarkdown',
- label: 'Render user content as Markdown',
- type: 'checkbox'
}
]
},
@@ -237,11 +233,6 @@
title: 'Developer',
icon: Code,
fields: [
- {
- key: 'modelSelectorEnabled',
- label: 'Enable model selector',
- type: 'checkbox'
- },
{
key: 'showToolCalls',
label: 'Show tool call labels',
@@ -347,7 +338,7 @@
}
}
- updateMultipleConfig(processedConfig);
+ settingsStore.updateMultipleConfig(processedConfig);
onSave?.();
}
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsFields.svelte b/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsFields.svelte
index 57862bc05e..305687decb 100644
--- a/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsFields.svelte
+++ b/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsFields.svelte
@@ -6,8 +6,7 @@
import * as Select from '$lib/components/ui/select';
import { Textarea } from '$lib/components/ui/textarea';
import { SETTING_CONFIG_DEFAULT, SETTING_CONFIG_INFO } from '$lib/constants/settings-config';
- import { supportsVision } from '$lib/stores/server.svelte';
- import { getParameterInfo, resetParameterToServerDefault } from '$lib/stores/settings.svelte';
+ import { settingsStore } from '$lib/stores/settings.svelte';
import { ParameterSyncService } from '$lib/services/parameter-sync';
import { ChatSettingsParameterSourceIndicator } from '$lib/components/app';
import type { Component } from 'svelte';
@@ -27,7 +26,7 @@
return null;
}
- return getParameterInfo(key);
+ return settingsStore.getParameterInfo(key);
}
@@ -82,7 +81,7 @@
{
- resetParameterToServerDefault(field.key);
+ settingsStore.resetParameterToServerDefault(field.key);
// Trigger UI update by calling onConfigChange with the default value
const defaultValue = propsDefault ?? SETTING_CONFIG_DEFAULT[field.key];
onConfigChange(field.key, String(defaultValue));
@@ -175,7 +174,7 @@
{
- resetParameterToServerDefault(field.key);
+ settingsStore.resetParameterToServerDefault(field.key);
// Trigger UI update by calling onConfigChange with the default value
const defaultValue = propsDefault ?? SETTING_CONFIG_DEFAULT[field.key];
onConfigChange(field.key, String(defaultValue));
@@ -210,14 +209,10 @@
{/if}
{:else if field.type === 'checkbox'}
- {@const pdfDisabled = field.key === 'pdfAsImage' && !supportsVision()}
- {@const isDisabled = pdfDisabled}
-
onConfigChange(field.key, checked)}
class="mt-1"
/>
@@ -225,9 +220,7 @@
{field.label}
@@ -240,11 +233,6 @@
{field.help || SETTING_CONFIG_INFO[field.key]}
- {:else if pdfDisabled}
-
- PDF-to-image processing requires a vision-capable model. PDFs will be processed as
- text.
-
{/if}
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsFooter.svelte b/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsFooter.svelte
index 4f2d978ab8..1f7eb4e752 100644
--- a/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsFooter.svelte
+++ b/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsFooter.svelte
@@ -1,7 +1,7 @@
-
import { Trash2, Pencil, MoreHorizontal, Download, Loader2 } from '@lucide/svelte';
import { ActionDropdown } from '$lib/components/app';
- import { downloadConversation, getAllLoadingConversations } from '$lib/stores/chat.svelte';
+ import { getAllLoadingChats } from '$lib/stores/chat.svelte';
+ import { conversationsStore } from '$lib/stores/conversations.svelte';
import { onMount } from 'svelte';
interface Props {
@@ -25,7 +26,7 @@
let renderActionsDropdown = $state(false);
let dropdownOpen = $state(false);
- let isLoading = $derived(getAllLoadingConversations().includes(conversation.id));
+ let isLoading = $derived(getAllLoadingChats().includes(conversation.id));
function handleEdit(event: Event) {
event.stopPropagation();
@@ -114,7 +115,7 @@
label: 'Export',
onclick: (e) => {
e.stopPropagation();
- downloadConversation(conversation.id);
+ conversationsStore.downloadConversation(conversation.id);
},
shortcut: ['shift', 'cmd', 's']
},
diff --git a/tools/server/webui/src/lib/components/app/dialogs/DialogChatAttachmentPreview.svelte b/tools/server/webui/src/lib/components/app/dialogs/DialogChatAttachmentPreview.svelte
index ac70b8dc6d..012ba00b49 100644
--- a/tools/server/webui/src/lib/components/app/dialogs/DialogChatAttachmentPreview.svelte
+++ b/tools/server/webui/src/lib/components/app/dialogs/DialogChatAttachmentPreview.svelte
@@ -1,49 +1,39 @@
-
+
- {displayName}
+ {displayName}
- {displayType}
{#if displaySize}
- • {formatFileSize(displaySize)}
+ {formatFileSize(displaySize)}
{/if}
@@ -70,9 +59,9 @@
{uploadedFile}
{attachment}
{preview}
- {name}
- {type}
+ name={displayName}
{textContent}
+ {activeModelId}
/>
diff --git a/tools/server/webui/src/lib/components/app/dialogs/DialogChatAttachmentsViewAll.svelte b/tools/server/webui/src/lib/components/app/dialogs/DialogChatAttachmentsViewAll.svelte
index 8f6ca76d42..33ab0fe02e 100644
--- a/tools/server/webui/src/lib/components/app/dialogs/DialogChatAttachmentsViewAll.svelte
+++ b/tools/server/webui/src/lib/components/app/dialogs/DialogChatAttachmentsViewAll.svelte
@@ -11,6 +11,7 @@
imageHeight?: string;
imageWidth?: string;
imageClass?: string;
+ activeModelId?: string;
}
let {
@@ -21,7 +22,8 @@
onFileRemove,
imageHeight = 'h-24',
imageWidth = 'w-auto',
- imageClass = ''
+ imageClass = '',
+ activeModelId
}: Props = $props();
let totalCount = $derived(uploadedFiles.length + attachments.length);
@@ -45,6 +47,7 @@
{imageHeight}
{imageWidth}
{imageClass}
+ {activeModelId}
/>
diff --git a/tools/server/webui/src/lib/components/app/dialogs/DialogModelInformation.svelte b/tools/server/webui/src/lib/components/app/dialogs/DialogModelInformation.svelte
new file mode 100644
index 0000000000..38f3a629ce
--- /dev/null
+++ b/tools/server/webui/src/lib/components/app/dialogs/DialogModelInformation.svelte
@@ -0,0 +1,226 @@
+
+
+
+
+
+
+
+ Model Information
+ Current model details and capabilities
+
+
+
+ {#if isLoadingModels}
+
+
Loading model information...
+
+ {:else if modelsData && modelsData.data.length > 0}
+ {@const modelMeta = modelsData.data[0].meta}
+
+ {#if serverProps}
+
+
+
+ Model
+
+
+
+
+ {modelName}
+
+
+
+
+
+
+
+
+
+
+ File Path
+
+
+
+ {serverProps.model_path}
+
+
+
+
+
+
+
+
+ Context Size
+ {formatNumber(serverProps.default_generation_settings.n_ctx)} tokens
+
+
+
+ {#if modelMeta?.n_ctx_train}
+
+ Training Context
+ {formatNumber(modelMeta.n_ctx_train)} tokens
+
+ {/if}
+
+
+ {#if modelMeta?.size}
+
+ Model Size
+ {formatFileSize(modelMeta.size)}
+
+ {/if}
+
+
+ {#if modelMeta?.n_params}
+
+ Parameters
+ {formatParameters(modelMeta.n_params)}
+
+ {/if}
+
+
+ {#if modelMeta?.n_embd}
+
+ Embedding Size
+ {formatNumber(modelMeta.n_embd)}
+
+ {/if}
+
+
+ {#if modelMeta?.n_vocab}
+
+ Vocabulary Size
+ {formatNumber(modelMeta.n_vocab)} tokens
+
+ {/if}
+
+
+ {#if modelMeta?.vocab_type}
+
+ Vocabulary Type
+ {modelMeta.vocab_type}
+
+ {/if}
+
+
+
+ Parallel Slots
+ {serverProps.total_slots}
+
+
+
+ {#if modalities.length > 0}
+
+ Modalities
+
+
+
+
+
+
+ {/if}
+
+
+
+ Build Info
+ {serverProps.build_info}
+
+
+
+ {#if serverProps.chat_template}
+
+ Chat Template
+
+
+
{serverProps.chat_template}
+
+
+
+ {/if}
+
+
+ {/if}
+ {:else if !isLoadingModels}
+
+
No model information available
+
+ {/if}
+
+
+
diff --git a/tools/server/webui/src/lib/components/app/dialogs/DialogModelNotAvailable.svelte b/tools/server/webui/src/lib/components/app/dialogs/DialogModelNotAvailable.svelte
new file mode 100644
index 0000000000..a6c20291fa
--- /dev/null
+++ b/tools/server/webui/src/lib/components/app/dialogs/DialogModelNotAvailable.svelte
@@ -0,0 +1,76 @@
+
+
+
+
+
+
+
+ Model Not Available
+
+
+
+ The requested model could not be found. Select an available model to continue.
+
+
+
+
+
+
+ Requested: {modelName}
+
+
+
+ {#if availableModels.length > 0}
+
+
Select an available model:
+
+ {#each availableModels as model (model)}
+
handleSelectModel(model)}
+ >
+ {model}
+
+
+ {/each}
+
+
+ {/if}
+
+
+
+ handleOpenChange(false)}>Cancel
+
+
+
diff --git a/tools/server/webui/src/lib/components/app/index.ts b/tools/server/webui/src/lib/components/app/index.ts
index 54bd8d5aa3..cf4d7495e2 100644
--- a/tools/server/webui/src/lib/components/app/index.ts
+++ b/tools/server/webui/src/lib/components/app/index.ts
@@ -10,20 +10,21 @@ export { default as ChatForm } from './chat/ChatForm/ChatForm.svelte';
export { default as ChatFormActionFileAttachments } from './chat/ChatForm/ChatFormActions/ChatFormActionFileAttachments.svelte';
export { default as ChatFormActionRecord } from './chat/ChatForm/ChatFormActions/ChatFormActionRecord.svelte';
export { default as ChatFormActions } from './chat/ChatForm/ChatFormActions/ChatFormActions.svelte';
+export { default as ChatFormActionSubmit } from './chat/ChatForm/ChatFormActions/ChatFormActionSubmit.svelte';
export { default as ChatFormFileInputInvisible } from './chat/ChatForm/ChatFormFileInputInvisible.svelte';
export { default as ChatFormHelperText } from './chat/ChatForm/ChatFormHelperText.svelte';
-export { default as ChatFormModelSelector } from './chat/ChatForm/ChatFormModelSelector.svelte';
export { default as ChatFormTextarea } from './chat/ChatForm/ChatFormTextarea.svelte';
export { default as ChatMessage } from './chat/ChatMessages/ChatMessage.svelte';
-export { default as ChatMessages } from './chat/ChatMessages/ChatMessages.svelte';
+export { default as ChatMessageActions } from './chat/ChatMessages/ChatMessageActions.svelte';
export { default as ChatMessageBranchingControls } from './chat/ChatMessages/ChatMessageBranchingControls.svelte';
+export { default as ChatMessageStatistics } from './chat/ChatMessages/ChatMessageStatistics.svelte';
export { default as ChatMessageThinkingBlock } from './chat/ChatMessages/ChatMessageThinkingBlock.svelte';
+export { default as ChatMessages } from './chat/ChatMessages/ChatMessages.svelte';
export { default as ChatScreen } from './chat/ChatScreen/ChatScreen.svelte';
export { default as ChatScreenHeader } from './chat/ChatScreen/ChatScreenHeader.svelte';
export { default as ChatScreenProcessingInfo } from './chat/ChatScreen/ChatScreenProcessingInfo.svelte';
-export { default as ChatScreenWarning } from './chat/ChatScreen/ChatScreenWarning.svelte';
export { default as ChatSettings } from './chat/ChatSettings/ChatSettings.svelte';
export { default as ChatSettingsFooter } from './chat/ChatSettings/ChatSettingsFooter.svelte';
@@ -45,19 +46,27 @@ export { default as DialogConfirmation } from './dialogs/DialogConfirmation.svel
export { default as DialogConversationSelection } from './dialogs/DialogConversationSelection.svelte';
export { default as DialogConversationTitleUpdate } from './dialogs/DialogConversationTitleUpdate.svelte';
export { default as DialogEmptyFileAlert } from './dialogs/DialogEmptyFileAlert.svelte';
+export { default as DialogModelInformation } from './dialogs/DialogModelInformation.svelte';
+export { default as DialogModelNotAvailable } from './dialogs/DialogModelNotAvailable.svelte';
// Miscellanous
export { default as ActionButton } from './misc/ActionButton.svelte';
export { default as ActionDropdown } from './misc/ActionDropdown.svelte';
+export { default as BadgeChatStatistic } from './misc/BadgeChatStatistic.svelte';
+export { default as BadgeInfo } from './misc/BadgeInfo.svelte';
+export { default as ModelBadge } from './models/ModelBadge.svelte';
+export { default as BadgeModality } from './misc/BadgeModality.svelte';
export { default as ConversationSelection } from './misc/ConversationSelection.svelte';
+export { default as CopyToClipboardIcon } from './misc/CopyToClipboardIcon.svelte';
export { default as KeyboardShortcutInfo } from './misc/KeyboardShortcutInfo.svelte';
export { default as MarkdownContent } from './misc/MarkdownContent.svelte';
export { default as RemoveButton } from './misc/RemoveButton.svelte';
+export { default as SyntaxHighlightedCode } from './misc/SyntaxHighlightedCode.svelte';
+export { default as ModelsSelector } from './models/ModelsSelector.svelte';
// Server
export { default as ServerStatus } from './server/ServerStatus.svelte';
export { default as ServerErrorSplash } from './server/ServerErrorSplash.svelte';
export { default as ServerLoadingSplash } from './server/ServerLoadingSplash.svelte';
-export { default as ServerInfo } from './server/ServerInfo.svelte';
diff --git a/tools/server/webui/src/lib/components/app/misc/ActionButton.svelte b/tools/server/webui/src/lib/components/app/misc/ActionButton.svelte
index 11c4679a6e..411a8b6094 100644
--- a/tools/server/webui/src/lib/components/app/misc/ActionButton.svelte
+++ b/tools/server/webui/src/lib/components/app/misc/ActionButton.svelte
@@ -1,7 +1,6 @@
-
+
e.stopPropagation()}
>
{#if triggerTooltip}
-
+
{@render iconComponent(triggerIcon, 'h-3 w-3')}
{triggerTooltip}
diff --git a/tools/server/webui/src/lib/components/app/misc/BadgeChatStatistic.svelte b/tools/server/webui/src/lib/components/app/misc/BadgeChatStatistic.svelte
new file mode 100644
index 0000000000..9e5339cab5
--- /dev/null
+++ b/tools/server/webui/src/lib/components/app/misc/BadgeChatStatistic.svelte
@@ -0,0 +1,25 @@
+
+
+
+ {#snippet icon()}
+
+ {/snippet}
+
+ {value}
+
diff --git a/tools/server/webui/src/lib/components/app/misc/BadgeInfo.svelte b/tools/server/webui/src/lib/components/app/misc/BadgeInfo.svelte
new file mode 100644
index 0000000000..c70af6f423
--- /dev/null
+++ b/tools/server/webui/src/lib/components/app/misc/BadgeInfo.svelte
@@ -0,0 +1,27 @@
+
+
+
+ {#if icon}
+ {@render icon()}
+ {/if}
+
+ {@render children()}
+
diff --git a/tools/server/webui/src/lib/components/app/misc/BadgeModality.svelte b/tools/server/webui/src/lib/components/app/misc/BadgeModality.svelte
new file mode 100644
index 0000000000..a0d5e863c2
--- /dev/null
+++ b/tools/server/webui/src/lib/components/app/misc/BadgeModality.svelte
@@ -0,0 +1,39 @@
+
+
+{#each displayableModalities as modality, index (index)}
+ {@const IconComponent = MODALITY_ICONS[modality]}
+ {@const label = MODALITY_LABELS[modality]}
+
+
+ {#if IconComponent}
+
+ {/if}
+
+ {label}
+
+{/each}
diff --git a/tools/server/webui/src/lib/components/app/misc/CopyToClipboardIcon.svelte b/tools/server/webui/src/lib/components/app/misc/CopyToClipboardIcon.svelte
new file mode 100644
index 0000000000..bf6cd4fb28
--- /dev/null
+++ b/tools/server/webui/src/lib/components/app/misc/CopyToClipboardIcon.svelte
@@ -0,0 +1,18 @@
+
+
+ canCopy && copyToClipboard(text)}
+/>
diff --git a/tools/server/webui/src/lib/components/app/misc/MarkdownContent.svelte b/tools/server/webui/src/lib/components/app/misc/MarkdownContent.svelte
index 176a98b212..99d6e21e13 100644
--- a/tools/server/webui/src/lib/components/app/misc/MarkdownContent.svelte
+++ b/tools/server/webui/src/lib/components/app/misc/MarkdownContent.svelte
@@ -7,9 +7,8 @@
import remarkRehype from 'remark-rehype';
import rehypeKatex from 'rehype-katex';
import rehypeStringify from 'rehype-stringify';
- import { copyCodeToClipboard } from '$lib/utils/copy';
+ import { copyCodeToClipboard, preprocessLaTeX } from '$lib/utils';
import { rehypeRestoreTableHtml } from '$lib/markdown/table-html-restorer';
- import { preprocessLaTeX } from '$lib/utils/latex-protection';
import { browser } from '$app/environment';
import '$styles/katex-custom.scss';
diff --git a/tools/server/webui/src/lib/components/app/misc/SyntaxHighlightedCode.svelte b/tools/server/webui/src/lib/components/app/misc/SyntaxHighlightedCode.svelte
new file mode 100644
index 0000000000..f36a9a20b9
--- /dev/null
+++ b/tools/server/webui/src/lib/components/app/misc/SyntaxHighlightedCode.svelte
@@ -0,0 +1,96 @@
+
+
+
+
{@html highlightedHtml}
+
+
+
diff --git a/tools/server/webui/src/lib/components/app/models/ModelBadge.svelte b/tools/server/webui/src/lib/components/app/models/ModelBadge.svelte
new file mode 100644
index 0000000000..bea1bf6e3f
--- /dev/null
+++ b/tools/server/webui/src/lib/components/app/models/ModelBadge.svelte
@@ -0,0 +1,56 @@
+
+
+{#snippet badgeContent()}
+
+ {#snippet icon()}
+
+ {/snippet}
+
+ {model}
+
+ {#if showCopyIcon}
+
+ {/if}
+
+{/snippet}
+
+{#if model && isModelMode}
+ {#if showTooltip}
+
+
+ {@render badgeContent()}
+
+
+
+ {onclick ? 'Click for model details' : model}
+
+
+ {:else}
+ {@render badgeContent()}
+ {/if}
+{/if}
diff --git a/tools/server/webui/src/lib/components/app/models/ModelsSelector.svelte b/tools/server/webui/src/lib/components/app/models/ModelsSelector.svelte
new file mode 100644
index 0000000000..c4331e92f1
--- /dev/null
+++ b/tools/server/webui/src/lib/components/app/models/ModelsSelector.svelte
@@ -0,0 +1,596 @@
+
+
+
+
+
+
+ {#if loading && options.length === 0 && isRouter}
+
+
+ Loading models…
+
+ {:else if options.length === 0 && isRouter}
+
No models available.
+ {:else}
+ {@const selectedOption = getDisplayOption()}
+
+
+
+
+
+
+ {selectedOption?.model || 'Select model'}
+
+
+ {#if updating}
+
+ {:else if isRouter}
+
+ {/if}
+
+
+ {#if isOpen && isRouter}
+
+
0
+ ? `${menuPosition.maxHeight}px`
+ : undefined}
+ >
+ {#if !isCurrentModelInCache() && currentModel}
+
+
+ {selectedOption?.name || currentModel}
+ (not available)
+
+
+ {/if}
+ {#each options as option (option.id)}
+ {@const status = getModelStatus(option.model)}
+ {@const isLoaded = status === ServerModelStatus.LOADED}
+ {@const isLoading = status === ServerModelStatus.LOADING}
+ {@const isSelected = currentModel === option.model || activeId === option.id}
+ {@const isCompatible = isModelCompatible(option)}
+ {@const missingModalities = getMissingModalities(option)}
+
isCompatible && handleSelect(option.id)}
+ onkeydown={(e) => {
+ if (isCompatible && (e.key === 'Enter' || e.key === ' ')) {
+ e.preventDefault();
+ handleSelect(option.id);
+ }
+ }}
+ >
+
{option.model}
+
+ {#if missingModalities}
+
+ {#if missingModalities.vision}
+
+
+
+
+
+ No vision support
+
+
+ {/if}
+ {#if missingModalities.audio}
+
+
+
+
+
+ No audio support
+
+
+ {/if}
+
+ {/if}
+
+ {#if isLoading}
+
+
+
+
+
+ Loading model...
+
+
+ {:else if isLoaded}
+
+
+ {
+ e.stopPropagation();
+ modelsStore.unloadModel(option.model);
+ }}
+ >
+
+
+
+
+
+ Unload model
+
+
+ {:else}
+
+ {/if}
+
+ {/each}
+
+
+ {/if}
+
+ {/if}
+
+
+{#if showModelDialog && !isRouter}
+
+{/if}
diff --git a/tools/server/webui/src/lib/components/app/server/ServerErrorSplash.svelte b/tools/server/webui/src/lib/components/app/server/ServerErrorSplash.svelte
index af142e32aa..39613f200c 100644
--- a/tools/server/webui/src/lib/components/app/server/ServerErrorSplash.svelte
+++ b/tools/server/webui/src/lib/components/app/server/ServerErrorSplash.svelte
@@ -5,7 +5,7 @@
import { Input } from '$lib/components/ui/input';
import Label from '$lib/components/ui/label/label.svelte';
import { serverStore, serverLoading } from '$lib/stores/server.svelte';
- import { config, updateConfig } from '$lib/stores/settings.svelte';
+ import { config, settingsStore } from '$lib/stores/settings.svelte';
import { fade, fly, scale } from 'svelte/transition';
interface Props {
@@ -42,7 +42,7 @@
if (onRetry) {
onRetry();
} else {
- serverStore.fetchServerProps();
+ serverStore.fetch();
}
}
@@ -61,7 +61,7 @@
try {
// Update the API key in settings first
- updateConfig('apiKey', apiKeyInput.trim());
+ settingsStore.updateConfig('apiKey', apiKeyInput.trim());
// Test the API key by making a real request to the server
const response = await fetch('./props', {
diff --git a/tools/server/webui/src/lib/components/app/server/ServerInfo.svelte b/tools/server/webui/src/lib/components/app/server/ServerInfo.svelte
deleted file mode 100644
index 9a43e333c4..0000000000
--- a/tools/server/webui/src/lib/components/app/server/ServerInfo.svelte
+++ /dev/null
@@ -1,43 +0,0 @@
-
-
-{#if props}
-
- {#if model}
-
-
-
- {model}
-
- {/if}
-
-
- {#if props.default_generation_settings.n_ctx}
-
- ctx: {props.default_generation_settings.n_ctx.toLocaleString()}
-
- {/if}
-
- {#if modalities.length > 0}
- {#each modalities as modality (modality)}
-
- {#if modality === 'vision'}
-
- {:else if modality === 'audio'}
-
- {/if}
-
- {modality}
-
- {/each}
- {/if}
-
-
-{/if}
diff --git a/tools/server/webui/src/lib/components/app/server/ServerStatus.svelte b/tools/server/webui/src/lib/components/app/server/ServerStatus.svelte
index f04c954d70..d9f6d4a32a 100644
--- a/tools/server/webui/src/lib/components/app/server/ServerStatus.svelte
+++ b/tools/server/webui/src/lib/components/app/server/ServerStatus.svelte
@@ -2,7 +2,8 @@
import { AlertTriangle, Server } from '@lucide/svelte';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
- import { serverProps, serverLoading, serverError, modelName } from '$lib/stores/server.svelte';
+ import { serverProps, serverLoading, serverError } from '$lib/stores/server.svelte';
+ import { singleModelName } from '$lib/stores/models.svelte';
interface Props {
class?: string;
@@ -13,7 +14,7 @@
let error = $derived(serverError());
let loading = $derived(serverLoading());
- let model = $derived(modelName());
+ let model = $derived(singleModelName());
let serverData = $derived(serverProps());
function getStatusColor() {
diff --git a/tools/server/webui/src/lib/components/ui/alert/alert-description.svelte b/tools/server/webui/src/lib/components/ui/alert/alert-description.svelte
new file mode 100644
index 0000000000..440d0069d3
--- /dev/null
+++ b/tools/server/webui/src/lib/components/ui/alert/alert-description.svelte
@@ -0,0 +1,23 @@
+
+
+
+ {@render children?.()}
+
diff --git a/tools/server/webui/src/lib/components/ui/alert/alert-title.svelte b/tools/server/webui/src/lib/components/ui/alert/alert-title.svelte
new file mode 100644
index 0000000000..0721aebf12
--- /dev/null
+++ b/tools/server/webui/src/lib/components/ui/alert/alert-title.svelte
@@ -0,0 +1,20 @@
+
+
+
+ {@render children?.()}
+
diff --git a/tools/server/webui/src/lib/components/ui/alert/alert.svelte b/tools/server/webui/src/lib/components/ui/alert/alert.svelte
new file mode 100644
index 0000000000..7d79e4bc0e
--- /dev/null
+++ b/tools/server/webui/src/lib/components/ui/alert/alert.svelte
@@ -0,0 +1,44 @@
+
+
+
+
+
+ {@render children?.()}
+
diff --git a/tools/server/webui/src/lib/components/ui/alert/index.ts b/tools/server/webui/src/lib/components/ui/alert/index.ts
new file mode 100644
index 0000000000..5e0f854da6
--- /dev/null
+++ b/tools/server/webui/src/lib/components/ui/alert/index.ts
@@ -0,0 +1,14 @@
+import Root from './alert.svelte';
+import Description from './alert-description.svelte';
+import Title from './alert-title.svelte';
+export { alertVariants, type AlertVariant } from './alert.svelte';
+
+export {
+ Root,
+ Description,
+ Title,
+ //
+ Root as Alert,
+ Description as AlertDescription,
+ Title as AlertTitle
+};
diff --git a/tools/server/webui/src/lib/components/ui/sidebar/sidebar-provider.svelte b/tools/server/webui/src/lib/components/ui/sidebar/sidebar-provider.svelte
index ed90ea84eb..364235a499 100644
--- a/tools/server/webui/src/lib/components/ui/sidebar/sidebar-provider.svelte
+++ b/tools/server/webui/src/lib/components/ui/sidebar/sidebar-provider.svelte
@@ -1,5 +1,4 @@
+
+
+ {@render children?.()}
+
diff --git a/tools/server/webui/src/lib/components/ui/table/table-caption.svelte b/tools/server/webui/src/lib/components/ui/table/table-caption.svelte
new file mode 100644
index 0000000000..0fdcc6439c
--- /dev/null
+++ b/tools/server/webui/src/lib/components/ui/table/table-caption.svelte
@@ -0,0 +1,20 @@
+
+
+
+ {@render children?.()}
+
diff --git a/tools/server/webui/src/lib/components/ui/table/table-cell.svelte b/tools/server/webui/src/lib/components/ui/table/table-cell.svelte
new file mode 100644
index 0000000000..4506fdfc5b
--- /dev/null
+++ b/tools/server/webui/src/lib/components/ui/table/table-cell.svelte
@@ -0,0 +1,23 @@
+
+
+
+ {@render children?.()}
+
diff --git a/tools/server/webui/src/lib/components/ui/table/table-footer.svelte b/tools/server/webui/src/lib/components/ui/table/table-footer.svelte
new file mode 100644
index 0000000000..77e4a64c08
--- /dev/null
+++ b/tools/server/webui/src/lib/components/ui/table/table-footer.svelte
@@ -0,0 +1,20 @@
+
+
+tr]:last:border-b-0', className)}
+ {...restProps}
+>
+ {@render children?.()}
+
diff --git a/tools/server/webui/src/lib/components/ui/table/table-head.svelte b/tools/server/webui/src/lib/components/ui/table/table-head.svelte
new file mode 100644
index 0000000000..c1c57ad443
--- /dev/null
+++ b/tools/server/webui/src/lib/components/ui/table/table-head.svelte
@@ -0,0 +1,23 @@
+
+
+
+ {@render children?.()}
+
diff --git a/tools/server/webui/src/lib/components/ui/table/table-header.svelte b/tools/server/webui/src/lib/components/ui/table/table-header.svelte
new file mode 100644
index 0000000000..eb366739b3
--- /dev/null
+++ b/tools/server/webui/src/lib/components/ui/table/table-header.svelte
@@ -0,0 +1,20 @@
+
+
+
+ {@render children?.()}
+
diff --git a/tools/server/webui/src/lib/components/ui/table/table-row.svelte b/tools/server/webui/src/lib/components/ui/table/table-row.svelte
new file mode 100644
index 0000000000..4131d3660a
--- /dev/null
+++ b/tools/server/webui/src/lib/components/ui/table/table-row.svelte
@@ -0,0 +1,23 @@
+
+
+svelte-css-wrapper]:[&>th,td]:bg-muted/50',
+ className
+ )}
+ {...restProps}
+>
+ {@render children?.()}
+
diff --git a/tools/server/webui/src/lib/components/ui/table/table.svelte b/tools/server/webui/src/lib/components/ui/table/table.svelte
new file mode 100644
index 0000000000..c11a6a6c4b
--- /dev/null
+++ b/tools/server/webui/src/lib/components/ui/table/table.svelte
@@ -0,0 +1,22 @@
+
+
+
+
+ {@render children?.()}
+
+
diff --git a/tools/server/webui/src/lib/constants/debounce.ts b/tools/server/webui/src/lib/constants/debounce.ts
deleted file mode 100644
index 7394669f3a..0000000000
--- a/tools/server/webui/src/lib/constants/debounce.ts
+++ /dev/null
@@ -1 +0,0 @@
-export const SLOTS_DEBOUNCE_INTERVAL = 100;
diff --git a/tools/server/webui/src/lib/constants/default-context.ts b/tools/server/webui/src/lib/constants/default-context.ts
new file mode 100644
index 0000000000..78f31116e3
--- /dev/null
+++ b/tools/server/webui/src/lib/constants/default-context.ts
@@ -0,0 +1 @@
+export const DEFAULT_CONTEXT = 4096;
diff --git a/tools/server/webui/src/lib/constants/floating-ui-constraints.ts b/tools/server/webui/src/lib/constants/floating-ui-constraints.ts
new file mode 100644
index 0000000000..c95d3f1841
--- /dev/null
+++ b/tools/server/webui/src/lib/constants/floating-ui-constraints.ts
@@ -0,0 +1,3 @@
+export const VIEWPORT_GUTTER = 8;
+export const MENU_OFFSET = 6;
+export const MENU_MAX_WIDTH = 320;
diff --git a/tools/server/webui/src/lib/constants/icons.ts b/tools/server/webui/src/lib/constants/icons.ts
new file mode 100644
index 0000000000..1e88ab5b3a
--- /dev/null
+++ b/tools/server/webui/src/lib/constants/icons.ts
@@ -0,0 +1,32 @@
+/**
+ * Icon mappings for file types and model modalities
+ * Centralized configuration to ensure consistent icon usage across the app
+ */
+
+import {
+ File as FileIcon,
+ FileText as FileTextIcon,
+ Image as ImageIcon,
+ Eye as VisionIcon,
+ Mic as AudioIcon
+} from '@lucide/svelte';
+import { FileTypeCategory, ModelModality } from '$lib/enums';
+
+export const FILE_TYPE_ICONS = {
+ [FileTypeCategory.IMAGE]: ImageIcon,
+ [FileTypeCategory.AUDIO]: AudioIcon,
+ [FileTypeCategory.TEXT]: FileTextIcon,
+ [FileTypeCategory.PDF]: FileIcon
+} as const;
+
+export const DEFAULT_FILE_ICON = FileIcon;
+
+export const MODALITY_ICONS = {
+ [ModelModality.VISION]: VisionIcon,
+ [ModelModality.AUDIO]: AudioIcon
+} as const;
+
+export const MODALITY_LABELS = {
+ [ModelModality.VISION]: 'Vision',
+ [ModelModality.AUDIO]: 'Audio'
+} as const;
diff --git a/tools/server/webui/src/lib/constants/localstorage-keys.ts b/tools/server/webui/src/lib/constants/localstorage-keys.ts
index 8bdc5f33c3..919b6ea06d 100644
--- a/tools/server/webui/src/lib/constants/localstorage-keys.ts
+++ b/tools/server/webui/src/lib/constants/localstorage-keys.ts
@@ -1,2 +1,2 @@
-export const SERVER_PROPS_LOCALSTORAGE_KEY = 'LlamaCppWebui.serverProps';
-export const SELECTED_MODEL_LOCALSTORAGE_KEY = 'LlamaCppWebui.selectedModel';
+export const CONFIG_LOCALSTORAGE_KEY = 'LlamaCppWebui.config';
+export const USER_OVERRIDES_LOCALSTORAGE_KEY = 'LlamaCppWebui.userOverrides';
diff --git a/tools/server/webui/src/lib/constants/settings-config.ts b/tools/server/webui/src/lib/constants/settings-config.ts
index f8187bbf49..b40ad089c9 100644
--- a/tools/server/webui/src/lib/constants/settings-config.ts
+++ b/tools/server/webui/src/lib/constants/settings-config.ts
@@ -4,7 +4,6 @@ export const SETTING_CONFIG_DEFAULT: Record =
apiKey: '',
systemMessage: '',
theme: 'system',
- showTokensPerSecond: false,
showThoughtInProgress: false,
showToolCalls: false,
disableReasoningFormat: false,
@@ -13,10 +12,9 @@ export const SETTING_CONFIG_DEFAULT: Record =
askForTitleConfirmation: false,
pasteLongTextToFileLen: 2500,
pdfAsImage: false,
- showModelInfo: false,
disableAutoScroll: false,
renderUserContentAsMarkdown: false,
- modelSelectorEnabled: false,
+ autoMicOnEmpty: false,
// make sure these default values are in sync with `common.h`
samplers: 'top_k;typ_p;top_p;min_p;temperature',
backend_sampling: false,
@@ -84,7 +82,6 @@ export const SETTING_CONFIG_INFO: Record = {
'DRY sampling reduces repetition in generated text even across long contexts. This parameter sets DRY penalty for the last n tokens.',
max_tokens: 'The maximum number of token per output. Use -1 for infinite (no limit).',
custom: 'Custom JSON parameters to send to the API. Must be valid JSON format.',
- showTokensPerSecond: 'Display generation speed in tokens per second during streaming.',
showThoughtInProgress: 'Expand thought process by default when generating messages.',
showToolCalls:
'Display tool call labels and payloads from Harmony-compatible delta.tool_calls data below assistant messages.',
@@ -95,13 +92,13 @@ export const SETTING_CONFIG_INFO: Record = {
'Display generation statistics (tokens/second, token count, duration) below each assistant message.',
askForTitleConfirmation:
'Ask for confirmation before automatically changing conversation title when editing the first message.',
- pdfAsImage: 'Parse PDF as image instead of text (requires vision-capable model).',
- showModelInfo: 'Display the model name used to generate each message below the message content.',
+ pdfAsImage:
+ 'Parse PDF as image instead of text. Automatically falls back to text processing for non-vision models.',
disableAutoScroll:
'Disable automatic scrolling while messages stream so you can control the viewport position manually.',
renderUserContentAsMarkdown: 'Render user messages using markdown formatting in the chat.',
- modelSelectorEnabled:
- 'Enable the model selector in the chat input to choose the inference model. Sends the associated model field in API requests.',
+ autoMicOnEmpty:
+ 'Automatically show microphone button instead of send button when textarea is empty for models with audio modality support.',
pyInterpreterEnabled:
'Enable Python interpreter using Pyodide. Allows running Python code in markdown code blocks.',
enableContinueGeneration:
diff --git a/tools/server/webui/src/lib/constants/supported-file-types.ts b/tools/server/webui/src/lib/constants/supported-file-types.ts
index 1258c3a059..93bbab5d39 100644
--- a/tools/server/webui/src/lib/constants/supported-file-types.ts
+++ b/tools/server/webui/src/lib/constants/supported-file-types.ts
@@ -16,7 +16,7 @@ import {
MimeTypeImage,
MimeTypeApplication,
MimeTypeText
-} from '$lib/enums/files';
+} from '$lib/enums';
// File type configuration using enums
export const AUDIO_FILE_TYPES = {
diff --git a/tools/server/webui/src/lib/enums/attachment.ts b/tools/server/webui/src/lib/enums/attachment.ts
new file mode 100644
index 0000000000..7c7d0da994
--- /dev/null
+++ b/tools/server/webui/src/lib/enums/attachment.ts
@@ -0,0 +1,10 @@
+/**
+ * Attachment type enum for database message extras
+ */
+export enum AttachmentType {
+ AUDIO = 'AUDIO',
+ IMAGE = 'IMAGE',
+ PDF = 'PDF',
+ TEXT = 'TEXT',
+ LEGACY_CONTEXT = 'context' // Legacy attachment type for backward compatibility
+}
diff --git a/tools/server/webui/src/lib/enums/files.ts b/tools/server/webui/src/lib/enums/files.ts
index 3f725da227..45b0feea16 100644
--- a/tools/server/webui/src/lib/enums/files.ts
+++ b/tools/server/webui/src/lib/enums/files.ts
@@ -32,10 +32,10 @@ export enum FileTypePdf {
export enum FileTypeText {
PLAIN_TEXT = 'plainText',
- MARKDOWN = 'markdown',
+ MARKDOWN = 'md',
ASCIIDOC = 'asciidoc',
- JAVASCRIPT = 'javascript',
- TYPESCRIPT = 'typescript',
+ JAVASCRIPT = 'js',
+ TYPESCRIPT = 'ts',
JSX = 'jsx',
TSX = 'tsx',
CSS = 'css',
diff --git a/tools/server/webui/src/lib/enums/index.ts b/tools/server/webui/src/lib/enums/index.ts
new file mode 100644
index 0000000000..d9e9001470
--- /dev/null
+++ b/tools/server/webui/src/lib/enums/index.ts
@@ -0,0 +1,21 @@
+export { AttachmentType } from './attachment';
+
+export {
+ FileTypeCategory,
+ FileTypeImage,
+ FileTypeAudio,
+ FileTypePdf,
+ FileTypeText,
+ FileExtensionImage,
+ FileExtensionAudio,
+ FileExtensionPdf,
+ FileExtensionText,
+ MimeTypeApplication,
+ MimeTypeAudio,
+ MimeTypeImage,
+ MimeTypeText
+} from './files';
+
+export { ModelModality } from './model';
+
+export { ServerRole, ServerModelStatus } from './server';
diff --git a/tools/server/webui/src/lib/enums/model.ts b/tools/server/webui/src/lib/enums/model.ts
new file mode 100644
index 0000000000..7729ecfeab
--- /dev/null
+++ b/tools/server/webui/src/lib/enums/model.ts
@@ -0,0 +1,5 @@
+export enum ModelModality {
+ TEXT = 'TEXT',
+ AUDIO = 'AUDIO',
+ VISION = 'VISION'
+}
diff --git a/tools/server/webui/src/lib/enums/server.ts b/tools/server/webui/src/lib/enums/server.ts
new file mode 100644
index 0000000000..7f30eab2cf
--- /dev/null
+++ b/tools/server/webui/src/lib/enums/server.ts
@@ -0,0 +1,20 @@
+/**
+ * Server role enum - used for single/multi-model mode
+ */
+export enum ServerRole {
+ /** Single model mode - server running with a specific model loaded */
+ MODEL = 'model',
+ /** Router mode - server managing multiple model instances */
+ ROUTER = 'router'
+}
+
+/**
+ * Model status enum - matches tools/server/server-models.h from C++ server
+ * Used as the `value` field in the status object from /models endpoint
+ */
+export enum ServerModelStatus {
+ UNLOADED = 'unloaded',
+ LOADING = 'loading',
+ LOADED = 'loaded',
+ FAILED = 'failed'
+}
diff --git a/tools/server/webui/src/lib/hooks/use-model-change-validation.svelte.ts b/tools/server/webui/src/lib/hooks/use-model-change-validation.svelte.ts
new file mode 100644
index 0000000000..bb666159c9
--- /dev/null
+++ b/tools/server/webui/src/lib/hooks/use-model-change-validation.svelte.ts
@@ -0,0 +1,118 @@
+import { modelsStore } from '$lib/stores/models.svelte';
+import { isRouterMode } from '$lib/stores/server.svelte';
+import { toast } from 'svelte-sonner';
+
+interface UseModelChangeValidationOptions {
+ /**
+ * Function to get required modalities for validation.
+ * For ChatForm: () => usedModalities() - all messages
+ * For ChatMessageAssistant: () => getModalitiesUpToMessage(messageId) - messages before
+ */
+ getRequiredModalities: () => ModelModalities;
+
+ /**
+ * Optional callback to execute after successful validation.
+ * For ChatForm: undefined - just select model
+ * For ChatMessageAssistant: (modelName) => onRegenerate(modelName)
+ */
+ onSuccess?: (modelName: string) => void;
+
+ /**
+ * Optional callback for rollback on validation failure.
+ * For ChatForm: (previousId) => selectModelById(previousId)
+ * For ChatMessageAssistant: undefined - no rollback needed
+ */
+ onValidationFailure?: (previousModelId: string | null) => Promise;
+}
+
+export function useModelChangeValidation(options: UseModelChangeValidationOptions) {
+ const { getRequiredModalities, onSuccess, onValidationFailure } = options;
+
+ let previousSelectedModelId: string | null = null;
+ const isRouter = $derived(isRouterMode());
+
+ async function handleModelChange(modelId: string, modelName: string): Promise {
+ try {
+ // Store previous selection for potential rollback
+ if (onValidationFailure) {
+ previousSelectedModelId = modelsStore.selectedModelId;
+ }
+
+ // Load model if not already loaded (router mode only)
+ let hasLoadedModel = false;
+ const isModelLoadedBefore = modelsStore.isModelLoaded(modelName);
+
+ if (isRouter && !isModelLoadedBefore) {
+ try {
+ await modelsStore.loadModel(modelName);
+ hasLoadedModel = true;
+ } catch {
+ toast.error(`Failed to load model "${modelName}"`);
+ return false;
+ }
+ }
+
+ // Fetch model props to validate modalities
+ const props = await modelsStore.fetchModelProps(modelName);
+
+ if (props?.modalities) {
+ const requiredModalities = getRequiredModalities();
+
+ // Check if model supports required modalities
+ const missingModalities: string[] = [];
+ if (requiredModalities.vision && !props.modalities.vision) {
+ missingModalities.push('vision');
+ }
+ if (requiredModalities.audio && !props.modalities.audio) {
+ missingModalities.push('audio');
+ }
+
+ if (missingModalities.length > 0) {
+ toast.error(
+ `Model "${modelName}" doesn't support required modalities: ${missingModalities.join(', ')}. Please select a different model.`
+ );
+
+ // Unload the model if we just loaded it
+ if (isRouter && hasLoadedModel) {
+ try {
+ await modelsStore.unloadModel(modelName);
+ } catch (error) {
+ console.error('Failed to unload incompatible model:', error);
+ }
+ }
+
+ // Execute rollback callback if provided
+ if (onValidationFailure && previousSelectedModelId) {
+ await onValidationFailure(previousSelectedModelId);
+ }
+
+ return false;
+ }
+ }
+
+ // Select the model (validation passed)
+ await modelsStore.selectModelById(modelId);
+
+ // Execute success callback if provided
+ if (onSuccess) {
+ onSuccess(modelName);
+ }
+
+ return true;
+ } catch (error) {
+ console.error('Failed to change model:', error);
+ toast.error('Failed to validate model capabilities');
+
+ // Execute rollback callback on error if provided
+ if (onValidationFailure && previousSelectedModelId) {
+ await onValidationFailure(previousSelectedModelId);
+ }
+
+ return false;
+ }
+ }
+
+ return {
+ handleModelChange
+ };
+}
diff --git a/tools/server/webui/src/lib/hooks/use-processing-state.svelte.ts b/tools/server/webui/src/lib/hooks/use-processing-state.svelte.ts
index e8c3aa1ae8..a861f23b48 100644
--- a/tools/server/webui/src/lib/hooks/use-processing-state.svelte.ts
+++ b/tools/server/webui/src/lib/hooks/use-processing-state.svelte.ts
@@ -1,4 +1,4 @@
-import { slotsService } from '$lib/services';
+import { activeProcessingState } from '$lib/stores/chat.svelte';
import { config } from '$lib/stores/settings.svelte';
export interface UseProcessingStateReturn {
@@ -6,7 +6,7 @@ export interface UseProcessingStateReturn {
getProcessingDetails(): string[];
getProcessingMessage(): string;
shouldShowDetails(): boolean;
- startMonitoring(): Promise;
+ startMonitoring(): void;
stopMonitoring(): void;
}
@@ -14,92 +14,71 @@ export interface UseProcessingStateReturn {
* useProcessingState - Reactive processing state hook
*
* This hook provides reactive access to the processing state of the server.
- * It subscribes to timing data updates from the slots service and provides
+ * It directly reads from chatStore's reactive state and provides
* formatted processing details for UI display.
*
* **Features:**
- * - Real-time processing state monitoring
+ * - Real-time processing state via direct reactive state binding
* - Context and output token tracking
* - Tokens per second calculation
- * - Graceful degradation when slots endpoint unavailable
- * - Automatic cleanup on component unmount
+ * - Automatic updates when streaming data arrives
+ * - Supports multiple concurrent conversations
*
* @returns Hook interface with processing state and control methods
*/
export function useProcessingState(): UseProcessingStateReturn {
let isMonitoring = $state(false);
- let processingState = $state(null);
let lastKnownState = $state(null);
- let unsubscribe: (() => void) | null = null;
- async function startMonitoring(): Promise {
- if (isMonitoring) return;
-
- isMonitoring = true;
-
- unsubscribe = slotsService.subscribe((state) => {
- processingState = state;
- if (state) {
- lastKnownState = state;
- } else {
- lastKnownState = null;
- }
- });
-
- try {
- const currentState = await slotsService.getCurrentState();
-
- if (currentState) {
- processingState = currentState;
- lastKnownState = currentState;
- }
-
- if (slotsService.isStreaming()) {
- slotsService.startStreaming();
- }
- } catch (error) {
- console.warn('Failed to start slots monitoring:', error);
- // Continue without slots monitoring - graceful degradation
+ // Derive processing state reactively from chatStore's direct state
+ const processingState = $derived.by(() => {
+ if (!isMonitoring) {
+ return lastKnownState;
}
+ // Read directly from the reactive state export
+ return activeProcessingState();
+ });
+
+ // Track last known state for keepStatsVisible functionality
+ $effect(() => {
+ if (processingState && isMonitoring) {
+ lastKnownState = processingState;
+ }
+ });
+
+ function startMonitoring(): void {
+ if (isMonitoring) return;
+ isMonitoring = true;
}
function stopMonitoring(): void {
if (!isMonitoring) return;
-
isMonitoring = false;
- // Only clear processing state if keepStatsVisible is disabled
- // This preserves the last known state for display when stats should remain visible
+ // Only clear last known state if keepStatsVisible is disabled
const currentConfig = config();
if (!currentConfig.keepStatsVisible) {
- processingState = null;
- } else if (lastKnownState) {
- // Keep the last known state visible when keepStatsVisible is enabled
- processingState = lastKnownState;
- }
-
- if (unsubscribe) {
- unsubscribe();
- unsubscribe = null;
+ lastKnownState = null;
}
}
function getProcessingMessage(): string {
- if (!processingState) {
+ const state = processingState;
+ if (!state) {
return 'Processing...';
}
- switch (processingState.status) {
+ switch (state.status) {
case 'initializing':
return 'Initializing...';
case 'preparing':
- if (processingState.progressPercent !== undefined) {
- return `Processing (${processingState.progressPercent}%)`;
+ if (state.progressPercent !== undefined) {
+ return `Processing (${state.progressPercent}%)`;
}
return 'Preparing response...';
case 'generating':
- if (processingState.tokensDecoded > 0) {
- return `Generating... (${processingState.tokensDecoded} tokens)`;
+ if (state.tokensDecoded > 0) {
+ return `Generating... (${state.tokensDecoded} tokens)`;
}
return 'Generating...';
default:
@@ -115,7 +94,6 @@ export function useProcessingState(): UseProcessingStateReturn {
}
const details: string[] = [];
- const currentConfig = config(); // Get fresh config each time
// Always show context info when we have valid data
if (stateToUse.contextUsed >= 0 && stateToUse.contextTotal > 0) {
@@ -141,11 +119,7 @@ export function useProcessingState(): UseProcessingStateReturn {
}
}
- if (
- currentConfig.showTokensPerSecond &&
- stateToUse.tokensPerSecond &&
- stateToUse.tokensPerSecond > 0
- ) {
+ if (stateToUse.tokensPerSecond && stateToUse.tokensPerSecond > 0) {
details.push(`${stateToUse.tokensPerSecond.toFixed(1)} tokens/sec`);
}
@@ -157,7 +131,8 @@ export function useProcessingState(): UseProcessingStateReturn {
}
function shouldShowDetails(): boolean {
- return processingState !== null && processingState.status !== 'idle';
+ const state = processingState;
+ return state !== null && state.status !== 'idle';
}
return {
diff --git a/tools/server/webui/src/lib/services/chat.ts b/tools/server/webui/src/lib/services/chat.ts
index 66134a524e..1525885db8 100644
--- a/tools/server/webui/src/lib/services/chat.ts
+++ b/tools/server/webui/src/lib/services/chat.ts
@@ -1,55 +1,42 @@
-import { config } from '$lib/stores/settings.svelte';
-import { selectedModelName } from '$lib/stores/models.svelte';
-import { slotsService } from './slots';
-import type {
- ApiChatCompletionRequest,
- ApiChatCompletionResponse,
- ApiChatCompletionStreamChunk,
- ApiChatCompletionToolCall,
- ApiChatCompletionToolCallDelta,
- ApiChatMessageData
-} from '$lib/types/api';
-import type {
- DatabaseMessage,
- DatabaseMessageExtra,
- DatabaseMessageExtraAudioFile,
- DatabaseMessageExtraImageFile,
- DatabaseMessageExtraLegacyContext,
- DatabaseMessageExtraPdfFile,
- DatabaseMessageExtraTextFile
-} from '$lib/types/database';
-import type { ChatMessagePromptProgress, ChatMessageTimings } from '$lib/types/chat';
-import type { SettingsChatServiceOptions } from '$lib/types/settings';
+import { getJsonHeaders } from '$lib/utils';
+import { AttachmentType } from '$lib/enums';
+
/**
- * ChatService - Low-level API communication layer for llama.cpp server interactions
+ * ChatService - Low-level API communication layer for Chat Completions
*
- * This service handles direct communication with the llama.cpp server's chat completion API.
+ * **Terminology - Chat vs Conversation:**
+ * - **Chat**: The active interaction space with the Chat Completions API. This service
+ * handles the real-time communication with the AI backend - sending messages, receiving
+ * streaming responses, and managing request lifecycles. "Chat" is ephemeral and runtime-focused.
+ * - **Conversation**: The persistent database entity storing all messages and metadata.
+ * Managed by ConversationsService/Store, conversations persist across sessions.
+ *
+ * This service handles direct communication with the llama-server's Chat Completions API.
* It provides the network layer abstraction for AI model interactions while remaining
* stateless and focused purely on API communication.
*
- * **Architecture & Relationship with ChatStore:**
+ * **Architecture & Relationships:**
* - **ChatService** (this class): Stateless API communication layer
- * - Handles HTTP requests/responses with llama.cpp server
+ * - Handles HTTP requests/responses with the llama-server
* - Manages streaming and non-streaming response parsing
- * - Provides request abortion capabilities
+ * - Provides per-conversation request abortion capabilities
* - Converts database messages to API format
* - Handles error translation for server responses
*
- * - **ChatStore**: Stateful orchestration and UI state management
- * - Uses ChatService for all AI model communication
- * - Manages conversation state, message history, and UI reactivity
- * - Coordinates with DatabaseStore for persistence
- * - Handles complex workflows like branching and regeneration
+ * - **chatStore**: Uses ChatService for all AI model communication
+ * - **conversationsStore**: Provides message context for API requests
*
* **Key Responsibilities:**
* - Message format conversion (DatabaseMessage → API format)
* - Streaming response handling with real-time callbacks
* - Reasoning content extraction and processing
* - File attachment processing (images, PDFs, audio, text)
- * - Request lifecycle management (abort, cleanup)
+ * - Request lifecycle management (abort via AbortSignal)
*/
export class ChatService {
- private abortControllers: Map = new Map();
+ // ─────────────────────────────────────────────────────────────────────────────
+ // Messaging
+ // ─────────────────────────────────────────────────────────────────────────────
/**
* Sends a chat completion request to the llama.cpp server.
@@ -61,10 +48,11 @@ export class ChatService {
* @returns {Promise} that resolves to the complete response string (non-streaming) or void (streaming)
* @throws {Error} if the request fails or is aborted
*/
- async sendMessage(
+ static async sendMessage(
messages: ApiChatMessageData[] | (DatabaseMessage & { extra?: DatabaseMessageExtra[] })[],
options: SettingsChatServiceOptions = {},
- conversationId?: string
+ conversationId?: string,
+ signal?: AbortSignal
): Promise {
const {
stream,
@@ -74,7 +62,7 @@ export class ChatService {
onReasoningChunk,
onToolCallChunk,
onModel,
- onFirstValidChunk,
+ onTimings,
// Generation parameters
temperature,
max_tokens,
@@ -100,25 +88,17 @@ export class ChatService {
samplers,
backend_sampling,
custom,
- timings_per_token
+ timings_per_token,
+ // Config options
+ systemMessage,
+ disableReasoningFormat
} = options;
- const currentConfig = config();
-
- const requestId = conversationId || 'default';
-
- if (this.abortControllers.has(requestId)) {
- this.abortControllers.get(requestId)?.abort();
- }
-
- const abortController = new AbortController();
- this.abortControllers.set(requestId, abortController);
-
const normalizedMessages: ApiChatMessageData[] = messages
.map((msg) => {
if ('id' in msg && 'convId' in msg && 'timestamp' in msg) {
const dbMsg = msg as DatabaseMessage & { extra?: DatabaseMessageExtra[] };
- return ChatService.convertMessageToChatServiceData(dbMsg);
+ return ChatService.convertDbMessageToApiChatMessageData(dbMsg);
} else {
return msg as ApiChatMessageData;
}
@@ -133,7 +113,7 @@ export class ChatService {
return true;
});
- const processedMessages = this.injectSystemMessage(normalizedMessages);
+ const processedMessages = ChatService.injectSystemMessage(normalizedMessages, systemMessage);
const requestBody: ApiChatCompletionRequest = {
messages: processedMessages.map((msg: ApiChatMessageData) => ({
@@ -143,14 +123,12 @@ export class ChatService {
stream
};
- const modelSelectorEnabled = Boolean(currentConfig.modelSelectorEnabled);
- const activeModel = modelSelectorEnabled ? selectedModelName() : null;
-
- if (modelSelectorEnabled && activeModel) {
- requestBody.model = activeModel;
+ // Include model in request if provided (required in ROUTER mode)
+ if (options.model) {
+ requestBody.model = options.model;
}
- requestBody.reasoning_format = currentConfig.disableReasoningFormat ? 'none' : 'auto';
+ requestBody.reasoning_format = disableReasoningFormat ? 'none' : 'auto';
if (temperature !== undefined) requestBody.temperature = temperature;
if (max_tokens !== undefined) {
@@ -197,20 +175,15 @@ export class ChatService {
}
try {
- const apiKey = currentConfig.apiKey?.toString().trim();
-
const response = await fetch(`./v1/chat/completions`, {
method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {})
- },
+ headers: getJsonHeaders(),
body: JSON.stringify(requestBody),
- signal: abortController.signal
+ signal
});
if (!response.ok) {
- const error = await this.parseErrorResponse(response);
+ const error = await ChatService.parseErrorResponse(response);
if (onError) {
onError(error);
}
@@ -218,7 +191,7 @@ export class ChatService {
}
if (stream) {
- await this.handleStreamResponse(
+ await ChatService.handleStreamResponse(
response,
onChunk,
onComplete,
@@ -226,13 +199,13 @@ export class ChatService {
onReasoningChunk,
onToolCallChunk,
onModel,
- onFirstValidChunk,
+ onTimings,
conversationId,
- abortController.signal
+ signal
);
return;
} else {
- return this.handleNonStreamResponse(
+ return ChatService.handleNonStreamResponse(
response,
onComplete,
onError,
@@ -272,11 +245,13 @@ export class ChatService {
onError(userFriendlyError);
}
throw userFriendlyError;
- } finally {
- this.abortControllers.delete(requestId);
}
}
+ // ─────────────────────────────────────────────────────────────────────────────
+ // Streaming
+ // ─────────────────────────────────────────────────────────────────────────────
+
/**
* Handles streaming response from the chat completion API
* @param response - The Response object from the fetch request
@@ -288,7 +263,7 @@ export class ChatService {
* @returns {Promise} Promise that resolves when streaming is complete
* @throws {Error} if the stream cannot be read or parsed
*/
- private async handleStreamResponse(
+ private static async handleStreamResponse(
response: Response,
onChunk?: (chunk: string) => void,
onComplete?: (
@@ -301,7 +276,7 @@ export class ChatService {
onReasoningChunk?: (chunk: string) => void,
onToolCallChunk?: (chunk: string) => void,
onModel?: (model: string) => void,
- onFirstValidChunk?: () => void,
+ onTimings?: (timings: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => void,
conversationId?: string,
abortSignal?: AbortSignal
): Promise {
@@ -318,7 +293,6 @@ export class ChatService {
let lastTimings: ChatMessageTimings | undefined;
let streamFinished = false;
let modelEmitted = false;
- let firstValidChunkEmitted = false;
let toolCallIndexOffset = 0;
let hasOpenToolCallBatch = false;
@@ -336,7 +310,7 @@ export class ChatService {
return;
}
- aggregatedToolCalls = this.mergeToolCallDeltas(
+ aggregatedToolCalls = ChatService.mergeToolCallDeltas(
aggregatedToolCalls,
toolCalls,
toolCallIndexOffset
@@ -385,29 +359,20 @@ export class ChatService {
try {
const parsed: ApiChatCompletionStreamChunk = JSON.parse(data);
-
- if (!firstValidChunkEmitted && parsed.object === 'chat.completion.chunk') {
- firstValidChunkEmitted = true;
-
- if (!abortSignal?.aborted) {
- onFirstValidChunk?.();
- }
- }
-
const content = parsed.choices[0]?.delta?.content;
const reasoningContent = parsed.choices[0]?.delta?.reasoning_content;
const toolCalls = parsed.choices[0]?.delta?.tool_calls;
const timings = parsed.timings;
const promptProgress = parsed.prompt_progress;
- const chunkModel = this.extractModelName(parsed);
+ const chunkModel = ChatService.extractModelName(parsed);
if (chunkModel && !modelEmitted) {
modelEmitted = true;
onModel?.(chunkModel);
}
if (timings || promptProgress) {
- this.updateProcessingState(timings, promptProgress, conversationId);
+ ChatService.notifyTimings(timings, promptProgress, onTimings);
if (timings) {
lastTimings = timings;
}
@@ -465,7 +430,91 @@ export class ChatService {
}
}
- private mergeToolCallDeltas(
+ /**
+ * Handles non-streaming response from the chat completion API.
+ * Parses the JSON response and extracts the generated content.
+ *
+ * @param response - The fetch Response object containing the JSON data
+ * @param onComplete - Optional callback invoked when response is successfully parsed
+ * @param onError - Optional callback invoked if an error occurs during parsing
+ * @returns {Promise} Promise that resolves to the generated content string
+ * @throws {Error} if the response cannot be parsed or is malformed
+ */
+ private static async handleNonStreamResponse(
+ response: Response,
+ onComplete?: (
+ response: string,
+ reasoningContent?: string,
+ timings?: ChatMessageTimings,
+ toolCalls?: string
+ ) => void,
+ onError?: (error: Error) => void,
+ onToolCallChunk?: (chunk: string) => void,
+ onModel?: (model: string) => void
+ ): Promise {
+ try {
+ const responseText = await response.text();
+
+ if (!responseText.trim()) {
+ const noResponseError = new Error('No response received from server. Please try again.');
+ throw noResponseError;
+ }
+
+ const data: ApiChatCompletionResponse = JSON.parse(responseText);
+
+ const responseModel = ChatService.extractModelName(data);
+ if (responseModel) {
+ onModel?.(responseModel);
+ }
+
+ const content = data.choices[0]?.message?.content || '';
+ const reasoningContent = data.choices[0]?.message?.reasoning_content;
+ const toolCalls = data.choices[0]?.message?.tool_calls;
+
+ if (reasoningContent) {
+ console.log('Full reasoning content:', reasoningContent);
+ }
+
+ let serializedToolCalls: string | undefined;
+
+ if (toolCalls && toolCalls.length > 0) {
+ const mergedToolCalls = ChatService.mergeToolCallDeltas([], toolCalls);
+
+ if (mergedToolCalls.length > 0) {
+ serializedToolCalls = JSON.stringify(mergedToolCalls);
+ if (serializedToolCalls) {
+ onToolCallChunk?.(serializedToolCalls);
+ }
+ }
+ }
+
+ if (!content.trim() && !serializedToolCalls) {
+ const noResponseError = new Error('No response received from server. Please try again.');
+ throw noResponseError;
+ }
+
+ onComplete?.(content, reasoningContent, undefined, serializedToolCalls);
+
+ return content;
+ } catch (error) {
+ const err = error instanceof Error ? error : new Error('Parse error');
+
+ onError?.(err);
+
+ throw err;
+ }
+ }
+
+ /**
+ * Merges tool call deltas into an existing array of tool calls.
+ * Handles both existing and new tool calls, updating existing ones and adding new ones.
+ *
+ * @param existing - The existing array of tool calls to merge into
+ * @param deltas - The array of tool call deltas to merge
+ * @param indexOffset - Optional offset to apply to the index of new tool calls
+ * @returns {ApiChatCompletionToolCall[]} The merged array of tool calls
+ */
+ private static mergeToolCallDeltas(
existing: ApiChatCompletionToolCall[],
deltas: ApiChatCompletionToolCallDelta[],
indexOffset = 0
@@ -513,80 +562,9 @@ export class ChatService {
return result;
}
- /**
- * Handles non-streaming response from the chat completion API.
- * Parses the JSON response and extracts the generated content.
- *
- * @param response - The fetch Response object containing the JSON data
- * @param onComplete - Optional callback invoked when response is successfully parsed
- * @param onError - Optional callback invoked if an error occurs during parsing
- * @returns {Promise} Promise that resolves to the generated content string
- * @throws {Error} if the response cannot be parsed or is malformed
- */
- private async handleNonStreamResponse(
- response: Response,
- onComplete?: (
- response: string,
- reasoningContent?: string,
- timings?: ChatMessageTimings,
- toolCalls?: string
- ) => void,
- onError?: (error: Error) => void,
- onToolCallChunk?: (chunk: string) => void,
- onModel?: (model: string) => void
- ): Promise {
- try {
- const responseText = await response.text();
-
- if (!responseText.trim()) {
- const noResponseError = new Error('No response received from server. Please try again.');
- throw noResponseError;
- }
-
- const data: ApiChatCompletionResponse = JSON.parse(responseText);
-
- const responseModel = this.extractModelName(data);
- if (responseModel) {
- onModel?.(responseModel);
- }
-
- const content = data.choices[0]?.message?.content || '';
- const reasoningContent = data.choices[0]?.message?.reasoning_content;
- const toolCalls = data.choices[0]?.message?.tool_calls;
-
- if (reasoningContent) {
- console.log('Full reasoning content:', reasoningContent);
- }
-
- let serializedToolCalls: string | undefined;
-
- if (toolCalls && toolCalls.length > 0) {
- const mergedToolCalls = this.mergeToolCallDeltas([], toolCalls);
-
- if (mergedToolCalls.length > 0) {
- serializedToolCalls = JSON.stringify(mergedToolCalls);
- if (serializedToolCalls) {
- onToolCallChunk?.(serializedToolCalls);
- }
- }
- }
-
- if (!content.trim() && !serializedToolCalls) {
- const noResponseError = new Error('No response received from server. Please try again.');
- throw noResponseError;
- }
-
- onComplete?.(content, reasoningContent, undefined, serializedToolCalls);
-
- return content;
- } catch (error) {
- const err = error instanceof Error ? error : new Error('Parse error');
-
- onError?.(err);
-
- throw err;
- }
- }
+ // ─────────────────────────────────────────────────────────────────────────────
+ // Conversion
+ // ─────────────────────────────────────────────────────────────────────────────
/**
* Converts a database message with attachments to API chat message format.
@@ -600,7 +578,7 @@ export class ChatService {
* @returns {ApiChatMessageData} object formatted for the chat completion API
* @static
*/
- static convertMessageToChatServiceData(
+ static convertDbMessageToApiChatMessageData(
message: DatabaseMessage & { extra?: DatabaseMessageExtra[] }
): ApiChatMessageData {
if (!message.extra || message.extra.length === 0) {
@@ -621,7 +599,7 @@ export class ChatService {
const imageFiles = message.extra.filter(
(extra: DatabaseMessageExtra): extra is DatabaseMessageExtraImageFile =>
- extra.type === 'imageFile'
+ extra.type === AttachmentType.IMAGE
);
for (const image of imageFiles) {
@@ -633,7 +611,7 @@ export class ChatService {
const textFiles = message.extra.filter(
(extra: DatabaseMessageExtra): extra is DatabaseMessageExtraTextFile =>
- extra.type === 'textFile'
+ extra.type === AttachmentType.TEXT
);
for (const textFile of textFiles) {
@@ -646,7 +624,7 @@ export class ChatService {
// Handle legacy 'context' type from old webui (pasted content)
const legacyContextFiles = message.extra.filter(
(extra: DatabaseMessageExtra): extra is DatabaseMessageExtraLegacyContext =>
- extra.type === 'context'
+ extra.type === AttachmentType.LEGACY_CONTEXT
);
for (const legacyContextFile of legacyContextFiles) {
@@ -658,7 +636,7 @@ export class ChatService {
const audioFiles = message.extra.filter(
(extra: DatabaseMessageExtra): extra is DatabaseMessageExtraAudioFile =>
- extra.type === 'audioFile'
+ extra.type === AttachmentType.AUDIO
);
for (const audio of audioFiles) {
@@ -673,7 +651,7 @@ export class ChatService {
const pdfFiles = message.extra.filter(
(extra: DatabaseMessageExtra): extra is DatabaseMessageExtraPdfFile =>
- extra.type === 'pdfFile'
+ extra.type === AttachmentType.PDF
);
for (const pdfFile of pdfFiles) {
@@ -698,19 +676,17 @@ export class ChatService {
};
}
+ // ─────────────────────────────────────────────────────────────────────────────
+ // Utilities
+ // ─────────────────────────────────────────────────────────────────────────────
+
/**
- * Get server properties - static method for API compatibility
+ * Get server properties - static method for API compatibility (to be refactored)
*/
static async getServerProps(): Promise {
try {
- const currentConfig = config();
- const apiKey = currentConfig.apiKey?.toString().trim();
-
const response = await fetch(`./props`, {
- headers: {
- 'Content-Type': 'application/json',
- ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {})
- }
+ headers: getJsonHeaders()
});
if (!response.ok) {
@@ -726,49 +702,51 @@ export class ChatService {
}
/**
- * Aborts any ongoing chat completion request.
- * Cancels the current request and cleans up the abort controller.
- *
- * @public
+ * Get model information from /models endpoint (to be refactored)
*/
- public abort(conversationId?: string): void {
- if (conversationId) {
- const abortController = this.abortControllers.get(conversationId);
- if (abortController) {
- abortController.abort();
- this.abortControllers.delete(conversationId);
+ static async getModels(): Promise {
+ try {
+ const response = await fetch(`./models`, {
+ headers: getJsonHeaders()
+ });
+
+ if (!response.ok) {
+ throw new Error(`Failed to fetch models: ${response.status} ${response.statusText}`);
}
- } else {
- for (const controller of this.abortControllers.values()) {
- controller.abort();
- }
- this.abortControllers.clear();
+
+ const data = await response.json();
+ return data;
+ } catch (error) {
+ console.error('Error fetching models:', error);
+ throw error;
}
}
/**
- * Injects a system message at the beginning of the conversation if configured in settings.
- * Checks for existing system messages to avoid duplication and retrieves the system message
- * from the current configuration settings.
+ * Injects a system message at the beginning of the conversation if provided.
+ * Checks for existing system messages to avoid duplication.
*
* @param messages - Array of chat messages to process
- * @returns Array of messages with system message injected at the beginning if configured
+ * @param systemMessage - Optional system message to inject
+ * @returns Array of messages with system message injected at the beginning if provided
* @private
*/
- private injectSystemMessage(messages: ApiChatMessageData[]): ApiChatMessageData[] {
- const currentConfig = config();
- const systemMessage = currentConfig.systemMessage?.toString().trim();
+ private static injectSystemMessage(
+ messages: ApiChatMessageData[],
+ systemMessage?: string
+ ): ApiChatMessageData[] {
+ const trimmedSystemMessage = systemMessage?.trim();
- if (!systemMessage) {
+ if (!trimmedSystemMessage) {
return messages;
}
if (messages.length > 0 && messages[0].role === 'system') {
- if (messages[0].content !== systemMessage) {
+ if (messages[0].content !== trimmedSystemMessage) {
const updatedMessages = [...messages];
updatedMessages[0] = {
role: 'system',
- content: systemMessage
+ content: trimmedSystemMessage
};
return updatedMessages;
}
@@ -778,7 +756,7 @@ export class ChatService {
const systemMsg: ApiChatMessageData = {
role: 'system',
- content: systemMessage
+ content: trimmedSystemMessage
};
return [systemMsg, ...messages];
@@ -789,7 +767,7 @@ export class ChatService {
* @param response - HTTP response object
* @returns Promise - Parsed error with context info if available
*/
- private async parseErrorResponse(response: Response): Promise {
+ private static async parseErrorResponse(response: Response): Promise {
try {
const errorText = await response.text();
const errorData: ApiErrorResponse = JSON.parse(errorText);
@@ -806,7 +784,18 @@ export class ChatService {
}
}
- private extractModelName(data: unknown): string | undefined {
+ /**
+ * Extracts model name from Chat Completions API response data.
+ * Handles various response formats including streaming chunks and final responses.
+ *
+ * WORKAROUND: In single model mode, llama-server returns a default/incorrect model name
+ * in the response. We override it with the actual model name from serverStore.
+ *
+ * @param data - Raw response data from the Chat Completions API
+ * @returns Model name string if found, undefined otherwise
+ * @private
+ */
+ private static extractModelName(data: unknown): string | undefined {
const asRecord = (value: unknown): Record | undefined => {
return typeof value === 'object' && value !== null
? (value as Record)
@@ -839,31 +828,22 @@ export class ChatService {
return undefined;
}
- private updateProcessingState(
- timings?: ChatMessageTimings,
- promptProgress?: ChatMessagePromptProgress,
- conversationId?: string
+ /**
+ * Calls the onTimings callback with timing data from streaming response.
+ *
+ * @param timings - Timing information from the Chat Completions API response
+ * @param promptProgress - Prompt processing progress data
+ * @param onTimingsCallback - Callback function to invoke with timing data
+ * @private
+ */
+ private static notifyTimings(
+ timings: ChatMessageTimings | undefined,
+ promptProgress: ChatMessagePromptProgress | undefined,
+ onTimingsCallback:
+ | ((timings: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => void)
+ | undefined
): void {
- const tokensPerSecond =
- timings?.predicted_ms && timings?.predicted_n
- ? (timings.predicted_n / timings.predicted_ms) * 1000
- : 0;
-
- slotsService
- .updateFromTimingData(
- {
- prompt_n: timings?.prompt_n || 0,
- predicted_n: timings?.predicted_n || 0,
- predicted_per_second: tokensPerSecond,
- cache_n: timings?.cache_n || 0,
- prompt_progress: promptProgress
- },
- conversationId
- )
- .catch((error) => {
- console.warn('Failed to update processing state:', error);
- });
+ if (!timings || !onTimingsCallback) return;
+ onTimingsCallback(timings, promptProgress);
}
}
-
-export const chatService = new ChatService();
diff --git a/tools/server/webui/src/lib/stores/database.ts b/tools/server/webui/src/lib/services/database.ts
similarity index 68%
rename from tools/server/webui/src/lib/stores/database.ts
rename to tools/server/webui/src/lib/services/database.ts
index 82edcc3227..185a598c3b 100644
--- a/tools/server/webui/src/lib/stores/database.ts
+++ b/tools/server/webui/src/lib/services/database.ts
@@ -1,5 +1,5 @@
import Dexie, { type EntityTable } from 'dexie';
-import { filterByLeafNodeId, findDescendantMessages } from '$lib/utils/branching';
+import { findDescendantMessages } from '$lib/utils';
class LlamacppDatabase extends Dexie {
conversations!: EntityTable;
@@ -16,60 +16,59 @@ class LlamacppDatabase extends Dexie {
}
const db = new LlamacppDatabase();
+import { v4 as uuid } from 'uuid';
/**
- * DatabaseStore - Persistent data layer for conversation and message management
+ * DatabaseService - Stateless IndexedDB communication layer
*
- * This service provides a comprehensive data access layer built on IndexedDB using Dexie.
- * It handles all persistent storage operations for conversations, messages, and application settings
- * with support for complex conversation branching and message threading.
+ * **Terminology - Chat vs Conversation:**
+ * - **Chat**: The active interaction space with the Chat Completions API (ephemeral, runtime).
+ * - **Conversation**: The persistent database entity storing all messages and metadata.
+ * This service handles raw database operations for conversations - the lowest layer
+ * in the persistence stack.
*
- * **Architecture & Relationships:**
- * - **DatabaseStore** (this class): Stateless data persistence layer
- * - Manages IndexedDB operations through Dexie ORM
- * - Handles conversation and message CRUD operations
- * - Supports complex branching with parent-child relationships
+ * This service provides a stateless data access layer built on IndexedDB using Dexie ORM.
+ * It handles all low-level storage operations for conversations and messages with support
+ * for complex branching and message threading. All methods are static - no instance state.
+ *
+ * **Architecture & Relationships (bottom to top):**
+ * - **DatabaseService** (this class): Stateless IndexedDB operations
+ * - Lowest layer - direct Dexie/IndexedDB communication
+ * - Pure CRUD operations without business logic
+ * - Handles branching tree structure (parent-child relationships)
* - Provides transaction safety for multi-table operations
*
- * - **ChatStore**: Primary consumer for conversation state management
- * - Uses DatabaseStore for all persistence operations
- * - Coordinates UI state with database state
- * - Handles conversation lifecycle and message branching
+ * - **ConversationsService**: Stateless business logic layer
+ * - Uses DatabaseService for all persistence operations
+ * - Adds import/export, navigation, and higher-level operations
+ *
+ * - **conversationsStore**: Reactive state management for conversations
+ * - Uses ConversationsService for database operations
+ * - Manages conversation list, active conversation, and messages in memory
+ *
+ * - **chatStore**: Active AI interaction management
+ * - Uses conversationsStore for conversation context
+ * - Directly uses DatabaseService for message CRUD during streaming
*
* **Key Features:**
- * - **Conversation Management**: Create, read, update, delete conversations
- * - **Message Branching**: Support for tree-like conversation structures
+ * - **Conversation CRUD**: Create, read, update, delete conversations
+ * - **Message CRUD**: Add, update, delete messages with branching support
+ * - **Branch Operations**: Create branches, find descendants, cascade deletions
* - **Transaction Safety**: Atomic operations for data consistency
- * - **Path Resolution**: Navigate conversation branches and find leaf nodes
- * - **Cascading Deletion**: Remove entire conversation branches
*
* **Database Schema:**
- * - `conversations`: Conversation metadata with current node tracking
- * - `messages`: Individual messages with parent-child relationships
+ * - `conversations`: id, lastModified, currNode, name
+ * - `messages`: id, convId, type, role, timestamp, parent, children
*
* **Branching Model:**
* Messages form a tree structure where each message can have multiple children,
* enabling conversation branching and alternative response paths. The conversation's
* `currNode` tracks the currently active branch endpoint.
*/
-import { v4 as uuid } from 'uuid';
-
-export class DatabaseStore {
- /**
- * Adds a new message to the database.
- *
- * @param message - Message to add (without id)
- * @returns The created message
- */
- static async addMessage(message: Omit): Promise {
- const newMessage: DatabaseMessage = {
- ...message,
- id: uuid()
- };
-
- await db.messages.add(newMessage);
- return newMessage;
- }
+export class DatabaseService {
+ // ─────────────────────────────────────────────────────────────────────────────
+ // Conversations
+ // ─────────────────────────────────────────────────────────────────────────────
/**
* Creates a new conversation.
@@ -89,6 +88,10 @@ export class DatabaseStore {
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.
@@ -255,18 +258,6 @@ export class DatabaseStore {
return await db.conversations.get(id);
}
- /**
- * Gets all leaf nodes (messages with no children) in a conversation.
- * Useful for finding all possible conversation endpoints.
- *
- * @param convId - Conversation ID
- * @returns Array of leaf node message IDs
- */
- static async getConversationLeafNodes(convId: string): Promise {
- const allMessages = await this.getConversationMessages(convId);
- return allMessages.filter((msg) => msg.children.length === 0).map((msg) => msg.id);
- }
-
/**
* Gets all messages in a conversation, sorted by timestamp (oldest first).
*
@@ -277,34 +268,6 @@ export class DatabaseStore {
return await db.messages.where('convId').equals(convId).sortBy('timestamp');
}
- /**
- * Gets the conversation path from root to the current leaf node.
- * Uses the conversation's currNode to determine the active branch.
- *
- * @param convId - Conversation ID
- * @returns Array of messages in the current conversation path
- */
- static async getConversationPath(convId: string): Promise {
- const conversation = await this.getConversation(convId);
-
- if (!conversation) {
- return [];
- }
-
- const allMessages = await this.getConversationMessages(convId);
-
- if (allMessages.length === 0) {
- return [];
- }
-
- // If no currNode is set, use the latest message as leaf
- const leafNodeId =
- conversation.currNode ||
- allMessages.reduce((latest, msg) => (msg.timestamp > latest.timestamp ? msg : latest)).id;
-
- return filterByLeafNodeId(allMessages, leafNodeId, false) as DatabaseMessage[];
- }
-
/**
* Updates a conversation.
*
@@ -322,6 +285,10 @@ export class DatabaseStore {
});
}
+ // ─────────────────────────────────────────────────────────────────────────────
+ // Navigation
+ // ─────────────────────────────────────────────────────────────────────────────
+
/**
* Updates the conversation's current node (active branch).
* This determines which conversation path is currently being viewed.
@@ -349,6 +316,10 @@ export class DatabaseStore {
await db.messages.update(id, updates);
}
+ // ─────────────────────────────────────────────────────────────────────────────
+ // Import
+ // ─────────────────────────────────────────────────────────────────────────────
+
/**
* Imports multiple conversations and their messages.
* Skips conversations that already exist.
diff --git a/tools/server/webui/src/lib/services/index.ts b/tools/server/webui/src/lib/services/index.ts
index 9a9774bd56..c36c64a6fa 100644
--- a/tools/server/webui/src/lib/services/index.ts
+++ b/tools/server/webui/src/lib/services/index.ts
@@ -1,2 +1,5 @@
-export { chatService } from './chat';
-export { slotsService } from './slots';
+export { ChatService } from './chat';
+export { DatabaseService } from './database';
+export { ModelsService } from './models';
+export { PropsService } from './props';
+export { ParameterSyncService } from './parameter-sync';
diff --git a/tools/server/webui/src/lib/services/models.ts b/tools/server/webui/src/lib/services/models.ts
index 1c7fa3b456..f031bd7497 100644
--- a/tools/server/webui/src/lib/services/models.ts
+++ b/tools/server/webui/src/lib/services/models.ts
@@ -1,16 +1,34 @@
import { base } from '$app/paths';
-import { config } from '$lib/stores/settings.svelte';
-import type { ApiModelListResponse } from '$lib/types/api';
+import { ServerModelStatus } from '$lib/enums';
+import { getJsonHeaders } from '$lib/utils';
+/**
+ * ModelsService - Stateless service for model management API communication
+ *
+ * This service handles communication with model-related endpoints:
+ * - `/v1/models` - OpenAI-compatible model list (MODEL + ROUTER mode)
+ * - `/models` - Router-specific model management (ROUTER mode only)
+ *
+ * **Responsibilities:**
+ * - List available models
+ * - Load/unload models (ROUTER mode)
+ * - Check model status (ROUTER mode)
+ *
+ * **Used by:**
+ * - modelsStore: Primary consumer for model state management
+ */
export class ModelsService {
- static async list(): Promise {
- const currentConfig = config();
- const apiKey = currentConfig.apiKey?.toString().trim();
+ // ─────────────────────────────────────────────────────────────────────────────
+ // Listing
+ // ─────────────────────────────────────────────────────────────────────────────
+ /**
+ * Fetch list of models from OpenAI-compatible endpoint
+ * Works in both MODEL and ROUTER modes
+ */
+ static async list(): Promise {
const response = await fetch(`${base}/v1/models`, {
- headers: {
- ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {})
- }
+ headers: getJsonHeaders()
});
if (!response.ok) {
@@ -19,4 +37,88 @@ export class ModelsService {
return response.json() as Promise;
}
+
+ /**
+ * Fetch list of all models with detailed metadata (ROUTER mode)
+ * Returns models with load status, paths, and other metadata
+ */
+ static async listRouter(): Promise {
+ const response = await fetch(`${base}/models`, {
+ headers: getJsonHeaders()
+ });
+
+ if (!response.ok) {
+ throw new Error(`Failed to fetch router models list (status ${response.status})`);
+ }
+
+ return response.json() as Promise;
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────────
+ // Load/Unload
+ // ─────────────────────────────────────────────────────────────────────────────
+
+ /**
+ * Load a model (ROUTER mode)
+ * POST /models/load
+ * @param modelId - Model identifier to load
+ * @param extraArgs - Optional additional arguments to pass to the model instance
+ */
+ static async load(modelId: string, extraArgs?: string[]): Promise {
+ const payload: { model: string; extra_args?: string[] } = { model: modelId };
+ if (extraArgs && extraArgs.length > 0) {
+ payload.extra_args = extraArgs;
+ }
+
+ const response = await fetch(`${base}/models/load`, {
+ method: 'POST',
+ headers: getJsonHeaders(),
+ body: JSON.stringify(payload)
+ });
+
+ if (!response.ok) {
+ const errorData = await response.json().catch(() => ({}));
+ throw new Error(errorData.error || `Failed to load model (status ${response.status})`);
+ }
+
+ return response.json() as Promise;
+ }
+
+ /**
+ * Unload a model (ROUTER mode)
+ * POST /models/unload
+ * @param modelId - Model identifier to unload
+ */
+ static async unload(modelId: string): Promise {
+ const response = await fetch(`${base}/models/unload`, {
+ method: 'POST',
+ headers: getJsonHeaders(),
+ body: JSON.stringify({ model: modelId })
+ });
+
+ if (!response.ok) {
+ const errorData = await response.json().catch(() => ({}));
+ throw new Error(errorData.error || `Failed to unload model (status ${response.status})`);
+ }
+
+ return response.json() as Promise;
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────────
+ // Status
+ // ─────────────────────────────────────────────────────────────────────────────
+
+ /**
+ * Check if a model is loaded based on its metadata
+ */
+ static isModelLoaded(model: ApiModelDataEntry): boolean {
+ return model.status.value === ServerModelStatus.LOADED;
+ }
+
+ /**
+ * Check if a model is currently loading
+ */
+ static isModelLoading(model: ApiModelDataEntry): boolean {
+ return model.status.value === ServerModelStatus.LOADING;
+ }
}
diff --git a/tools/server/webui/src/lib/services/parameter-sync.spec.ts b/tools/server/webui/src/lib/services/parameter-sync.spec.ts
index 9ced55faa0..17b12f757c 100644
--- a/tools/server/webui/src/lib/services/parameter-sync.spec.ts
+++ b/tools/server/webui/src/lib/services/parameter-sync.spec.ts
@@ -1,6 +1,5 @@
import { describe, it, expect } from 'vitest';
import { ParameterSyncService } from './parameter-sync';
-import type { ApiLlamaCppServerProps } from '$lib/types/api';
describe('ParameterSyncService', () => {
describe('roundFloatingPoint', () => {
diff --git a/tools/server/webui/src/lib/services/parameter-sync.ts b/tools/server/webui/src/lib/services/parameter-sync.ts
index ee147ae194..d32d669264 100644
--- a/tools/server/webui/src/lib/services/parameter-sync.ts
+++ b/tools/server/webui/src/lib/services/parameter-sync.ts
@@ -12,8 +12,7 @@
* - Provide sync utilities for settings store integration
*/
-import type { ApiLlamaCppServerProps } from '$lib/types/api';
-import { normalizeFloatingPoint } from '$lib/utils/precision';
+import { normalizeFloatingPoint } from '$lib/utils';
export type ParameterSource = 'default' | 'custom';
export type ParameterValue = string | number | boolean;
@@ -60,6 +59,10 @@ export const SYNCABLE_PARAMETERS: SyncableParameter[] = [
];
export class ParameterSyncService {
+ // ─────────────────────────────────────────────────────────────────────────────
+ // Extraction
+ // ─────────────────────────────────────────────────────────────────────────────
+
/**
* Round floating-point numbers to avoid JavaScript precision issues
*/
@@ -95,6 +98,10 @@ export class ParameterSyncService {
return extracted;
}
+ // ─────────────────────────────────────────────────────────────────────────────
+ // Merging
+ // ─────────────────────────────────────────────────────────────────────────────
+
/**
* Merge server defaults with current user settings
* Returns updated settings that respect user overrides while using server defaults
@@ -116,6 +123,10 @@ export class ParameterSyncService {
return merged;
}
+ // ─────────────────────────────────────────────────────────────────────────────
+ // Info
+ // ─────────────────────────────────────────────────────────────────────────────
+
/**
* Get parameter information including source and values
*/
@@ -172,6 +183,10 @@ export class ParameterSyncService {
}
}
+ // ─────────────────────────────────────────────────────────────────────────────
+ // Diff
+ // ─────────────────────────────────────────────────────────────────────────────
+
/**
* Create a diff between current settings and server defaults
*/
diff --git a/tools/server/webui/src/lib/services/props.ts b/tools/server/webui/src/lib/services/props.ts
new file mode 100644
index 0000000000..01fead9fa3
--- /dev/null
+++ b/tools/server/webui/src/lib/services/props.ts
@@ -0,0 +1,77 @@
+import { getAuthHeaders } from '$lib/utils';
+
+/**
+ * PropsService - Server properties management
+ *
+ * This service handles communication with the /props endpoint to retrieve
+ * server configuration, model information, and capabilities.
+ *
+ * **Responsibilities:**
+ * - Fetch server properties from /props endpoint
+ * - Handle API authentication
+ * - Parse and validate server response
+ *
+ * **Used by:**
+ * - serverStore: Primary consumer for server state management
+ */
+export class PropsService {
+ // ─────────────────────────────────────────────────────────────────────────────
+ // Fetching
+ // ─────────────────────────────────────────────────────────────────────────────
+
+ /**
+ * Fetches server properties from the /props endpoint
+ *
+ * @param autoload - If false, prevents automatic model loading (default: false)
+ * @returns {Promise} Server properties
+ * @throws {Error} If the request fails or returns invalid data
+ */
+ static async fetch(autoload = false): Promise {
+ const url = new URL('./props', window.location.href);
+ if (!autoload) {
+ url.searchParams.set('autoload', 'false');
+ }
+
+ const response = await fetch(url.toString(), {
+ headers: getAuthHeaders()
+ });
+
+ if (!response.ok) {
+ throw new Error(
+ `Failed to fetch server properties: ${response.status} ${response.statusText}`
+ );
+ }
+
+ const data = await response.json();
+ return data as ApiLlamaCppServerProps;
+ }
+
+ /**
+ * Fetches server properties for a specific model (ROUTER mode)
+ *
+ * @param modelId - The model ID to fetch properties for
+ * @param autoload - If false, prevents automatic model loading (default: false)
+ * @returns {Promise} Server properties for the model
+ * @throws {Error} If the request fails or returns invalid data
+ */
+ static async fetchForModel(modelId: string, autoload = false): Promise {
+ const url = new URL('./props', window.location.href);
+ url.searchParams.set('model', modelId);
+ if (!autoload) {
+ url.searchParams.set('autoload', 'false');
+ }
+
+ const response = await fetch(url.toString(), {
+ headers: getAuthHeaders()
+ });
+
+ if (!response.ok) {
+ throw new Error(
+ `Failed to fetch model properties: ${response.status} ${response.statusText}`
+ );
+ }
+
+ const data = await response.json();
+ return data as ApiLlamaCppServerProps;
+ }
+}
diff --git a/tools/server/webui/src/lib/services/slots.ts b/tools/server/webui/src/lib/services/slots.ts
deleted file mode 100644
index e99297d6a0..0000000000
--- a/tools/server/webui/src/lib/services/slots.ts
+++ /dev/null
@@ -1,322 +0,0 @@
-import { config } from '$lib/stores/settings.svelte';
-
-/**
- * SlotsService - Real-time processing state monitoring and token rate calculation
- *
- * This service provides real-time information about generation progress, token rates,
- * and context usage based on timing data from ChatService streaming responses.
- * It manages streaming session tracking and provides accurate processing state updates.
- *
- * **Architecture & Relationships:**
- * - **SlotsService** (this class): Processing state monitoring
- * - Receives timing data from ChatService streaming responses
- * - Calculates token generation rates and context usage
- * - Manages streaming session lifecycle
- * - Provides real-time updates to UI components
- *
- * - **ChatService**: Provides timing data from `/chat/completions` streaming
- * - **UI Components**: Subscribe to processing state for progress indicators
- *
- * **Key Features:**
- * - **Real-time Monitoring**: Live processing state during generation
- * - **Token Rate Calculation**: Accurate tokens/second from timing data
- * - **Context Tracking**: Current context usage and remaining capacity
- * - **Streaming Lifecycle**: Start/stop tracking for streaming sessions
- * - **Timing Data Processing**: Converts streaming timing data to structured state
- * - **Error Handling**: Graceful handling when timing data is unavailable
- *
- * **Processing States:**
- * - `idle`: No active processing
- * - `generating`: Actively generating tokens
- *
- * **Token Rate Calculation:**
- * Uses timing data from `/chat/completions` streaming response for accurate
- * real-time token generation rate measurement.
- */
-export class SlotsService {
- private callbacks: Set<(state: ApiProcessingState | null) => void> = new Set();
- private isStreamingActive: boolean = false;
- private lastKnownState: ApiProcessingState | null = null;
- private conversationStates: Map = new Map();
- private activeConversationId: string | null = null;
-
- /**
- * Start streaming session tracking
- */
- startStreaming(): void {
- this.isStreamingActive = true;
- }
-
- /**
- * Stop streaming session tracking
- */
- stopStreaming(): void {
- this.isStreamingActive = false;
- }
-
- /**
- * Clear the current processing state
- * Used when switching to a conversation without timing data
- */
- clearState(): void {
- this.lastKnownState = null;
-
- for (const callback of this.callbacks) {
- try {
- callback(null);
- } catch (error) {
- console.error('Error in clearState callback:', error);
- }
- }
- }
-
- /**
- * Check if currently in a streaming session
- */
- isStreaming(): boolean {
- return this.isStreamingActive;
- }
-
- /**
- * Set the active conversation for statistics display
- */
- setActiveConversation(conversationId: string | null): void {
- this.activeConversationId = conversationId;
- this.notifyCallbacks();
- }
-
- /**
- * Update processing state for a specific conversation
- */
- updateConversationState(conversationId: string, state: ApiProcessingState | null): void {
- this.conversationStates.set(conversationId, state);
-
- if (conversationId === this.activeConversationId) {
- this.lastKnownState = state;
- this.notifyCallbacks();
- }
- }
-
- /**
- * Get processing state for a specific conversation
- */
- getConversationState(conversationId: string): ApiProcessingState | null {
- return this.conversationStates.get(conversationId) || null;
- }
-
- /**
- * Clear state for a specific conversation
- */
- clearConversationState(conversationId: string): void {
- this.conversationStates.delete(conversationId);
-
- if (conversationId === this.activeConversationId) {
- this.lastKnownState = null;
- this.notifyCallbacks();
- }
- }
-
- /**
- * Notify all callbacks with current state
- */
- private notifyCallbacks(): void {
- const currentState = this.activeConversationId
- ? this.conversationStates.get(this.activeConversationId) || null
- : this.lastKnownState;
-
- for (const callback of this.callbacks) {
- try {
- callback(currentState);
- } catch (error) {
- console.error('Error in slots service callback:', error);
- }
- }
- }
-
- /**
- * @deprecated Polling is no longer used - timing data comes from ChatService streaming response
- * This method logs a warning if called to help identify outdated usage
- */
- fetchAndNotify(): void {
- console.warn(
- 'SlotsService.fetchAndNotify() is deprecated - use timing data from ChatService instead'
- );
- }
-
- subscribe(callback: (state: ApiProcessingState | null) => void): () => void {
- this.callbacks.add(callback);
-
- if (this.lastKnownState) {
- callback(this.lastKnownState);
- }
-
- return () => {
- this.callbacks.delete(callback);
- };
- }
-
- /**
- * Updates processing state with timing data from ChatService streaming response
- */
- async updateFromTimingData(
- timingData: {
- prompt_n: number;
- predicted_n: number;
- predicted_per_second: number;
- cache_n: number;
- prompt_progress?: ChatMessagePromptProgress;
- },
- conversationId?: string
- ): Promise {
- const processingState = await this.parseCompletionTimingData(timingData);
-
- if (processingState === null) {
- console.warn('Failed to parse timing data - skipping update');
-
- return;
- }
-
- if (conversationId) {
- this.updateConversationState(conversationId, processingState);
- } else {
- this.lastKnownState = processingState;
- this.notifyCallbacks();
- }
- }
-
- /**
- * Gets context total from last known slots data or fetches from server
- */
- private async getContextTotal(): Promise {
- if (this.lastKnownState && this.lastKnownState.contextTotal > 0) {
- return this.lastKnownState.contextTotal;
- }
-
- try {
- const currentConfig = config();
- const apiKey = currentConfig.apiKey?.toString().trim();
-
- const response = await fetch(`./slots`, {
- headers: {
- ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {})
- }
- });
-
- if (response.ok) {
- const slotsData = await response.json();
- if (Array.isArray(slotsData) && slotsData.length > 0) {
- const slot = slotsData[0];
- if (slot.n_ctx && slot.n_ctx > 0) {
- return slot.n_ctx;
- }
- }
- }
- } catch (error) {
- console.warn('Failed to fetch context total from /slots:', error);
- }
-
- return 4096;
- }
-
- private async parseCompletionTimingData(
- timingData: Record
- ): Promise {
- const promptTokens = (timingData.prompt_n as number) || 0;
- const predictedTokens = (timingData.predicted_n as number) || 0;
- const tokensPerSecond = (timingData.predicted_per_second as number) || 0;
- const cacheTokens = (timingData.cache_n as number) || 0;
- const promptProgress = timingData.prompt_progress as
- | {
- total: number;
- cache: number;
- processed: number;
- time_ms: number;
- }
- | undefined;
-
- const contextTotal = await this.getContextTotal();
-
- if (contextTotal === null) {
- console.warn('No context total available - cannot calculate processing state');
-
- return null;
- }
-
- const currentConfig = config();
- const outputTokensMax = currentConfig.max_tokens || -1;
-
- const contextUsed = promptTokens + cacheTokens + predictedTokens;
- const outputTokensUsed = predictedTokens;
-
- const progressPercent = promptProgress
- ? Math.round((promptProgress.processed / promptProgress.total) * 100)
- : undefined;
-
- return {
- status: predictedTokens > 0 ? 'generating' : promptProgress ? 'preparing' : 'idle',
- tokensDecoded: predictedTokens,
- tokensRemaining: outputTokensMax - predictedTokens,
- contextUsed,
- contextTotal,
- outputTokensUsed,
- outputTokensMax,
- hasNextToken: predictedTokens > 0,
- tokensPerSecond,
- temperature: currentConfig.temperature ?? 0.8,
- topP: currentConfig.top_p ?? 0.95,
- speculative: false,
- progressPercent,
- promptTokens,
- cacheTokens
- };
- }
-
- /**
- * Get current processing state
- * Returns the last known state from timing data, or null if no data available
- * If activeConversationId is set, returns state for that conversation
- */
- async getCurrentState(): Promise {
- if (this.activeConversationId) {
- const conversationState = this.conversationStates.get(this.activeConversationId);
-
- if (conversationState) {
- return conversationState;
- }
- }
-
- if (this.lastKnownState) {
- return this.lastKnownState;
- }
- try {
- const { chatStore } = await import('$lib/stores/chat.svelte');
- const messages = chatStore.activeMessages;
-
- for (let i = messages.length - 1; i >= 0; i--) {
- const message = messages[i];
- if (message.role === 'assistant' && message.timings) {
- const restoredState = await this.parseCompletionTimingData({
- prompt_n: message.timings.prompt_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,
- cache_n: message.timings.cache_n || 0
- });
-
- if (restoredState) {
- this.lastKnownState = restoredState;
- return restoredState;
- }
- }
- }
- } catch (error) {
- console.warn('Failed to restore timing data from messages:', error);
- }
-
- return null;
- }
-}
-
-export const slotsService = new SlotsService();
diff --git a/tools/server/webui/src/lib/stores/chat.svelte.ts b/tools/server/webui/src/lib/stores/chat.svelte.ts
index d8978558e1..0c17b06bc1 100644
--- a/tools/server/webui/src/lib/stores/chat.svelte.ts
+++ b/tools/server/webui/src/lib/stores/chat.svelte.ts
@@ -1,607 +1,336 @@
-import { DatabaseStore } from '$lib/stores/database';
-import { chatService, slotsService } from '$lib/services';
+import { DatabaseService, ChatService } from '$lib/services';
+import { conversationsStore } from '$lib/stores/conversations.svelte';
import { config } from '$lib/stores/settings.svelte';
-import { serverStore } from '$lib/stores/server.svelte';
-import { normalizeModelName } from '$lib/utils/model-names';
-import { filterByLeafNodeId, findLeafNode, findDescendantMessages } from '$lib/utils/branching';
-import { browser } from '$app/environment';
-import { goto } from '$app/navigation';
-import { toast } from 'svelte-sonner';
+import { contextSize, isRouterMode } from '$lib/stores/server.svelte';
+import { selectedModelName, modelsStore } from '$lib/stores/models.svelte';
+import {
+ normalizeModelName,
+ filterByLeafNodeId,
+ findDescendantMessages,
+ findLeafNode
+} from '$lib/utils';
import { SvelteMap } from 'svelte/reactivity';
-import type { ExportedConversations } from '$lib/types/database';
+import { DEFAULT_CONTEXT } from '$lib/constants/default-context';
/**
- * ChatStore - Central state management for chat conversations and AI interactions
+ * chatStore - Active AI interaction and streaming state management
*
- * This store manages the complete chat experience including:
- * - Conversation lifecycle (create, load, delete, update)
- * - Message management with branching support for conversation trees
- * - Real-time AI response streaming with reasoning content support
- * - File attachment handling and processing
- * - Context error management and recovery
- * - Database persistence through DatabaseStore integration
+ * **Terminology - Chat vs Conversation:**
+ * - **Chat**: The active interaction space with the Chat Completions API. Represents the
+ * real-time streaming session, loading states, and UI visualization of AI communication.
+ * A "chat" is ephemeral - it exists only while the user is actively interacting with the AI.
+ * - **Conversation**: The persistent database entity storing all messages and metadata.
+ * Managed by conversationsStore, conversations persist across sessions and page reloads.
+ *
+ * This store manages all active AI interactions including real-time streaming, response
+ * generation, and per-chat loading states. It handles the runtime layer between UI and
+ * AI backend, supporting concurrent streaming across multiple conversations.
*
* **Architecture & Relationships:**
- * - **ChatService**: Handles low-level API communication with AI models
- * - ChatStore orchestrates ChatService for streaming responses
- * - ChatService provides abort capabilities and error handling
- * - ChatStore manages the UI state while ChatService handles network layer
+ * - **chatStore** (this class): Active AI session and streaming management
+ * - Manages real-time AI response streaming via ChatService
+ * - Tracks per-chat loading and streaming states for concurrent sessions
+ * - Handles message operations (send, edit, regenerate, branch)
+ * - Coordinates with conversationsStore for persistence
*
- * - **DatabaseStore**: Provides persistent storage for conversations and messages
- * - ChatStore uses DatabaseStore for all CRUD operations
- * - Maintains referential integrity for conversation trees
- * - Handles message branching and parent-child relationships
- *
- * - **SlotsService**: Monitors server resource usage during AI generation
- * - ChatStore coordinates slots polling during streaming
- * - Provides real-time feedback on server capacity
+ * - **conversationsStore**: Provides conversation data and message arrays for chat context
+ * - **ChatService**: Low-level API communication with llama.cpp server
+ * - **DatabaseService**: Message persistence and retrieval
*
* **Key Features:**
- * - Reactive state management using Svelte 5 runes ($state)
- * - Conversation branching for exploring different response paths
- * - Streaming AI responses with real-time content updates
- * - File attachment support (images, PDFs, text files, audio)
- * - Partial response saving when generation is interrupted
- * - Message editing with automatic response regeneration
+ * - **AI Streaming**: Real-time token streaming with abort support
+ * - **Concurrent Chats**: Independent loading/streaming states per conversation
+ * - **Message Branching**: Edit, regenerate, and branch conversation trees
+ * - **Error Handling**: Timeout and server error recovery with user feedback
+ * - **Graceful Stop**: Save partial responses when stopping generation
+ *
+ * **State Management:**
+ * - Global `isLoading` and `currentResponse` for active chat UI
+ * - `chatLoadingStates` Map for per-conversation streaming tracking
+ * - `chatStreamingStates` Map for per-conversation streaming content
+ * - `processingStates` Map for per-conversation processing state (timing/context info)
+ * - Automatic state sync when switching between conversations
*/
class ChatStore {
- activeConversation = $state(null);
- activeMessages = $state([]);
- conversations = $state([]);
+ // ─────────────────────────────────────────────────────────────────────────────
+ // State
+ // ─────────────────────────────────────────────────────────────────────────────
+
+ activeProcessingState = $state(null);
currentResponse = $state('');
errorDialogState = $state<{ type: 'timeout' | 'server'; message: string } | null>(null);
- isInitialized = $state(false);
isLoading = $state(false);
- conversationLoadingStates = new SvelteMap();
- conversationStreamingStates = new SvelteMap();
- titleUpdateConfirmationCallback?: (currentTitle: string, newTitle: string) => Promise;
+ chatLoadingStates = new SvelteMap();
+ chatStreamingStates = new SvelteMap();
+ private abortControllers = new SvelteMap();
+ private processingStates = new SvelteMap();
+ private activeConversationId = $state(null);
+ private isStreamingActive = $state(false);
- constructor() {
- if (browser) {
- this.initialize();
- }
- }
+ // ─────────────────────────────────────────────────────────────────────────────
+ // Loading State
+ // ─────────────────────────────────────────────────────────────────────────────
- /**
- * Initializes the chat store by loading conversations from the database
- * Sets up the initial state and loads existing conversations
- */
- async initialize(): Promise {
- try {
- await this.loadConversations();
-
- this.isInitialized = true;
- } catch (error) {
- console.error('Failed to initialize chat store:', error);
- }
- }
-
- /**
- * Loads all conversations from the database
- * Refreshes the conversations list from persistent storage
- */
- async loadConversations(): Promise {
- this.conversations = await DatabaseStore.getAllConversations();
- }
-
- /**
- * Creates a new conversation and navigates to it
- * @param name - Optional name for the conversation, defaults to timestamped name
- * @returns The ID of the created conversation
- */
- async createConversation(name?: string): Promise {
- const conversationName = name || `Chat ${new Date().toLocaleString()}`;
- const conversation = await DatabaseStore.createConversation(conversationName);
-
- this.conversations.unshift(conversation);
-
- this.activeConversation = conversation;
- this.activeMessages = [];
-
- slotsService.setActiveConversation(conversation.id);
-
- const isConvLoading = this.isConversationLoading(conversation.id);
- this.isLoading = isConvLoading;
-
- this.currentResponse = '';
-
- await goto(`#/chat/${conversation.id}`);
-
- return conversation.id;
- }
-
- /**
- * Loads a specific conversation and its messages
- * @param convId - The conversation ID to load
- * @returns True if conversation was loaded successfully, false otherwise
- */
- async loadConversation(convId: string): Promise {
- try {
- const conversation = await DatabaseStore.getConversation(convId);
-
- if (!conversation) {
- return false;
- }
-
- this.activeConversation = conversation;
-
- slotsService.setActiveConversation(convId);
-
- const isConvLoading = this.isConversationLoading(convId);
- this.isLoading = isConvLoading;
-
- const streamingState = this.getConversationStreaming(convId);
- this.currentResponse = streamingState?.response || '';
-
- if (conversation.currNode) {
- const allMessages = await DatabaseStore.getConversationMessages(convId);
- this.activeMessages = filterByLeafNodeId(
- allMessages,
- conversation.currNode,
- false
- ) as DatabaseMessage[];
- } else {
- // Load all messages for conversations without currNode (backward compatibility)
- this.activeMessages = await DatabaseStore.getConversationMessages(convId);
- }
-
- return true;
- } catch (error) {
- console.error('Failed to load conversation:', error);
-
- return false;
- }
- }
-
- /**
- * Adds a new message to the active conversation
- * @param role - The role of the message sender (user/assistant)
- * @param content - The message content
- * @param type - The message type, defaults to 'text'
- * @param parent - Parent message ID, defaults to '-1' for auto-detection
- * @param extras - Optional extra data (files, attachments, etc.)
- * @returns The created message or null if failed
- */
- async addMessage(
- role: ChatRole,
- content: string,
- type: ChatMessageType = 'text',
- parent: string = '-1',
- extras?: DatabaseMessageExtra[]
- ): Promise {
- if (!this.activeConversation) {
- console.error('No active conversation when trying to add message');
- return null;
- }
-
- try {
- let parentId: string | null = null;
-
- if (parent === '-1') {
- if (this.activeMessages.length > 0) {
- parentId = this.activeMessages[this.activeMessages.length - 1].id;
- } else {
- const allMessages = await DatabaseStore.getConversationMessages(
- this.activeConversation.id
- );
- const rootMessage = allMessages.find((m) => m.parent === null && m.type === 'root');
-
- if (!rootMessage) {
- const rootId = await DatabaseStore.createRootMessage(this.activeConversation.id);
- parentId = rootId;
- } else {
- parentId = rootMessage.id;
- }
- }
- } else {
- parentId = parent;
- }
-
- const message = await DatabaseStore.createMessageBranch(
- {
- convId: this.activeConversation.id,
- role,
- content,
- type,
- timestamp: Date.now(),
- thinking: '',
- toolCalls: '',
- children: [],
- extra: extras
- },
- parentId
- );
-
- this.activeMessages.push(message);
-
- await DatabaseStore.updateCurrentNode(this.activeConversation.id, message.id);
- this.activeConversation.currNode = message.id;
-
- this.updateConversationTimestamp();
-
- return message;
- } catch (error) {
- console.error('Failed to add message:', error);
- return null;
- }
- }
-
- /**
- * Gets API options from current configuration settings
- * Converts settings store values to API-compatible format
- * @returns API options object for chat completion requests
- */
- private getApiOptions(): Record {
- const currentConfig = config();
- const hasValue = (value: unknown): boolean =>
- value !== undefined && value !== null && value !== '';
-
- const apiOptions: Record = {
- stream: true,
- timings_per_token: true
- };
-
- if (hasValue(currentConfig.temperature)) {
- apiOptions.temperature = Number(currentConfig.temperature);
- }
- if (hasValue(currentConfig.max_tokens)) {
- apiOptions.max_tokens = Number(currentConfig.max_tokens);
- }
- if (hasValue(currentConfig.dynatemp_range)) {
- apiOptions.dynatemp_range = Number(currentConfig.dynatemp_range);
- }
- if (hasValue(currentConfig.dynatemp_exponent)) {
- apiOptions.dynatemp_exponent = Number(currentConfig.dynatemp_exponent);
- }
- if (hasValue(currentConfig.top_k)) {
- apiOptions.top_k = Number(currentConfig.top_k);
- }
- if (hasValue(currentConfig.top_p)) {
- apiOptions.top_p = Number(currentConfig.top_p);
- }
- if (hasValue(currentConfig.min_p)) {
- apiOptions.min_p = Number(currentConfig.min_p);
- }
- if (hasValue(currentConfig.xtc_probability)) {
- apiOptions.xtc_probability = Number(currentConfig.xtc_probability);
- }
- if (hasValue(currentConfig.xtc_threshold)) {
- apiOptions.xtc_threshold = Number(currentConfig.xtc_threshold);
- }
- if (hasValue(currentConfig.typ_p)) {
- apiOptions.typ_p = Number(currentConfig.typ_p);
- }
- if (hasValue(currentConfig.repeat_last_n)) {
- apiOptions.repeat_last_n = Number(currentConfig.repeat_last_n);
- }
- if (hasValue(currentConfig.repeat_penalty)) {
- apiOptions.repeat_penalty = Number(currentConfig.repeat_penalty);
- }
- if (hasValue(currentConfig.presence_penalty)) {
- apiOptions.presence_penalty = Number(currentConfig.presence_penalty);
- }
- if (hasValue(currentConfig.frequency_penalty)) {
- apiOptions.frequency_penalty = Number(currentConfig.frequency_penalty);
- }
- if (hasValue(currentConfig.dry_multiplier)) {
- apiOptions.dry_multiplier = Number(currentConfig.dry_multiplier);
- }
- if (hasValue(currentConfig.dry_base)) {
- apiOptions.dry_base = Number(currentConfig.dry_base);
- }
- if (hasValue(currentConfig.dry_allowed_length)) {
- apiOptions.dry_allowed_length = Number(currentConfig.dry_allowed_length);
- }
- if (hasValue(currentConfig.dry_penalty_last_n)) {
- apiOptions.dry_penalty_last_n = Number(currentConfig.dry_penalty_last_n);
- }
- if (currentConfig.samplers) {
- apiOptions.samplers = currentConfig.samplers;
- }
- if (currentConfig.backend_sampling !== undefined) {
- apiOptions.backend_sampling = Boolean(currentConfig.backend_sampling);
- }
- if (currentConfig.custom) {
- apiOptions.custom = currentConfig.custom;
- }
-
- return apiOptions;
- }
-
- /**
- * Helper methods for per-conversation loading state management
- */
- private setConversationLoading(convId: string, loading: boolean): void {
+ private setChatLoading(convId: string, loading: boolean): void {
if (loading) {
- this.conversationLoadingStates.set(convId, true);
- if (this.activeConversation?.id === convId) {
- this.isLoading = true;
- }
+ this.chatLoadingStates.set(convId, true);
+ if (conversationsStore.activeConversation?.id === convId) this.isLoading = true;
} else {
- this.conversationLoadingStates.delete(convId);
- if (this.activeConversation?.id === convId) {
- this.isLoading = false;
- }
+ this.chatLoadingStates.delete(convId);
+ if (conversationsStore.activeConversation?.id === convId) this.isLoading = false;
}
}
- private isConversationLoading(convId: string): boolean {
- return this.conversationLoadingStates.get(convId) || false;
+ private isChatLoading(convId: string): boolean {
+ return this.chatLoadingStates.get(convId) || false;
}
- private setConversationStreaming(convId: string, response: string, messageId: string): void {
- this.conversationStreamingStates.set(convId, { response, messageId });
- if (this.activeConversation?.id === convId) {
- this.currentResponse = response;
- }
+ private setChatStreaming(convId: string, response: string, messageId: string): void {
+ this.chatStreamingStates.set(convId, { response, messageId });
+ if (conversationsStore.activeConversation?.id === convId) this.currentResponse = response;
}
- private clearConversationStreaming(convId: string): void {
- this.conversationStreamingStates.delete(convId);
- if (this.activeConversation?.id === convId) {
- this.currentResponse = '';
- }
+ private clearChatStreaming(convId: string): void {
+ this.chatStreamingStates.delete(convId);
+ if (conversationsStore.activeConversation?.id === convId) this.currentResponse = '';
}
- private getConversationStreaming(
- convId: string
- ): { response: string; messageId: string } | undefined {
- return this.conversationStreamingStates.get(convId);
+ private getChatStreaming(convId: string): { response: string; messageId: string } | undefined {
+ return this.chatStreamingStates.get(convId);
+ }
+
+ syncLoadingStateForChat(convId: string): void {
+ this.isLoading = this.isChatLoading(convId);
+ const streamingState = this.getChatStreaming(convId);
+ this.currentResponse = streamingState?.response || '';
}
/**
- * Handles streaming chat completion with the AI model
- * @param allMessages - All messages in the conversation
- * @param assistantMessage - The assistant message to stream content into
- * @param onComplete - Optional callback when streaming completes
- * @param onError - Optional callback when an error occurs
+ * Clears global UI state without affecting background streaming.
+ * Used when navigating to empty/new chat while other chats stream in background.
*/
- private async streamChatCompletion(
- allMessages: DatabaseMessage[],
- assistantMessage: DatabaseMessage,
- onComplete?: (content: string) => Promise,
- onError?: (error: Error) => void
- ): Promise {
- let streamedContent = '';
- let streamedReasoningContent = '';
- let streamedToolCallContent = '';
+ clearUIState(): void {
+ this.isLoading = false;
+ this.currentResponse = '';
+ }
- let resolvedModel: string | null = null;
- let modelPersisted = false;
- const currentConfig = config();
- const preferServerPropsModel = !currentConfig.modelSelectorEnabled;
- let serverPropsRefreshed = false;
- let updateModelFromServerProps: ((persistImmediately?: boolean) => void) | null = null;
+ // ─────────────────────────────────────────────────────────────────────────────
+ // Processing State
+ // ─────────────────────────────────────────────────────────────────────────────
- const refreshServerPropsOnce = () => {
- if (serverPropsRefreshed) {
- return;
+ /**
+ * Set the active conversation for statistics display
+ */
+ setActiveProcessingConversation(conversationId: string | null): void {
+ this.activeConversationId = conversationId;
+
+ if (conversationId) {
+ this.activeProcessingState = this.processingStates.get(conversationId) || null;
+ } else {
+ this.activeProcessingState = null;
+ }
+ }
+
+ /**
+ * Get processing state for a specific conversation
+ */
+ getProcessingState(conversationId: string): ApiProcessingState | null {
+ return this.processingStates.get(conversationId) || null;
+ }
+
+ /**
+ * Clear processing state for a specific conversation
+ */
+ clearProcessingState(conversationId: string): void {
+ this.processingStates.delete(conversationId);
+
+ if (conversationId === this.activeConversationId) {
+ this.activeProcessingState = null;
+ }
+ }
+
+ /**
+ * Get the current processing state for the active conversation (reactive)
+ * Returns the direct reactive state for UI binding
+ */
+ getActiveProcessingState(): ApiProcessingState | null {
+ return this.activeProcessingState;
+ }
+
+ /**
+ * Updates processing state with timing data from streaming response
+ */
+ updateProcessingStateFromTimings(
+ timingData: {
+ prompt_n: number;
+ predicted_n: number;
+ predicted_per_second: number;
+ cache_n: number;
+ prompt_progress?: ChatMessagePromptProgress;
+ },
+ conversationId?: string
+ ): void {
+ const processingState = this.parseTimingData(timingData);
+
+ if (processingState === null) {
+ console.warn('Failed to parse timing data - skipping update');
+ return;
+ }
+
+ const targetId = conversationId || this.activeConversationId;
+ if (targetId) {
+ this.processingStates.set(targetId, processingState);
+
+ if (targetId === this.activeConversationId) {
+ this.activeProcessingState = processingState;
}
+ }
+ }
- serverPropsRefreshed = true;
+ /**
+ * Get current processing state (sync version for reactive access)
+ */
+ getCurrentProcessingStateSync(): ApiProcessingState | null {
+ return this.activeProcessingState;
+ }
- const hasExistingProps = serverStore.serverProps !== null;
-
- serverStore
- .fetchServerProps({ silent: hasExistingProps })
- .then(() => {
- updateModelFromServerProps?.(true);
- })
- .catch((error) => {
- console.warn('Failed to refresh server props after streaming started:', error);
+ /**
+ * Restore processing state from last assistant message timings
+ * Call this when keepStatsVisible is enabled and we need to show last known stats
+ */
+ restoreProcessingStateFromMessages(messages: DatabaseMessage[], conversationId: string): void {
+ for (let i = messages.length - 1; i >= 0; i--) {
+ const message = messages[i];
+ if (message.role === 'assistant' && message.timings) {
+ const restoredState = this.parseTimingData({
+ prompt_n: message.timings.prompt_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,
+ cache_n: message.timings.cache_n || 0
});
- };
- const recordModel = (modelName: string | null | undefined, persistImmediately = true): void => {
- const serverModelName = serverStore.modelName;
- const preferredModelSource = preferServerPropsModel
- ? (serverModelName ?? modelName ?? null)
- : (modelName ?? serverModelName ?? null);
+ if (restoredState) {
+ this.processingStates.set(conversationId, restoredState);
- if (!preferredModelSource) {
- return;
- }
-
- const normalizedModel = normalizeModelName(preferredModelSource);
-
- if (!normalizedModel || normalizedModel === resolvedModel) {
- return;
- }
-
- resolvedModel = normalizedModel;
-
- const messageIndex = this.findMessageIndex(assistantMessage.id);
-
- this.updateMessageAtIndex(messageIndex, { model: normalizedModel });
-
- if (persistImmediately && !modelPersisted) {
- modelPersisted = true;
- DatabaseStore.updateMessage(assistantMessage.id, { model: normalizedModel }).catch(
- (error) => {
- console.error('Failed to persist model name:', error);
- modelPersisted = false;
- resolvedModel = null;
+ if (conversationId === this.activeConversationId) {
+ this.activeProcessingState = restoredState;
}
- );
- }
- };
- if (preferServerPropsModel) {
- updateModelFromServerProps = (persistImmediately = true) => {
- const currentServerModel = serverStore.modelName;
-
- if (!currentServerModel) {
return;
}
-
- recordModel(currentServerModel, persistImmediately);
- };
-
- updateModelFromServerProps(false);
+ }
}
+ }
- slotsService.startStreaming();
- slotsService.setActiveConversation(assistantMessage.convId);
+ // ─────────────────────────────────────────────────────────────────────────────
+ // Streaming
+ // ─────────────────────────────────────────────────────────────────────────────
- await chatService.sendMessage(
- allMessages,
- {
- ...this.getApiOptions(),
-
- onFirstValidChunk: () => {
- refreshServerPropsOnce();
- },
- onChunk: (chunk: string) => {
- streamedContent += chunk;
- this.setConversationStreaming(
- assistantMessage.convId,
- streamedContent,
- assistantMessage.id
- );
-
- const messageIndex = this.findMessageIndex(assistantMessage.id);
- this.updateMessageAtIndex(messageIndex, {
- content: streamedContent
- });
- },
-
- onReasoningChunk: (reasoningChunk: string) => {
- streamedReasoningContent += reasoningChunk;
-
- const messageIndex = this.findMessageIndex(assistantMessage.id);
-
- this.updateMessageAtIndex(messageIndex, { thinking: streamedReasoningContent });
- },
-
- onToolCallChunk: (toolCallChunk: string) => {
- const chunk = toolCallChunk.trim();
-
- if (!chunk) {
- return;
- }
-
- streamedToolCallContent = chunk;
-
- const messageIndex = this.findMessageIndex(assistantMessage.id);
-
- this.updateMessageAtIndex(messageIndex, { toolCalls: streamedToolCallContent });
- },
-
- onModel: (modelName: string) => {
- recordModel(modelName);
- },
-
- onComplete: async (
- finalContent?: string,
- reasoningContent?: string,
- timings?: ChatMessageTimings,
- toolCallContent?: string
- ) => {
- slotsService.stopStreaming();
-
- const updateData: {
- content: string;
- thinking: string;
- toolCalls: string;
- timings?: ChatMessageTimings;
- model?: string;
- } = {
- content: finalContent || streamedContent,
- thinking: reasoningContent || streamedReasoningContent,
- toolCalls: toolCallContent || streamedToolCallContent,
- timings: timings
- };
-
- if (resolvedModel && !modelPersisted) {
- updateData.model = resolvedModel;
- modelPersisted = true;
- }
-
- await DatabaseStore.updateMessage(assistantMessage.id, updateData);
-
- const messageIndex = this.findMessageIndex(assistantMessage.id);
-
- const localUpdateData: {
- timings?: ChatMessageTimings;
- model?: string;
- toolCalls?: string;
- } = {
- timings: timings
- };
-
- if (updateData.model) {
- localUpdateData.model = updateData.model;
- }
-
- if (updateData.toolCalls !== undefined) {
- localUpdateData.toolCalls = updateData.toolCalls;
- }
-
- this.updateMessageAtIndex(messageIndex, localUpdateData);
-
- await DatabaseStore.updateCurrentNode(assistantMessage.convId, assistantMessage.id);
-
- if (this.activeConversation?.id === assistantMessage.convId) {
- this.activeConversation.currNode = assistantMessage.id;
- await this.refreshActiveMessages();
- }
-
- if (onComplete) {
- await onComplete(streamedContent);
- }
-
- this.setConversationLoading(assistantMessage.convId, false);
- this.clearConversationStreaming(assistantMessage.convId);
- slotsService.clearConversationState(assistantMessage.convId);
- },
-
- onError: (error: Error) => {
- slotsService.stopStreaming();
-
- if (this.isAbortError(error)) {
- this.setConversationLoading(assistantMessage.convId, false);
- this.clearConversationStreaming(assistantMessage.convId);
- slotsService.clearConversationState(assistantMessage.convId);
- return;
- }
-
- console.error('Streaming error:', error);
- this.setConversationLoading(assistantMessage.convId, false);
- this.clearConversationStreaming(assistantMessage.convId);
- slotsService.clearConversationState(assistantMessage.convId);
-
- const messageIndex = this.activeMessages.findIndex(
- (m: DatabaseMessage) => m.id === assistantMessage.id
- );
-
- if (messageIndex !== -1) {
- const [failedMessage] = this.activeMessages.splice(messageIndex, 1);
-
- if (failedMessage) {
- DatabaseStore.deleteMessage(failedMessage.id).catch((cleanupError) => {
- console.error('Failed to remove assistant message after error:', cleanupError);
- });
- }
- }
-
- const dialogType = error.name === 'TimeoutError' ? 'timeout' : 'server';
-
- this.showErrorDialog(dialogType, error.message);
-
- if (onError) {
- onError(error);
- }
- }
- },
- assistantMessage.convId
- );
+ /**
+ * Start streaming session tracking
+ */
+ startStreaming(): void {
+ this.isStreamingActive = true;
}
/**
- * Checks if an error is an abort error (user cancelled operation)
- * @param error - The error to check
- * @returns True if the error is an abort error
+ * Stop streaming session tracking
*/
+ stopStreaming(): void {
+ this.isStreamingActive = false;
+ }
+
+ /**
+ * Check if currently in a streaming session
+ */
+ isStreaming(): boolean {
+ return this.isStreamingActive;
+ }
+
+ private getContextTotal(): number {
+ const activeState = this.getActiveProcessingState();
+
+ if (activeState && activeState.contextTotal > 0) {
+ return activeState.contextTotal;
+ }
+
+ const propsContextSize = contextSize();
+ if (propsContextSize && propsContextSize > 0) {
+ return propsContextSize;
+ }
+
+ return DEFAULT_CONTEXT;
+ }
+
+ private parseTimingData(timingData: Record): ApiProcessingState | null {
+ const promptTokens = (timingData.prompt_n as number) || 0;
+ const predictedTokens = (timingData.predicted_n as number) || 0;
+ const tokensPerSecond = (timingData.predicted_per_second as number) || 0;
+ const cacheTokens = (timingData.cache_n as number) || 0;
+ const promptProgress = timingData.prompt_progress as
+ | {
+ total: number;
+ cache: number;
+ processed: number;
+ time_ms: number;
+ }
+ | undefined;
+
+ const contextTotal = this.getContextTotal();
+ const currentConfig = config();
+ const outputTokensMax = currentConfig.max_tokens || -1;
+
+ const contextUsed = promptTokens + cacheTokens + predictedTokens;
+ const outputTokensUsed = predictedTokens;
+
+ const progressPercent = promptProgress
+ ? Math.round((promptProgress.processed / promptProgress.total) * 100)
+ : undefined;
+
+ return {
+ status: predictedTokens > 0 ? 'generating' : promptProgress ? 'preparing' : 'idle',
+ tokensDecoded: predictedTokens,
+ tokensRemaining: outputTokensMax - predictedTokens,
+ contextUsed,
+ contextTotal,
+ outputTokensUsed,
+ outputTokensMax,
+ hasNextToken: predictedTokens > 0,
+ tokensPerSecond,
+ temperature: currentConfig.temperature ?? 0.8,
+ topP: currentConfig.top_p ?? 0.95,
+ speculative: false,
+ progressPercent,
+ promptTokens,
+ cacheTokens
+ };
+ }
+
+ /**
+ * Gets the model used in a conversation based on the latest assistant message.
+ * Returns the model from the most recent assistant message that has a model field set.
+ *
+ * @param messages - Array of messages to search through
+ * @returns The model name or null if no model found
+ */
+ getConversationModel(messages: DatabaseMessage[]): string | null {
+ // Search backwards through messages to find most recent assistant message with model
+ for (let i = messages.length - 1; i >= 0; i--) {
+ const message = messages[i];
+ if (message.role === 'assistant' && message.model) {
+ return message.model;
+ }
+ }
+ return null;
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────────
+ // Error Handling
+ // ─────────────────────────────────────────────────────────────────────────────
+
private isAbortError(error: unknown): boolean {
return error instanceof Error && (error.name === 'AbortError' || error instanceof DOMException);
}
@@ -614,37 +343,76 @@ class ChatStore {
this.errorDialogState = null;
}
- /**
- * Finds the index of a message in the active messages array
- * @param messageId - The message ID to find
- * @returns The index of the message, or -1 if not found
- */
- private findMessageIndex(messageId: string): number {
- return this.activeMessages.findIndex((m) => m.id === messageId);
- }
+ // ─────────────────────────────────────────────────────────────────────────────
+ // Message Operations
+ // ─────────────────────────────────────────────────────────────────────────────
- /**
- * Updates a message at a specific index with partial data
- * @param index - The index of the message to update
- * @param updates - Partial message data to update
- */
- private updateMessageAtIndex(index: number, updates: Partial): void {
- if (index !== -1) {
- Object.assign(this.activeMessages[index], updates);
+ async addMessage(
+ role: ChatRole,
+ content: string,
+ type: ChatMessageType = 'text',
+ parent: string = '-1',
+ extras?: DatabaseMessageExtra[]
+ ): Promise {
+ const activeConv = conversationsStore.activeConversation;
+ if (!activeConv) {
+ console.error('No active conversation when trying to add message');
+ return null;
+ }
+
+ try {
+ let parentId: string | null = null;
+
+ if (parent === '-1') {
+ const activeMessages = conversationsStore.activeMessages;
+ if (activeMessages.length > 0) {
+ parentId = activeMessages[activeMessages.length - 1].id;
+ } else {
+ const allMessages = await conversationsStore.getConversationMessages(activeConv.id);
+ const rootMessage = allMessages.find((m) => m.parent === null && m.type === 'root');
+ if (!rootMessage) {
+ parentId = await DatabaseService.createRootMessage(activeConv.id);
+ } else {
+ parentId = rootMessage.id;
+ }
+ }
+ } else {
+ parentId = parent;
+ }
+
+ const message = await DatabaseService.createMessageBranch(
+ {
+ convId: activeConv.id,
+ role,
+ content,
+ type,
+ timestamp: Date.now(),
+ thinking: '',
+ toolCalls: '',
+ children: [],
+ extra: extras
+ },
+ parentId
+ );
+
+ conversationsStore.addMessageToActive(message);
+ await conversationsStore.updateCurrentNode(message.id);
+ conversationsStore.updateConversationTimestamp();
+
+ return message;
+ } catch (error) {
+ console.error('Failed to add message:', error);
+ return null;
}
}
- /**
- * Creates a new assistant message in the database
- * @param parentId - Optional parent message ID, defaults to '-1'
- * @returns The created assistant message or null if failed
- */
private async createAssistantMessage(parentId?: string): Promise {
- if (!this.activeConversation) return null;
+ const activeConv = conversationsStore.activeConversation;
+ if (!activeConv) return null;
- return await DatabaseStore.createMessageBranch(
+ return await DatabaseService.createMessageBranch(
{
- convId: this.activeConversation.id,
+ convId: activeConv.id,
type: 'text',
role: 'assistant',
content: '',
@@ -658,169 +426,253 @@ class ChatStore {
);
}
- /**
- * Updates conversation lastModified timestamp and moves it to top of list
- * Ensures recently active conversations appear first in the sidebar
- */
- private updateConversationTimestamp(): void {
- if (!this.activeConversation) return;
+ private async streamChatCompletion(
+ allMessages: DatabaseMessage[],
+ assistantMessage: DatabaseMessage,
+ onComplete?: (content: string) => Promise,
+ onError?: (error: Error) => void,
+ modelOverride?: string | null
+ ): Promise {
+ let streamedContent = '';
+ let streamedReasoningContent = '';
+ let streamedToolCallContent = '';
+ let resolvedModel: string | null = null;
+ let modelPersisted = false;
- const chatIndex = this.conversations.findIndex((c) => c.id === this.activeConversation!.id);
+ const recordModel = (modelName: string | null | undefined, persistImmediately = true): void => {
+ if (!modelName) return;
+ const normalizedModel = normalizeModelName(modelName);
+ if (!normalizedModel || normalizedModel === resolvedModel) return;
+ resolvedModel = normalizedModel;
+ const messageIndex = conversationsStore.findMessageIndex(assistantMessage.id);
+ conversationsStore.updateMessageAtIndex(messageIndex, { model: normalizedModel });
+ if (persistImmediately && !modelPersisted) {
+ modelPersisted = true;
+ DatabaseService.updateMessage(assistantMessage.id, { model: normalizedModel }).catch(() => {
+ modelPersisted = false;
+ resolvedModel = null;
+ });
+ }
+ };
- if (chatIndex !== -1) {
- this.conversations[chatIndex].lastModified = Date.now();
- const updatedConv = this.conversations.splice(chatIndex, 1)[0];
- this.conversations.unshift(updatedConv);
- }
+ this.startStreaming();
+ this.setActiveProcessingConversation(assistantMessage.convId);
+
+ const abortController = this.getOrCreateAbortController(assistantMessage.convId);
+
+ await ChatService.sendMessage(
+ allMessages,
+ {
+ ...this.getApiOptions(),
+ ...(modelOverride ? { model: modelOverride } : {}),
+ onChunk: (chunk: string) => {
+ streamedContent += chunk;
+ this.setChatStreaming(assistantMessage.convId, streamedContent, assistantMessage.id);
+ const idx = conversationsStore.findMessageIndex(assistantMessage.id);
+ conversationsStore.updateMessageAtIndex(idx, { content: streamedContent });
+ },
+ onReasoningChunk: (reasoningChunk: string) => {
+ streamedReasoningContent += reasoningChunk;
+ const idx = conversationsStore.findMessageIndex(assistantMessage.id);
+ conversationsStore.updateMessageAtIndex(idx, { thinking: streamedReasoningContent });
+ },
+ onToolCallChunk: (toolCallChunk: string) => {
+ const chunk = toolCallChunk.trim();
+ if (!chunk) return;
+ streamedToolCallContent = chunk;
+ const idx = conversationsStore.findMessageIndex(assistantMessage.id);
+ conversationsStore.updateMessageAtIndex(idx, { toolCalls: streamedToolCallContent });
+ },
+ onModel: (modelName: string) => recordModel(modelName),
+ onTimings: (timings: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => {
+ const tokensPerSecond =
+ timings?.predicted_ms && timings?.predicted_n
+ ? (timings.predicted_n / timings.predicted_ms) * 1000
+ : 0;
+ this.updateProcessingStateFromTimings(
+ {
+ prompt_n: timings?.prompt_n || 0,
+ predicted_n: timings?.predicted_n || 0,
+ predicted_per_second: tokensPerSecond,
+ cache_n: timings?.cache_n || 0,
+ prompt_progress: promptProgress
+ },
+ assistantMessage.convId
+ );
+ },
+ onComplete: async (
+ finalContent?: string,
+ reasoningContent?: string,
+ timings?: ChatMessageTimings,
+ toolCallContent?: string
+ ) => {
+ this.stopStreaming();
+
+ // Build update data - only include model if not already persisted
+ const updateData: Record = {
+ content: finalContent || streamedContent,
+ thinking: reasoningContent || streamedReasoningContent,
+ toolCalls: toolCallContent || streamedToolCallContent,
+ timings
+ };
+ if (resolvedModel && !modelPersisted) {
+ updateData.model = resolvedModel;
+ }
+ await DatabaseService.updateMessage(assistantMessage.id, updateData);
+
+ // Update UI state - always include model and timings if available
+ const idx = conversationsStore.findMessageIndex(assistantMessage.id);
+ const uiUpdate: Partial = {
+ content: updateData.content as string,
+ toolCalls: updateData.toolCalls as string
+ };
+ if (timings) uiUpdate.timings = timings;
+ if (resolvedModel) uiUpdate.model = resolvedModel;
+
+ conversationsStore.updateMessageAtIndex(idx, uiUpdate);
+ await conversationsStore.updateCurrentNode(assistantMessage.id);
+
+ if (onComplete) await onComplete(streamedContent);
+ this.setChatLoading(assistantMessage.convId, false);
+ this.clearChatStreaming(assistantMessage.convId);
+ this.clearProcessingState(assistantMessage.convId);
+
+ if (isRouterMode()) {
+ modelsStore.fetchRouterModels().catch(console.error);
+ }
+ },
+ onError: (error: Error) => {
+ this.stopStreaming();
+ if (this.isAbortError(error)) {
+ this.setChatLoading(assistantMessage.convId, false);
+ this.clearChatStreaming(assistantMessage.convId);
+ this.clearProcessingState(assistantMessage.convId);
+ return;
+ }
+ console.error('Streaming error:', error);
+ this.setChatLoading(assistantMessage.convId, false);
+ this.clearChatStreaming(assistantMessage.convId);
+ this.clearProcessingState(assistantMessage.convId);
+ const idx = conversationsStore.findMessageIndex(assistantMessage.id);
+ if (idx !== -1) {
+ const failedMessage = conversationsStore.removeMessageAtIndex(idx);
+ if (failedMessage) DatabaseService.deleteMessage(failedMessage.id).catch(console.error);
+ }
+ this.showErrorDialog(error.name === 'TimeoutError' ? 'timeout' : 'server', error.message);
+ if (onError) onError(error);
+ }
+ },
+ assistantMessage.convId,
+ abortController.signal
+ );
}
- /**
- * Sends a new message and generates AI response
- * @param content - The message content to send
- * @param extras - Optional extra data (files, attachments, etc.)
- */
async sendMessage(content: string, extras?: DatabaseMessageExtra[]): Promise {
if (!content.trim() && (!extras || extras.length === 0)) return;
-
- if (this.activeConversation && this.isConversationLoading(this.activeConversation.id)) {
- console.log('Cannot send message: current conversation is already processing a message');
- return;
- }
+ const activeConv = conversationsStore.activeConversation;
+ if (activeConv && this.isChatLoading(activeConv.id)) return;
let isNewConversation = false;
-
- if (!this.activeConversation) {
- await this.createConversation();
+ if (!activeConv) {
+ await conversationsStore.createConversation();
isNewConversation = true;
}
-
- if (!this.activeConversation) {
- console.error('No active conversation available for sending message');
- return;
- }
+ const currentConv = conversationsStore.activeConversation;
+ if (!currentConv) return;
this.errorDialogState = null;
-
- this.setConversationLoading(this.activeConversation.id, true);
- this.clearConversationStreaming(this.activeConversation.id);
-
- let userMessage: DatabaseMessage | null = null;
+ this.setChatLoading(currentConv.id, true);
+ this.clearChatStreaming(currentConv.id);
try {
- userMessage = await this.addMessage('user', content, 'text', '-1', extras);
-
- if (!userMessage) {
- throw new Error('Failed to add user message');
- }
-
- if (isNewConversation && content) {
- const title = content.trim();
- await this.updateConversationName(this.activeConversation.id, title);
- }
+ const userMessage = await this.addMessage('user', content, 'text', '-1', extras);
+ if (!userMessage) throw new Error('Failed to add user message');
+ if (isNewConversation && content)
+ await conversationsStore.updateConversationName(currentConv.id, content.trim());
const assistantMessage = await this.createAssistantMessage(userMessage.id);
-
- if (!assistantMessage) {
- throw new Error('Failed to create assistant message');
- }
-
- this.activeMessages.push(assistantMessage);
-
- const conversationContext = this.activeMessages.slice(0, -1);
-
- await this.streamChatCompletion(conversationContext, assistantMessage);
+ if (!assistantMessage) throw new Error('Failed to create assistant message');
+ conversationsStore.addMessageToActive(assistantMessage);
+ await this.streamChatCompletion(
+ conversationsStore.activeMessages.slice(0, -1),
+ assistantMessage
+ );
} catch (error) {
if (this.isAbortError(error)) {
- this.setConversationLoading(this.activeConversation!.id, false);
+ this.setChatLoading(currentConv.id, false);
return;
}
-
console.error('Failed to send message:', error);
- this.setConversationLoading(this.activeConversation!.id, false);
+ this.setChatLoading(currentConv.id, false);
if (!this.errorDialogState) {
- if (error instanceof Error) {
- const dialogType = error.name === 'TimeoutError' ? 'timeout' : 'server';
- this.showErrorDialog(dialogType, error.message);
- } else {
- this.showErrorDialog('server', 'Unknown error occurred while sending message');
- }
+ const dialogType =
+ error instanceof Error && error.name === 'TimeoutError' ? 'timeout' : 'server';
+ this.showErrorDialog(dialogType, error instanceof Error ? error.message : 'Unknown error');
}
}
}
- /**
- * Stops the current message generation
- * Aborts ongoing requests and saves partial response if available
- */
async stopGeneration(): Promise {
- if (!this.activeConversation) return;
-
- const convId = this.activeConversation.id;
-
- await this.savePartialResponseIfNeeded(convId);
-
- slotsService.stopStreaming();
- chatService.abort(convId);
-
- this.setConversationLoading(convId, false);
- this.clearConversationStreaming(convId);
- slotsService.clearConversationState(convId);
+ const activeConv = conversationsStore.activeConversation;
+ if (!activeConv) return;
+ await this.savePartialResponseIfNeeded(activeConv.id);
+ this.stopStreaming();
+ this.abortRequest(activeConv.id);
+ this.setChatLoading(activeConv.id, false);
+ this.clearChatStreaming(activeConv.id);
+ this.clearProcessingState(activeConv.id);
}
/**
- * Gracefully stops generation and saves partial response
+ * Gets or creates an AbortController for a conversation
*/
- async gracefulStop(): Promise {
- if (!this.isLoading) return;
-
- slotsService.stopStreaming();
- chatService.abort();
- await this.savePartialResponseIfNeeded();
-
- this.conversationLoadingStates.clear();
- this.conversationStreamingStates.clear();
- this.isLoading = false;
- this.currentResponse = '';
- }
-
- /**
- * Saves partial response if generation was interrupted
- * Preserves user's partial content and timing data when generation is stopped early
- */
- private async savePartialResponseIfNeeded(convId?: string): Promise {
- const conversationId = convId || this.activeConversation?.id;
- if (!conversationId) return;
-
- const streamingState = this.conversationStreamingStates.get(conversationId);
- if (!streamingState || !streamingState.response.trim()) {
- return;
+ private getOrCreateAbortController(convId: string): AbortController {
+ let controller = this.abortControllers.get(convId);
+ if (!controller || controller.signal.aborted) {
+ controller = new AbortController();
+ this.abortControllers.set(convId, controller);
}
+ return controller;
+ }
+
+ /**
+ * Aborts any ongoing request for a conversation
+ */
+ private abortRequest(convId?: string): void {
+ if (convId) {
+ const controller = this.abortControllers.get(convId);
+ if (controller) {
+ controller.abort();
+ this.abortControllers.delete(convId);
+ }
+ } else {
+ for (const controller of this.abortControllers.values()) {
+ controller.abort();
+ }
+ this.abortControllers.clear();
+ }
+ }
+
+ private async savePartialResponseIfNeeded(convId?: string): Promise {
+ const conversationId = convId || conversationsStore.activeConversation?.id;
+ if (!conversationId) return;
+ const streamingState = this.chatStreamingStates.get(conversationId);
+ if (!streamingState || !streamingState.response.trim()) return;
const messages =
- conversationId === this.activeConversation?.id
- ? this.activeMessages
- : await DatabaseStore.getConversationMessages(conversationId);
-
+ conversationId === conversationsStore.activeConversation?.id
+ ? conversationsStore.activeMessages
+ : await conversationsStore.getConversationMessages(conversationId);
if (!messages.length) return;
const lastMessage = messages[messages.length - 1];
-
- if (lastMessage && lastMessage.role === 'assistant') {
+ if (lastMessage?.role === 'assistant') {
try {
- const updateData: {
- content: string;
- thinking?: string;
- timings?: ChatMessageTimings;
- } = {
+ const updateData: { content: string; thinking?: string; timings?: ChatMessageTimings } = {
content: streamingState.response
};
-
- if (lastMessage.thinking?.trim()) {
- updateData.thinking = lastMessage.thinking;
- }
-
- const lastKnownState = await slotsService.getCurrentState();
-
+ if (lastMessage.thinking?.trim()) updateData.thinking = lastMessage.thinking;
+ const lastKnownState = this.getCurrentProcessingStateSync();
if (lastKnownState) {
updateData.timings = {
prompt_n: lastKnownState.promptTokens || 0,
@@ -832,446 +684,127 @@ class ChatStore {
: undefined
};
}
-
- await DatabaseStore.updateMessage(lastMessage.id, updateData);
-
+ await DatabaseService.updateMessage(lastMessage.id, updateData);
lastMessage.content = this.currentResponse;
- if (updateData.thinking !== undefined) {
- lastMessage.thinking = updateData.thinking;
- }
- if (updateData.timings) {
- lastMessage.timings = updateData.timings;
- }
+ if (updateData.thinking) lastMessage.thinking = updateData.thinking;
+ if (updateData.timings) lastMessage.timings = updateData.timings;
} catch (error) {
lastMessage.content = this.currentResponse;
console.error('Failed to save partial response:', error);
}
- } else {
- console.error('Last message is not an assistant message');
}
}
- /**
- * Updates a user message and regenerates the assistant response
- * @param messageId - The ID of the message to update
- * @param newContent - The new content for the message
- */
async updateMessage(messageId: string, newContent: string): Promise {
- if (!this.activeConversation) return;
-
- if (this.isLoading) {
- this.stopGeneration();
- }
+ const activeConv = conversationsStore.activeConversation;
+ if (!activeConv) return;
+ if (this.isLoading) this.stopGeneration();
try {
- const messageIndex = this.findMessageIndex(messageId);
- if (messageIndex === -1) {
- console.error('Message not found for update');
- return;
- }
+ const messageIndex = conversationsStore.findMessageIndex(messageId);
+ if (messageIndex === -1) return;
- const messageToUpdate = this.activeMessages[messageIndex];
+ const messageToUpdate = conversationsStore.activeMessages[messageIndex];
const originalContent = messageToUpdate.content;
+ if (messageToUpdate.role !== 'user') return;
- if (messageToUpdate.role !== 'user') {
- console.error('Only user messages can be edited');
- return;
- }
-
- const allMessages = await DatabaseStore.getConversationMessages(this.activeConversation.id);
+ 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 && messageToUpdate.role === 'user';
+ const isFirstUserMessage = rootMessage && messageToUpdate.parent === rootMessage.id;
- this.updateMessageAtIndex(messageIndex, { content: newContent });
- await DatabaseStore.updateMessage(messageId, { content: newContent });
+ conversationsStore.updateMessageAtIndex(messageIndex, { content: newContent });
+ await DatabaseService.updateMessage(messageId, { content: newContent });
if (isFirstUserMessage && newContent.trim()) {
- await this.updateConversationTitleWithConfirmation(
- this.activeConversation.id,
+ await conversationsStore.updateConversationTitleWithConfirmation(
+ activeConv.id,
newContent.trim(),
- this.titleUpdateConfirmationCallback
+ conversationsStore.titleUpdateConfirmationCallback
);
}
- const messagesToRemove = this.activeMessages.slice(messageIndex + 1);
- for (const message of messagesToRemove) {
- await DatabaseStore.deleteMessage(message.id);
- }
+ const messagesToRemove = conversationsStore.activeMessages.slice(messageIndex + 1);
+ for (const message of messagesToRemove) await DatabaseService.deleteMessage(message.id);
+ conversationsStore.sliceActiveMessages(messageIndex + 1);
+ conversationsStore.updateConversationTimestamp();
- this.activeMessages = this.activeMessages.slice(0, messageIndex + 1);
- this.updateConversationTimestamp();
+ this.setChatLoading(activeConv.id, true);
+ this.clearChatStreaming(activeConv.id);
- this.setConversationLoading(this.activeConversation.id, true);
- this.clearConversationStreaming(this.activeConversation.id);
-
- try {
- const assistantMessage = await this.createAssistantMessage();
- if (!assistantMessage) {
- throw new Error('Failed to create assistant message');
- }
-
- this.activeMessages.push(assistantMessage);
- await DatabaseStore.updateCurrentNode(this.activeConversation.id, assistantMessage.id);
- this.activeConversation.currNode = assistantMessage.id;
-
- await this.streamChatCompletion(
- this.activeMessages.slice(0, -1),
- assistantMessage,
- undefined,
- () => {
- const editedMessageIndex = this.findMessageIndex(messageId);
- this.updateMessageAtIndex(editedMessageIndex, { content: originalContent });
- }
- );
- } catch (regenerateError) {
- console.error('Failed to regenerate response:', regenerateError);
- this.setConversationLoading(this.activeConversation!.id, false);
-
- const messageIndex = this.findMessageIndex(messageId);
- this.updateMessageAtIndex(messageIndex, { content: originalContent });
- }
- } catch (error) {
- if (this.isAbortError(error)) {
- return;
- }
-
- console.error('Failed to update message:', error);
- }
- }
-
- /**
- * Regenerates an assistant message with a new response
- * @param messageId - The ID of the assistant message to regenerate
- */
- async regenerateMessage(messageId: string): Promise {
- if (!this.activeConversation || this.isLoading) return;
-
- try {
- const messageIndex = this.findMessageIndex(messageId);
- if (messageIndex === -1) {
- console.error('Message not found for regeneration');
- return;
- }
-
- const messageToRegenerate = this.activeMessages[messageIndex];
- if (messageToRegenerate.role !== 'assistant') {
- console.error('Only assistant messages can be regenerated');
- return;
- }
-
- const messagesToRemove = this.activeMessages.slice(messageIndex);
- for (const message of messagesToRemove) {
- await DatabaseStore.deleteMessage(message.id);
- }
-
- this.activeMessages = this.activeMessages.slice(0, messageIndex);
- this.updateConversationTimestamp();
-
- this.setConversationLoading(this.activeConversation.id, true);
- this.clearConversationStreaming(this.activeConversation.id);
-
- try {
- const parentMessageId =
- this.activeMessages.length > 0
- ? this.activeMessages[this.activeMessages.length - 1].id
- : null;
-
- const assistantMessage = await this.createAssistantMessage(parentMessageId);
-
- if (!assistantMessage) {
- throw new Error('Failed to create assistant message');
- }
-
- this.activeMessages.push(assistantMessage);
-
- const conversationContext = this.activeMessages.slice(0, -1);
-
- await this.streamChatCompletion(conversationContext, assistantMessage);
- } catch (regenerateError) {
- console.error('Failed to regenerate response:', regenerateError);
- this.setConversationLoading(this.activeConversation!.id, false);
- }
- } catch (error) {
- if (this.isAbortError(error)) return;
- console.error('Failed to regenerate message:', error);
- }
- }
-
- /**
- * Updates the name of a conversation
- * @param convId - The conversation ID to update
- * @param name - The new name for the conversation
- */
- async updateConversationName(convId: string, name: string): Promise {
- try {
- await DatabaseStore.updateConversation(convId, { name });
-
- const convIndex = this.conversations.findIndex((c) => c.id === convId);
-
- if (convIndex !== -1) {
- this.conversations[convIndex].name = name;
- }
-
- if (this.activeConversation?.id === convId) {
- this.activeConversation.name = name;
- }
- } catch (error) {
- console.error('Failed to update conversation name:', error);
- }
- }
-
- /**
- * Sets the callback function for title update confirmations
- * @param callback - Function to call when confirmation is needed
- */
- setTitleUpdateConfirmationCallback(
- callback: (currentTitle: string, newTitle: string) => Promise
- ): void {
- this.titleUpdateConfirmationCallback = callback;
- }
-
- /**
- * Updates conversation title with optional confirmation dialog based on settings
- * @param convId - The conversation ID to update
- * @param newTitle - The new title content
- * @param onConfirmationNeeded - Callback when user confirmation is needed
- * @returns Promise - True if title was updated, false if cancelled
- */
- async updateConversationTitleWithConfirmation(
- convId: string,
- newTitle: string,
- onConfirmationNeeded?: (currentTitle: string, newTitle: string) => Promise
- ): Promise {
- try {
- const currentConfig = config();
-
- if (currentConfig.askForTitleConfirmation && onConfirmationNeeded) {
- const conversation = await DatabaseStore.getConversation(convId);
- if (!conversation) return false;
-
- const shouldUpdate = await onConfirmationNeeded(conversation.name, newTitle);
- if (!shouldUpdate) return false;
- }
-
- await this.updateConversationName(convId, newTitle);
- return true;
- } catch (error) {
- console.error('Failed to update conversation title with confirmation:', error);
- return false;
- }
- }
-
- /**
- * Downloads a conversation as JSON file
- * @param convId - The conversation ID to download
- */
- async downloadConversation(convId: string): Promise {
- if (!this.activeConversation || this.activeConversation.id !== convId) {
- // Load the conversation if not currently active
- const conversation = await DatabaseStore.getConversation(convId);
- if (!conversation) return;
-
- const messages = await DatabaseStore.getConversationMessages(convId);
- const conversationData = {
- conv: conversation,
- messages
- };
-
- this.triggerDownload(conversationData);
- } else {
- // Use current active conversation data
- const conversationData: ExportedConversations = {
- conv: this.activeConversation!,
- messages: this.activeMessages
- };
-
- this.triggerDownload(conversationData);
- }
- }
-
- /**
- * Triggers file download in browser
- * @param data - Data to download (expected: { conv: DatabaseConversation, messages: DatabaseMessage[] })
- * @param filename - Optional filename
- */
- private triggerDownload(data: ExportedConversations, filename?: string): void {
- const conversation =
- 'conv' in data ? data.conv : Array.isArray(data) ? data[0]?.conv : undefined;
- if (!conversation) {
- console.error('Invalid data: missing conversation');
- return;
- }
- const conversationName = conversation.name ? conversation.name.trim() : '';
- const convId = conversation.id || 'unknown';
- const truncatedSuffix = conversationName
- .toLowerCase()
- .replace(/[^a-z0-9]/gi, '_')
- .replace(/_+/g, '_')
- .substring(0, 20);
- const downloadFilename = filename || `conversation_${convId}_${truncatedSuffix}.json`;
-
- const conversationJson = JSON.stringify(data, null, 2);
- const blob = new Blob([conversationJson], {
- type: 'application/json'
- });
- const url = URL.createObjectURL(blob);
- const a = document.createElement('a');
- a.href = url;
- a.download = downloadFilename;
- document.body.appendChild(a);
- a.click();
- document.body.removeChild(a);
- URL.revokeObjectURL(url);
- }
-
- /**
- * Exports all conversations with their messages as a JSON file
- * Returns the list of exported conversations
- */
- async exportAllConversations(): Promise