Merge remote-tracking branch 'webui/allozaur/server_model_management_v1_2' into xsn/server_model_management_v1_2

This commit is contained in:
Xuan Son Nguyen 2025-11-29 23:54:34 +01:00
commit 802e77eaf4
126 changed files with 5404 additions and 3623 deletions

View File

@ -200,7 +200,6 @@ The project is under active development, and we are [looking for feedback and co
| `--models-max N` | for router server, maximum number of models to load simultaneously (default: 4, 0 = unlimited)<br/>(env: LLAMA_ARG_MODELS_MAX) |
| `--models-allow-extra-args` | for router server, allow extra arguments for models; important: some arguments can allow users to access local file system, use with caution (default: disabled)<br/>(env: LLAMA_ARG_MODELS_ALLOW_EXTRA_ARGS) |
| `--no-models-autoload` | disables automatic loading of models (default: enabled)<br/>(env: LLAMA_ARG_NO_MODELS_AUTOLOAD) |
| `--jinja` | use jinja template for chat (default: disabled)<br/>(env: LLAMA_ARG_JINJA) |
| `--jinja` | use jinja template for chat (default: enabled)<br/><br/>(env: LLAMA_ARG_JINJA) |
| `--no-jinja` | disable jinja template for chat (default: enabled)<br/><br/>(env: LLAMA_ARG_NO_JINJA) |
| `--reasoning-format FORMAT` | controls whether thought tags are allowed and/or extracted from the response, and in which format they're returned; one of:<br/>- none: leaves thoughts unparsed in `message.content`<br/>- deepseek: puts thoughts in `message.reasoning_content`<br/>- deepseek-legacy: keeps `<think>` tags in `message.content` while also populating `message.reasoning_content`<br/>(default: auto)<br/>(env: LLAMA_ARG_THINK) |

Binary file not shown.

View File

@ -1,7 +1,7 @@
import type { StorybookConfig } from '@storybook/sveltekit';
const config: StorybookConfig = {
stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|ts|svelte)'],
stories: ['../tests/stories/**/*.mdx', '../tests/stories/**/*.stories.@(js|ts|svelte)'],
addons: [
'@storybook/addon-svelte-csf',
'@chromatic-com/storybook',

View File

@ -0,0 +1,252 @@
flowchart TB
subgraph Routes["📍 Routes"]
R1["/ (+page.svelte)"]
R2["/chat/[id]"]
RL["+layout.svelte"]
end
subgraph Components["🧩 Components"]
direction TB
subgraph LayoutComponents["Layout"]
C_Sidebar["ChatSidebar"]
C_Screen["ChatScreen"]
end
subgraph ChatUIComponents["Chat UI"]
C_Form["ChatForm"]
C_Messages["ChatMessages"]
C_Message["ChatMessage"]
C_Attach["ChatAttachments"]
C_ModelsSelector["ModelsSelector"]
C_Settings["ChatSettings"]
end
end
subgraph Stores["🗄️ Stores"]
direction TB
subgraph S1["chatStore"]
S1State["<b>State:</b><br/>isLoading, currentResponse<br/>errorDialogState<br/>activeProcessingState<br/>chatLoadingStates<br/>chatStreamingStates<br/>abortControllers<br/>processingStates<br/>activeConversationId<br/>isStreamingActive"]
S1LoadState["<b>Loading State:</b><br/>setChatLoading()<br/>isChatLoading()<br/>syncLoadingStateForChat()<br/>clearUIState()<br/>isChatLoadingPublic()<br/>getAllLoadingChats()<br/>getAllStreamingChats()"]
S1ProcState["<b>Processing State:</b><br/>setActiveProcessingConversation()<br/>getProcessingState()<br/>clearProcessingState()<br/>getActiveProcessingState()<br/>updateProcessingStateFromTimings()<br/>getCurrentProcessingStateSync()<br/>restoreProcessingStateFromMessages()"]
S1Stream["<b>Streaming:</b><br/>streamChatCompletion()<br/>startStreaming()<br/>stopStreaming()<br/>stopGeneration()<br/>isStreaming()"]
S1Error["<b>Error Handling:</b><br/>showErrorDialog()<br/>dismissErrorDialog()<br/>isAbortError()"]
S1Msg["<b>Message Operations:</b><br/>addMessage()<br/>sendMessage()<br/>updateMessage()<br/>deleteMessage()<br/>getDeletionInfo()"]
S1Regen["<b>Regeneration:</b><br/>regenerateMessage()<br/>regenerateMessageWithBranching()<br/>continueAssistantMessage()"]
S1Edit["<b>Editing:</b><br/>editAssistantMessage()<br/>editUserMessagePreserveResponses()<br/>editMessageWithBranching()"]
S1Utils["<b>Utilities:</b><br/>getApiOptions()<br/>parseTimingData()<br/>getOrCreateAbortController()<br/>getConversationModel()"]
end
subgraph S2["conversationsStore"]
S2State["<b>State:</b><br/>conversations<br/>activeConversation<br/>activeMessages<br/>usedModalities<br/>isInitialized<br/>titleUpdateConfirmationCallback"]
S2Modal["<b>Modalities:</b><br/>getModalitiesUpToMessage()<br/>calculateModalitiesFromMessages()"]
S2Lifecycle["<b>Lifecycle:</b><br/>initialize()<br/>loadConversations()<br/>clearActiveConversation()"]
S2ConvCRUD["<b>Conversation CRUD:</b><br/>createConversation()<br/>loadConversation()<br/>deleteConversation()<br/>updateConversationName()<br/>updateConversationTitleWithConfirmation()"]
S2MsgMgmt["<b>Message Management:</b><br/>refreshActiveMessages()<br/>addMessageToActive()<br/>updateMessageAtIndex()<br/>findMessageIndex()<br/>sliceActiveMessages()<br/>removeMessageAtIndex()<br/>getConversationMessages()"]
S2Nav["<b>Navigation:</b><br/>navigateToSibling()<br/>updateCurrentNode()<br/>updateConversationTimestamp()"]
S2Export["<b>Import/Export:</b><br/>downloadConversation()<br/>exportAllConversations()<br/>importConversations()<br/>triggerDownload()"]
S2Utils["<b>Utilities:</b><br/>setTitleUpdateConfirmationCallback()"]
end
subgraph S3["modelsStore"]
S3State["<b>State:</b><br/>models, routerModels<br/>selectedModelId<br/>selectedModelName<br/>loading, updating, error<br/>modelUsage<br/>modelLoadingStates<br/>modelPropsCache<br/>modelPropsFetching<br/>propsCacheVersion"]
S3Getters["<b>Computed Getters:</b><br/>selectedModel<br/>loadedModelIds<br/>loadingModelIds<br/>singleModelName"]
S3Modal["<b>Modalities:</b><br/>getModelModalities()<br/>modelSupportsVision()<br/>modelSupportsAudio()<br/>getModelModalitiesArray()<br/>getModelProps()<br/>updateModelModalities()"]
S3Status["<b>Status Queries:</b><br/>isModelLoaded()<br/>isModelOperationInProgress()<br/>getModelStatus()<br/>isModelPropsFetching()"]
S3Fetch["<b>Data Fetching:</b><br/>fetch()<br/>fetchRouterModels()<br/>fetchModelProps()<br/>fetchModalitiesForLoadedModels()"]
S3Select["<b>Model Selection:</b><br/>selectModelById()<br/>selectModelByName()<br/>clearSelection()<br/>findModelByName()<br/>findModelById()<br/>hasModel()"]
S3LoadUnload["<b>Loading/Unloading Models:</b><br/>loadModel()<br/>unloadModel()<br/>ensureModelLoaded()<br/>waitForModelStatus()<br/>pollForModelStatus()"]
S3Usage["<b>Usage Tracking:</b><br/>registerModelUsage()<br/>unregisterModelUsage()<br/>clearConversationUsage()<br/>getModelUsage()<br/>isModelInUse()"]
S3Utils["<b>Utilities:</b><br/>toDisplayName()<br/>clear()"]
end
subgraph S4["serverStore"]
S4State["<b>State:</b><br/>props<br/>loading, error<br/>role<br/>fetchPromise"]
S4Getters["<b>Getters:</b><br/>defaultParams<br/>contextSize<br/>slotsEndpointAvailable<br/>isRouterMode<br/>isModelMode"]
S4Data["<b>Data Handling:</b><br/>fetch()<br/>getErrorMessage()<br/>clear()"]
S4Utils["<b>Utilities:</b><br/>detectRole()"]
end
subgraph S5["settingsStore"]
S5State["<b>State:</b><br/>config<br/>theme<br/>isInitialized<br/>userOverrides"]
S5Lifecycle["<b>Lifecycle:</b><br/>initialize()<br/>loadConfig()<br/>saveConfig()<br/>loadTheme()<br/>saveTheme()"]
S5Update["<b>Config Updates:</b><br/>updateConfig()<br/>updateMultipleConfig()<br/>updateTheme()"]
S5Reset["<b>Reset:</b><br/>resetConfig()<br/>resetTheme()<br/>resetAll()<br/>resetParameterToServerDefault()"]
S5Sync["<b>Server Sync:</b><br/>syncWithServerDefaults()<br/>forceSyncWithServerDefaults()"]
S5Utils["<b>Utilities:</b><br/>getConfig()<br/>getAllConfig()<br/>getParameterInfo()<br/>getParameterDiff()<br/>getServerDefaults()<br/>clearAllUserOverrides()"]
end
subgraph ReactiveExports["⚡ Reactive Exports"]
direction LR
subgraph ChatExports["chatStore"]
RE1["isLoading()"]
RE2["currentResponse()"]
RE3["errorDialog()"]
RE4["activeProcessingState()"]
RE5["isChatStreaming()"]
RE6["isChatLoading()"]
RE7["getChatStreaming()"]
RE8["getAllLoadingChats()"]
RE9["getAllStreamingChats()"]
end
subgraph ConvExports["conversationsStore"]
RE10["conversations()"]
RE11["activeConversation()"]
RE12["activeMessages()"]
RE13["isConversationsInitialized()"]
RE14["usedModalities()"]
end
subgraph ModelsExports["modelsStore"]
RE15["modelOptions()"]
RE16["routerModels()"]
RE17["modelsLoading()"]
RE18["modelsUpdating()"]
RE19["modelsError()"]
RE20["selectedModelId()"]
RE21["selectedModelName()"]
RE22["selectedModelOption()"]
RE23["loadedModelIds()"]
RE24["loadingModelIds()"]
RE25["propsCacheVersion()"]
RE26["singleModelName()"]
end
subgraph ServerExports["serverStore"]
RE27["serverProps()"]
RE28["serverLoading()"]
RE29["serverError()"]
RE30["serverRole()"]
RE31["slotsEndpointAvailable()"]
RE32["defaultParams()"]
RE33["contextSize()"]
RE34["isRouterMode()"]
RE35["isModelMode()"]
end
subgraph SettingsExports["settingsStore"]
RE36["config()"]
RE37["theme()"]
RE38["isInitialized()"]
end
end
end
subgraph Services["⚙️ Services"]
direction TB
subgraph SV1["ChatService"]
SV1Msg["<b>Messaging:</b><br/>sendMessage()"]
SV1Stream["<b>Streaming:</b><br/>handleStreamResponse()<br/>parseSSEChunk()"]
SV1Convert["<b>Conversion:</b><br/>convertMessageToChatData()<br/>convertExtraToApiFormat()"]
SV1Utils["<b>Utilities:</b><br/>extractReasoningContent()<br/>getServerProps()<br/>getModels()"]
end
subgraph SV2["ModelsService"]
SV2List["<b>Listing:</b><br/>list()<br/>listRouter()"]
SV2LoadUnload["<b>Load/Unload:</b><br/>load()<br/>unload()"]
SV2Status["<b>Status:</b><br/>getStatus()<br/>isModelLoaded()<br/>isModelLoading()"]
end
subgraph SV3["PropsService"]
SV3Fetch["<b>Fetching:</b><br/>fetch()<br/>fetchForModel()"]
end
subgraph SV4["DatabaseService"]
SV4Conv["<b>Conversations:</b><br/>createConversation()<br/>getConversation()<br/>getAllConversations()<br/>updateConversation()<br/>deleteConversation()"]
SV4Msg["<b>Messages:</b><br/>createMessageBranch()<br/>createRootMessage()<br/>getConversationMessages()<br/>updateMessage()<br/>deleteMessage()<br/>deleteMessageCascading()"]
SV4Node["<b>Navigation:</b><br/>updateCurrentNode()"]
SV4Import["<b>Import:</b><br/>importConversations()"]
end
subgraph SV5["ParameterSyncService"]
SV5Extract["<b>Extraction:</b><br/>extractServerDefaults()"]
SV5Merge["<b>Merging:</b><br/>mergeWithServerDefaults()"]
SV5Info["<b>Info:</b><br/>getParameterInfo()<br/>canSyncParameter()<br/>getSyncableParameterKeys()<br/>validateServerParameter()"]
SV5Diff["<b>Diff:</b><br/>createParameterDiff()"]
end
end
subgraph Storage["💾 Storage"]
ST1["IndexedDB"]
ST2["conversations"]
ST3["messages"]
ST5["LocalStorage"]
ST6["config"]
ST7["userOverrides"]
end
subgraph APIs["🌐 llama-server API"]
API1["/v1/chat/completions"]
API2["/props<br/>/props?model="]
API3["/models<br/>/models/load<br/>/models/unload<br/>/models/status"]
API4["/v1/models"]
end
%% Routes render Components
R1 --> C_Screen
R2 --> C_Screen
RL --> C_Sidebar
%% Component hierarchy
C_Screen --> C_Form & C_Messages & C_Settings
C_Messages --> C_Message
C_Message --> C_ModelsSelector
C_Form --> C_ModelsSelector
C_Form --> C_Attach
C_Message --> C_Attach
%% Components use Stores
C_Screen --> S1 & S2
C_Messages --> S2
C_Message --> S1 & S2 & S3
C_Form --> S1 & S3
C_Sidebar --> S2
C_ModelsSelector --> S3 & S4
C_Settings --> S5
%% Stores export Reactive State
S1 -. exports .-> ChatExports
S2 -. exports .-> ConvExports
S3 -. exports .-> ModelsExports
S4 -. exports .-> ServerExports
S5 -. exports .-> SettingsExports
%% Stores use Services
S1 --> SV1 & SV4
S2 --> SV4
S3 --> SV2 & SV3
S4 --> SV3
S5 --> SV5
%% Services to Storage
SV4 --> ST1
ST1 --> ST2 & ST3
SV5 --> ST5
ST5 --> ST6 & ST7
%% Services to APIs
SV1 --> API1
SV2 --> API3 & API4
SV3 --> API2
%% Styling
classDef routeStyle fill:#e1f5fe,stroke:#01579b,stroke-width:2px
classDef componentStyle fill:#f3e5f5,stroke:#7b1fa2,stroke-width:2px
classDef componentGroupStyle fill:#e1bee7,stroke:#7b1fa2,stroke-width:1px
classDef storeStyle fill:#fff3e0,stroke:#e65100,stroke-width:2px
classDef stateStyle fill:#ffe0b2,stroke:#e65100,stroke-width:1px
classDef methodStyle fill:#ffecb3,stroke:#e65100,stroke-width:1px
classDef reactiveStyle fill:#fffde7,stroke:#f9a825,stroke-width:1px
classDef serviceStyle fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px
classDef serviceMStyle fill:#c8e6c9,stroke:#2e7d32,stroke-width:1px
classDef storageStyle fill:#fce4ec,stroke:#c2185b,stroke-width:2px
classDef apiStyle fill:#e3f2fd,stroke:#1565c0,stroke-width:2px
class R1,R2,RL routeStyle
class C_Sidebar,C_Screen,C_Form,C_Messages,C_Message componentStyle
class C_ModelsSelector,C_Settings componentStyle
class C_Attach componentStyle
class LayoutComponents,ChatUIComponents componentGroupStyle
class S1,S2,S3,S4,S5 storeStyle
class S1State,S2State,S3State,S4State,S5State stateStyle
class S1Msg,S1Regen,S1Edit,S1Stream,S1LoadState,S1ProcState,S1Error,S1Utils methodStyle
class S2Lifecycle,S2ConvCRUD,S2MsgMgmt,S2Nav,S2Modal,S2Export,S2Utils methodStyle
class S3Getters,S3Modal,S3Status,S3Fetch,S3Select,S3LoadUnload,S3Usage,S3Utils methodStyle
class S4Getters,S4Data,S4Utils methodStyle
class S5Lifecycle,S5Update,S5Reset,S5Sync,S5Utils methodStyle
class ChatExports,ConvExports,ModelsExports,ServerExports,SettingsExports reactiveStyle
class SV1,SV2,SV3,SV4,SV5 serviceStyle
class SV1Msg,SV1Stream,SV1Convert,SV1Utils serviceMStyle
class SV2List,SV2LoadUnload,SV2Status serviceMStyle
class SV3Fetch serviceMStyle
class SV4Conv,SV4Msg,SV4Node,SV4Import serviceMStyle
class SV5Extract,SV5Merge,SV5Info,SV5Diff serviceMStyle
class ST1,ST2,ST3,ST5,ST6,ST7 storageStyle
class API1,API2,API3,API4 apiStyle

View File

@ -64,7 +64,7 @@
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"tailwind-merge": "^3.3.1",
"tailwind-variants": "^1.0.0",
"tailwind-variants": "^3.2.2",
"tailwindcss": "^4.0.0",
"tw-animate-css": "^1.3.5",
"typescript": "^5.0.0",
@ -8324,31 +8324,23 @@
}
},
"node_modules/tailwind-variants": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/tailwind-variants/-/tailwind-variants-1.0.0.tgz",
"integrity": "sha512-2WSbv4ulEEyuBKomOunut65D8UZwxrHoRfYnxGcQNnHqlSCp2+B7Yz2W+yrNDrxRodOXtGD/1oCcKGNBnUqMqA==",
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/tailwind-variants/-/tailwind-variants-3.2.2.tgz",
"integrity": "sha512-Mi4kHeMTLvKlM98XPnK+7HoBPmf4gygdFmqQPaDivc3DpYS6aIY6KiG/PgThrGvii5YZJqRsPz0aPyhoFzmZgg==",
"dev": true,
"license": "MIT",
"dependencies": {
"tailwind-merge": "3.0.2"
},
"engines": {
"node": ">=16.x",
"pnpm": ">=7.x"
},
"peerDependencies": {
"tailwind-merge": ">=3.0.0",
"tailwindcss": "*"
}
},
"node_modules/tailwind-variants/node_modules/tailwind-merge": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.0.2.tgz",
"integrity": "sha512-l7z+OYZ7mu3DTqrL88RiKrKIqO3NcpEO8V/Od04bNpvk0kiIFndGEoqfuzvj4yuhRkHKjRkII2z+KS2HfPcSxw==",
"dev": true,
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/dcastil"
},
"peerDependenciesMeta": {
"tailwind-merge": {
"optional": true
}
}
},
"node_modules/tailwindcss": {

View File

@ -66,7 +66,7 @@
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"tailwind-merge": "^3.3.1",
"tailwind-variants": "^1.0.0",
"tailwind-variants": "^3.2.2",
"tailwindcss": "^4.0.0",
"tw-animate-css": "^1.3.5",
"typescript": "^5.0.0",

View File

@ -7,5 +7,5 @@ export default defineConfig({
timeout: 120000,
reuseExistingServer: false
},
testDir: 'e2e'
testDir: 'tests/e2e'
});

View File

@ -49,7 +49,9 @@ trap cleanup SIGINT SIGTERM
echo "🚀 Starting development servers..."
echo "📝 Note: Make sure to start llama-server separately if needed"
cd tools/server/webui
storybook dev -p 6006 --ci & vite dev --host 0.0.0.0 &
# Use --insecure-http-parser to handle malformed HTTP responses from llama-server
# (some responses have both Content-Length and Transfer-Encoding headers)
storybook dev -p 6006 --ci & NODE_OPTIONS="--insecure-http-parser" vite dev --host 0.0.0.0 &
# Wait for all background processes
wait

View File

@ -29,7 +29,7 @@
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar: oklch(0.987 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
@ -66,7 +66,7 @@
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar: oklch(0.19 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);

View File

@ -4,14 +4,19 @@
// Import chat types from dedicated module
import type {
// API types
ApiChatCompletionRequest,
ApiChatCompletionResponse,
ApiChatCompletionStreamChunk,
ApiChatCompletionToolCall,
ApiChatCompletionToolCallDelta,
ApiChatMessageData,
ApiChatMessageContentPart,
ApiContextSizeError,
ApiErrorResponse,
ApiLlamaCppServerProps,
ApiModelDataEntry,
ApiModelListResponse,
ApiProcessingState,
ApiRouterModelMeta,
ApiRouterModelsLoadRequest,
@ -20,21 +25,17 @@ import type {
ApiRouterModelsStatusResponse,
ApiRouterModelsListResponse,
ApiRouterModelsUnloadRequest,
ApiRouterModelsUnloadResponse
} from '$lib/types/api';
import { ServerMode, ServerModelStatus, ModelModality } from '$lib/enums';
import type {
ApiRouterModelsUnloadResponse,
// Chat types
ChatAttachmentDisplayItem,
ChatAttachmentPreviewItem,
ChatMessageType,
ChatRole,
ChatUploadedFile,
ChatMessageSiblingInfo,
ChatMessagePromptProgress,
ChatMessageTimings
} from '$lib/types/chat';
import type {
ChatMessageTimings,
// Database types
DatabaseConversation,
DatabaseMessage,
DatabaseMessageExtra,
@ -42,14 +43,20 @@ import type {
DatabaseMessageExtraImageFile,
DatabaseMessageExtraTextFile,
DatabaseMessageExtraPdfFile,
DatabaseMessageExtraLegacyContext
} from '$lib/types/database';
import type {
DatabaseMessageExtraLegacyContext,
ExportedConversation,
ExportedConversations,
// Model types
ModelModalities,
ModelOption,
// Settings types
SettingsChatServiceOptions,
SettingsConfigValue,
SettingsFieldConfig,
SettingsConfigType
} from '$lib/types/settings';
} from '$lib/types';
import { ServerRole, ServerModelStatus, ModelModality } from '$lib/enums';
declare global {
// namespace App {
@ -61,14 +68,19 @@ declare global {
// }
export {
// API types
ApiChatCompletionRequest,
ApiChatCompletionResponse,
ApiChatCompletionStreamChunk,
ApiChatCompletionToolCall,
ApiChatCompletionToolCallDelta,
ApiChatMessageData,
ApiChatMessageContentPart,
ApiContextSizeError,
ApiErrorResponse,
ApiLlamaCppServerProps,
ApiModelDataEntry,
ApiModelListResponse,
ApiProcessingState,
ApiRouterModelMeta,
ApiRouterModelsLoadRequest,
@ -78,13 +90,16 @@ declare global {
ApiRouterModelsListResponse,
ApiRouterModelsUnloadRequest,
ApiRouterModelsUnloadResponse,
ChatMessageData,
// Chat types
ChatAttachmentDisplayItem,
ChatAttachmentPreviewItem,
ChatMessagePromptProgress,
ChatMessageSiblingInfo,
ChatMessageTimings,
ChatMessageType,
ChatRole,
ChatUploadedFile,
// Database types
DatabaseConversation,
DatabaseMessage,
DatabaseMessageExtra,
@ -93,12 +108,19 @@ declare global {
DatabaseMessageExtraTextFile,
DatabaseMessageExtraPdfFile,
DatabaseMessageExtraLegacyContext,
ExportedConversation,
ExportedConversations,
// Enum types
ModelModality,
ServerMode,
ServerRole,
ServerModelStatus,
// Model types
ModelModalities,
ModelOption,
// Settings types
SettingsChatServiceOptions,
SettingsConfigValue,
SettingsFieldConfig,
SettingsConfigType,
SettingsChatServiceOptions
SettingsConfigType
};
}

View File

@ -1,9 +1,17 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { FileText, Image, Music, FileIcon, Eye } from '@lucide/svelte';
import type { DatabaseMessageExtra } from '$lib/types/database';
import { convertPDFToImage } from '$lib/utils/pdf-processing';
import { isTextFile, isImageFile, isPdfFile, isAudioFile } from '$lib/utils/attachment-type';
import * as Alert from '$lib/components/ui/alert';
import { SyntaxHighlightedCode } from '$lib/components/app';
import { FileText, Image, Music, FileIcon, Eye, Info } from '@lucide/svelte';
import {
isTextFile,
isImageFile,
isPdfFile,
isAudioFile,
getLanguageFromFilename
} from '$lib/utils';
import { convertPDFToImage } from '$lib/utils/browser-only';
import { modelsStore } from '$lib/stores/models.svelte';
interface Props {
// Either an uploaded file or a stored attachment
@ -13,9 +21,15 @@
preview?: string;
name?: string;
textContent?: string;
// For checking vision modality
activeModelId?: string;
}
let { uploadedFile, attachment, preview, name, textContent }: Props = $props();
let { uploadedFile, attachment, preview, name, textContent, activeModelId }: Props = $props();
let hasVisionModality = $derived(
activeModelId ? modelsStore.modelSupportsVision(activeModelId) : false
);
let displayName = $derived(uploadedFile?.name || attachment?.name || name || 'Unknown File');
@ -35,6 +49,8 @@
(attachment && 'content' in attachment ? attachment.content : textContent)
);
let language = $derived(getLanguageFromFilename(displayName));
let IconComponent = $derived(() => {
if (isImage) return Image;
if (isText || isPdf) return FileText;
@ -161,6 +177,24 @@
/>
</div>
{:else if isPdf && pdfViewMode === 'pages'}
{#if !hasVisionModality && activeModelId}
<Alert.Root class="mb-4">
<Info class="h-4 w-4" />
<Alert.Title>Preview only</Alert.Title>
<Alert.Description>
<span class="inline-flex">
The selected model does not support vision. Only the extracted
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<span class="mx-1 cursor-pointer underline" onclick={() => (pdfViewMode = 'text')}>
text
</span>
will be sent to the model.
</span>
</Alert.Description>
</Alert.Root>
{/if}
{#if pdfImagesLoading}
<div class="flex items-center justify-center p-8">
<div class="text-center">
@ -207,11 +241,7 @@
</div>
{/if}
{:else if (isText || (isPdf && pdfViewMode === 'text')) && displayTextContent}
<div
class="max-h-[60vh] overflow-auto rounded-lg bg-muted p-4 font-mono text-sm break-words whitespace-pre-wrap"
>
{displayTextContent}
</div>
<SyntaxHighlightedCode code={displayTextContent} {language} maxWidth="69rem" />
{:else if isAudio}
<div class="flex items-center justify-center p-8">
<div class="w-full max-w-md text-center">

View File

@ -1,9 +1,7 @@
<script lang="ts">
import { RemoveButton } from '$lib/components/app';
import { getFileTypeLabel, getPreviewText } from '$lib/utils/file-preview';
import { formatFileSize } from '$lib/utils/formatters';
import { isTextFile } from '$lib/utils/attachment-type';
import type { DatabaseMessageExtra } from '$lib/types/database';
import { getFileTypeLabel, getPreviewText, formatFileSize, isTextFile } from '$lib/utils';
import { AttachmentType } from '$lib/enums';
interface Props {
class?: string;
@ -34,8 +32,32 @@
let isText = $derived(isTextFile(attachment, uploadedFile));
// Get file type for display
let fileType = $derived(uploadedFile?.type || 'unknown');
let fileTypeLabel = $derived.by(() => {
if (uploadedFile?.type) {
return getFileTypeLabel(uploadedFile.type);
}
if (attachment) {
if ('mimeType' in attachment && attachment.mimeType) {
return getFileTypeLabel(attachment.mimeType);
}
if (attachment.type) {
return getFileTypeLabel(attachment.type);
}
}
return getFileTypeLabel(name);
});
let pdfProcessingMode = $derived.by(() => {
if (attachment?.type === AttachmentType.PDF) {
const pdfAttachment = attachment as DatabaseMessageExtraPdfFile;
return pdfAttachment.processedAsImages ? 'Sent as Image' : 'Sent as Text';
}
return null;
});
</script>
{#if isText}
@ -115,17 +137,21 @@
<div
class="flex h-8 w-8 items-center justify-center rounded bg-primary/10 text-xs font-medium text-primary"
>
{getFileTypeLabel(fileType)}
{fileTypeLabel}
</div>
<div class="flex flex-col gap-1">
<div class="flex flex-col gap-0.5">
<span
class="max-w-24 truncate text-sm font-medium text-foreground group-hover:pr-6 md:max-w-32"
class="max-w-24 truncate text-sm font-medium text-foreground {readonly
? ''
: 'group-hover:pr-6'} md:max-w-32"
>
{name}
</span>
{#if size}
{#if pdfProcessingMode}
<span class="text-left text-xs text-muted-foreground">{pdfProcessingMode}</span>
{:else if size}
<span class="text-left text-xs text-muted-foreground">{formatFileSize(size)}</span>
{/if}
</div>

View File

@ -2,12 +2,8 @@
import { ChatAttachmentThumbnailImage, ChatAttachmentThumbnailFile } from '$lib/components/app';
import { Button } from '$lib/components/ui/button';
import { ChevronLeft, ChevronRight } from '@lucide/svelte';
import { getFileTypeCategory } from '$lib/utils/file-type';
import { FileTypeCategory } from '$lib/enums';
import { isImageFile } from '$lib/utils/attachment-type';
import { DialogChatAttachmentPreview, DialogChatAttachmentsViewAll } from '$lib/components/app';
import type { ChatAttachmentDisplayItem, ChatAttachmentPreviewItem } from '$lib/types/chat';
import type { DatabaseMessageExtra } from '$lib/types/database';
import { getAttachmentDisplayItems } from '$lib/utils';
interface Props {
class?: string;
@ -24,6 +20,8 @@
imageWidth?: string;
// Limit display to single row with "+ X more" button
limitToSingleRow?: boolean;
// For vision modality check
activeModelId?: string;
}
let {
@ -37,10 +35,11 @@
imageClass = '',
imageHeight = 'h-24',
imageWidth = 'w-auto',
limitToSingleRow = false
limitToSingleRow = false,
activeModelId
}: Props = $props();
let displayItems = $derived(getDisplayItems());
let displayItems = $derived(getAttachmentDisplayItems({ uploadedFiles, attachments }));
let canScrollLeft = $state(false);
let canScrollRight = $state(false);
@ -51,40 +50,6 @@
let showViewAll = $derived(limitToSingleRow && displayItems.length > 0 && isScrollable);
let viewAllDialogOpen = $state(false);
function getDisplayItems(): ChatAttachmentDisplayItem[] {
const items: ChatAttachmentDisplayItem[] = [];
// Add uploaded files (ChatForm)
for (const file of uploadedFiles) {
items.push({
id: file.id,
name: file.name,
size: file.size,
preview: file.preview,
isImage: getFileTypeCategory(file.type) === FileTypeCategory.IMAGE,
uploadedFile: file,
textContent: file.textContent
});
}
// Add stored attachments (ChatMessage)
for (const [index, attachment] of attachments.entries()) {
const isImage = isImageFile(attachment);
items.push({
id: `attachment-${index}`,
name: attachment.name,
preview: isImage && 'base64Url' in attachment ? attachment.base64Url : undefined,
isImage,
attachment,
attachmentIndex: index,
textContent: 'content' in attachment ? attachment.content : undefined
});
}
return items.reverse();
}
function openPreview(item: ChatAttachmentDisplayItem, event?: MouseEvent) {
event?.stopPropagation();
event?.preventDefault();
@ -218,7 +183,7 @@
</div>
{/if}
{:else}
<div class="flex flex-wrap justify-end gap-3">
<div class="flex flex-wrap items-start justify-end gap-3">
{#each displayItems as item (item.id)}
{#if item.isImage && item.preview}
<ChatAttachmentThumbnailImage
@ -262,6 +227,7 @@
name={previewItem.name}
size={previewItem.size}
textContent={previewItem.textContent}
{activeModelId}
/>
{/if}
@ -273,4 +239,5 @@
{onFileRemove}
imageHeight="h-64"
{imageClass}
{activeModelId}
/>

View File

@ -4,11 +4,7 @@
ChatAttachmentThumbnailFile,
DialogChatAttachmentPreview
} from '$lib/components/app';
import { FileTypeCategory } from '$lib/enums';
import { getFileTypeCategory } from '$lib/utils/file-type';
import { isImageFile } from '$lib/utils/attachment-type';
import type { ChatAttachmentDisplayItem, ChatAttachmentPreviewItem } from '$lib/types/chat';
import type { DatabaseMessageExtra } from '$lib/types/database';
import { getAttachmentDisplayItems } from '$lib/utils';
interface Props {
uploadedFiles?: ChatUploadedFile[];
@ -18,6 +14,7 @@
imageHeight?: string;
imageWidth?: string;
imageClass?: string;
activeModelId?: string;
}
let {
@ -27,48 +24,17 @@
onFileRemove,
imageHeight = 'h-24',
imageWidth = 'w-auto',
imageClass = ''
imageClass = '',
activeModelId
}: Props = $props();
let previewDialogOpen = $state(false);
let previewItem = $state<ChatAttachmentPreviewItem | null>(null);
let displayItems = $derived(getDisplayItems());
let displayItems = $derived(getAttachmentDisplayItems({ uploadedFiles, attachments }));
let imageItems = $derived(displayItems.filter((item) => item.isImage));
let fileItems = $derived(displayItems.filter((item) => !item.isImage));
function getDisplayItems(): ChatAttachmentDisplayItem[] {
const items: ChatAttachmentDisplayItem[] = [];
for (const file of uploadedFiles) {
items.push({
id: file.id,
name: file.name,
size: file.size,
preview: file.preview,
isImage: getFileTypeCategory(file.type) === FileTypeCategory.IMAGE,
uploadedFile: file,
textContent: file.textContent
});
}
for (const [index, attachment] of attachments.entries()) {
const isImage = isImageFile(attachment);
items.push({
id: `attachment-${index}`,
name: attachment.name,
preview: isImage && 'base64Url' in attachment ? attachment.base64Url : undefined,
isImage,
attachment,
attachmentIndex: index,
textContent: 'content' in attachment ? attachment.content : undefined
});
}
return items.reverse();
}
function openPreview(item: (typeof displayItems)[0], event?: Event) {
if (event) {
event.preventDefault();
@ -146,5 +112,6 @@
name={previewItem.name}
size={previewItem.size}
textContent={previewItem.textContent}
{activeModelId}
/>
{/if}

View File

@ -9,6 +9,10 @@
} from '$lib/components/app';
import { INPUT_CLASSES } from '$lib/constants/input-classes';
import { config } from '$lib/stores/settings.svelte';
import { modelsStore, modelOptions, selectedModelId } from '$lib/stores/models.svelte';
import { isRouterMode } from '$lib/stores/server.svelte';
import { chatStore } from '$lib/stores/chat.svelte';
import { activeMessages } from '$lib/stores/conversations.svelte';
import {
FileTypeCategory,
MimeTypeApplication,
@ -20,14 +24,14 @@
MimeTypeImage,
MimeTypeText
} from '$lib/enums';
import { isIMEComposing } from '$lib/utils';
import {
AudioRecorder,
convertToWav,
createAudioFile,
isAudioRecordingSupported
} from '$lib/utils/audio-recording';
} from '$lib/utils/browser-only';
import { onMount } from 'svelte';
import { isIMEComposing } from '$lib/utils/is-ime-composing';
interface Props {
class?: string;
@ -54,6 +58,7 @@
}: Props = $props();
let audioRecorder: AudioRecorder | undefined;
let chatFormActionsRef: ChatFormActions | undefined = $state(undefined);
let currentConfig = $derived(config());
let fileAcceptString = $state<string | undefined>(undefined);
let fileInputRef: ChatFormFileInputInvisible | undefined = $state(undefined);
@ -64,18 +69,95 @@
let recordingSupported = $state(false);
let textareaRef: ChatFormTextarea | undefined = $state(undefined);
// Check if model is selected (in ROUTER mode)
let conversationModel = $derived(
chatStore.getConversationModel(activeMessages() as DatabaseMessage[])
);
let isRouter = $derived(isRouterMode());
let hasModelSelected = $derived(!isRouter || !!conversationModel || !!selectedModelId());
// Get active model ID for capability detection
let activeModelId = $derived.by(() => {
if (!isRouter) return null;
const options = modelOptions();
// First try user-selected model
const selectedId = selectedModelId();
if (selectedId) {
const model = options.find((m) => m.id === selectedId);
if (model) return model.model;
}
// Fallback to conversation model
if (conversationModel) {
const model = options.find((m) => m.model === conversationModel);
if (model) return model.model;
}
return null;
});
// State for model props reactivity
let modelPropsVersion = $state(0);
// Fetch model props when active model changes
$effect(() => {
if (isRouter && activeModelId) {
const cached = modelsStore.getModelProps(activeModelId);
if (!cached) {
modelsStore.fetchModelProps(activeModelId).then(() => {
modelPropsVersion++;
});
}
}
});
// Derive modalities from active model (works for both MODEL and ROUTER mode)
let hasAudioModality = $derived.by(() => {
if (activeModelId) {
void modelPropsVersion; // Trigger reactivity on props fetch
return modelsStore.modelSupportsAudio(activeModelId);
}
return false;
});
let hasVisionModality = $derived.by(() => {
if (activeModelId) {
void modelPropsVersion; // Trigger reactivity on props fetch
return modelsStore.modelSupportsVision(activeModelId);
}
return false;
});
function checkModelSelected(): boolean {
if (!hasModelSelected) {
// Open the model selector
chatFormActionsRef?.openModelSelector();
return false;
}
return true;
}
function getAcceptStringForFileType(fileType: FileTypeCategory): string {
switch (fileType) {
case FileTypeCategory.IMAGE:
return [...Object.values(FileExtensionImage), ...Object.values(MimeTypeImage)].join(',');
case FileTypeCategory.AUDIO:
return [...Object.values(FileExtensionAudio), ...Object.values(MimeTypeAudio)].join(',');
case FileTypeCategory.PDF:
return [...Object.values(FileExtensionPdf), ...Object.values(MimeTypeApplication)].join(
','
);
case FileTypeCategory.TEXT:
return [...Object.values(FileExtensionText), MimeTypeText.PLAIN].join(',');
default:
return '';
}
@ -104,6 +186,9 @@
if ((!message.trim() && uploadedFiles.length === 0) || disabled || isLoading) return;
// Check if model is selected first
if (!checkModelSelected()) return;
const messageToSend = message.trim();
const filesToSend = [...uploadedFiles];
@ -132,6 +217,7 @@
if (files.length > 0) {
event.preventDefault();
onFileUpload?.(files);
return;
}
@ -155,6 +241,7 @@
async function handleMicClick() {
if (!audioRecorder || !recordingSupported) {
console.warn('Audio recording not supported');
return;
}
@ -188,6 +275,9 @@
event.preventDefault();
if ((!message.trim() && uploadedFiles.length === 0) || disabled || isLoading) return;
// Check if model is selected first
if (!checkModelSelected()) return;
const messageToSend = message.trim();
const filesToSend = [...uploadedFiles];
@ -226,12 +316,16 @@
<ChatFormFileInputInvisible
bind:this={fileInputRef}
bind:accept={fileAcceptString}
{hasAudioModality}
{hasVisionModality}
onFileSelect={handleFileSelect}
/>
<form
onsubmit={handleSubmit}
class="{INPUT_CLASSES} border-radius-bottom-none mx-auto max-w-[48rem] overflow-hidden rounded-3xl backdrop-blur-md {className}"
class="{INPUT_CLASSES} border-radius-bottom-none mx-auto max-w-[48rem] overflow-hidden rounded-3xl backdrop-blur-md {disabled
? 'cursor-not-allowed opacity-60'
: ''} {className}"
>
<ChatAttachmentsList
bind:uploadedFiles
@ -239,6 +333,7 @@
limitToSingleRow
class="py-5"
style="scroll-padding: 1rem;"
activeModelId={activeModelId ?? undefined}
/>
<div
@ -253,6 +348,7 @@
/>
<ChatFormActions
bind:this={chatFormActionsRef}
canSend={message.trim().length > 0 || uploadedFiles.length > 0}
hasText={message.trim().length > 0}
{disabled}

View File

@ -1,22 +1,29 @@
<script lang="ts">
import { Paperclip, Image, FileText, File, Volume2 } from '@lucide/svelte';
import { Paperclip } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import * as Tooltip from '$lib/components/ui/tooltip';
import { TOOLTIP_DELAY_DURATION } from '$lib/constants/tooltip-config';
import { FILE_TYPE_ICONS } from '$lib/constants/icons';
import { FileTypeCategory } from '$lib/enums';
import { supportsAudio, supportsVision } from '$lib/stores/server.svelte';
interface Props {
class?: string;
disabled?: boolean;
hasAudioModality?: boolean;
hasVisionModality?: boolean;
onFileUpload?: (fileType?: FileTypeCategory) => void;
}
let { class: className = '', disabled = false, onFileUpload }: Props = $props();
let {
class: className = '',
disabled = false,
hasAudioModality = false,
hasVisionModality = false,
onFileUpload
}: Props = $props();
const fileUploadTooltipText = $derived.by(() => {
return !supportsVision()
return !hasVisionModality
? 'Text files and PDFs supported. Images, audio, and video require vision models.'
: 'Attach files';
});
@ -29,7 +36,7 @@
<div class="flex items-center gap-1 {className}">
<DropdownMenu.Root>
<DropdownMenu.Trigger name="Attach files">
<Tooltip.Root delayDuration={TOOLTIP_DELAY_DURATION}>
<Tooltip.Root>
<Tooltip.Trigger>
<Button
class="file-upload-button h-8 w-8 rounded-full bg-transparent p-0 text-muted-foreground hover:bg-foreground/10 hover:text-foreground"
@ -49,40 +56,40 @@
</DropdownMenu.Trigger>
<DropdownMenu.Content align="start" class="w-48">
<Tooltip.Root delayDuration={TOOLTIP_DELAY_DURATION}>
<Tooltip.Root>
<Tooltip.Trigger class="w-full">
<DropdownMenu.Item
class="images-button flex cursor-pointer items-center gap-2"
disabled={!supportsVision()}
disabled={!hasVisionModality}
onclick={() => handleFileUpload(FileTypeCategory.IMAGE)}
>
<Image class="h-4 w-4" />
<FILE_TYPE_ICONS.image class="h-4 w-4" />
<span>Images</span>
</DropdownMenu.Item>
</Tooltip.Trigger>
{#if !supportsVision()}
{#if !hasVisionModality}
<Tooltip.Content>
<p>Images require vision models to be processed</p>
</Tooltip.Content>
{/if}
</Tooltip.Root>
<Tooltip.Root delayDuration={TOOLTIP_DELAY_DURATION}>
<Tooltip.Root>
<Tooltip.Trigger class="w-full">
<DropdownMenu.Item
class="audio-button flex cursor-pointer items-center gap-2"
disabled={!supportsAudio()}
disabled={!hasAudioModality}
onclick={() => handleFileUpload(FileTypeCategory.AUDIO)}
>
<Volume2 class="h-4 w-4" />
<FILE_TYPE_ICONS.audio class="h-4 w-4" />
<span>Audio Files</span>
</DropdownMenu.Item>
</Tooltip.Trigger>
{#if !supportsAudio()}
{#if !hasAudioModality}
<Tooltip.Content>
<p>Audio files require audio models to be processed</p>
</Tooltip.Content>
@ -93,24 +100,24 @@
class="flex cursor-pointer items-center gap-2"
onclick={() => handleFileUpload(FileTypeCategory.TEXT)}
>
<FileText class="h-4 w-4" />
<FILE_TYPE_ICONS.text class="h-4 w-4" />
<span>Text Files</span>
</DropdownMenu.Item>
<Tooltip.Root delayDuration={TOOLTIP_DELAY_DURATION}>
<Tooltip.Root>
<Tooltip.Trigger class="w-full">
<DropdownMenu.Item
class="flex cursor-pointer items-center gap-2"
onclick={() => handleFileUpload(FileTypeCategory.PDF)}
>
<File class="h-4 w-4" />
<FILE_TYPE_ICONS.pdf class="h-4 w-4" />
<span>PDF Files</span>
</DropdownMenu.Item>
</Tooltip.Trigger>
{#if !supportsVision()}
{#if !hasVisionModality}
<Tooltip.Content>
<p>PDFs will be converted to text. Image-based PDFs may not work properly.</p>
</Tooltip.Content>

View File

@ -2,11 +2,11 @@
import { Mic, Square } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
import * as Tooltip from '$lib/components/ui/tooltip';
import { supportsAudio } from '$lib/stores/server.svelte';
interface Props {
class?: string;
disabled?: boolean;
hasAudioModality?: boolean;
isLoading?: boolean;
isRecording?: boolean;
onMicClick?: () => void;
@ -15,6 +15,7 @@
let {
class: className = '',
disabled = false,
hasAudioModality = false,
isLoading = false,
isRecording = false,
onMicClick
@ -22,13 +23,13 @@
</script>
<div class="flex items-center gap-1 {className}">
<Tooltip.Root delayDuration={100}>
<Tooltip.Root>
<Tooltip.Trigger>
<Button
class="h-8 w-8 rounded-full p-0 {isRecording
? 'animate-pulse bg-red-500 text-white hover:bg-red-600'
: ''}"
disabled={disabled || isLoading || !supportsAudio()}
disabled={disabled || isLoading || !hasAudioModality}
onclick={onMicClick}
type="button"
>
@ -42,7 +43,7 @@
</Button>
</Tooltip.Trigger>
{#if !supportsAudio()}
{#if !hasAudioModality}
<Tooltip.Content>
<p>Current model does not support audio</p>
</Tooltip.Content>

View File

@ -8,7 +8,7 @@
canSend?: boolean;
disabled?: boolean;
isLoading?: boolean;
isModelAvailable?: boolean;
showErrorState?: boolean;
tooltipLabel?: string;
}
@ -16,13 +16,11 @@
canSend = false,
disabled = false,
isLoading = false,
isModelAvailable = true,
showErrorState = false,
tooltipLabel
}: Props = $props();
// Error state when model is not available
let isErrorState = $derived(!isModelAvailable);
let isDisabled = $derived(!canSend || disabled || isLoading || !isModelAvailable);
let isDisabled = $derived(!canSend || disabled || isLoading);
</script>
{#snippet submitButton(props = {})}
@ -31,7 +29,7 @@
disabled={isDisabled}
class={cn(
'h-8 w-8 rounded-full p-0',
isErrorState
showErrorState
? 'bg-red-400/10 text-red-400 hover:bg-red-400/20 hover:text-red-400 disabled:opacity-100'
: ''
)}
@ -43,17 +41,15 @@
{/snippet}
{#if tooltipLabel}
<Tooltip.Provider>
<Tooltip.Root>
<Tooltip.Trigger>
{@render submitButton()}
</Tooltip.Trigger>
<Tooltip.Root>
<Tooltip.Trigger>
{@render submitButton()}
</Tooltip.Trigger>
<Tooltip.Content>
<p>{tooltipLabel}</p>
</Tooltip.Content>
</Tooltip.Root>
</Tooltip.Provider>
<Tooltip.Content>
<p>{tooltipLabel}</p>
</Tooltip.Content>
</Tooltip.Root>
{:else}
{@render submitButton()}
{/if}

View File

@ -5,14 +5,16 @@
ChatFormActionFileAttachments,
ChatFormActionRecord,
ChatFormActionSubmit,
SelectorModel
ModelsSelector
} from '$lib/components/app';
import { FileTypeCategory } from '$lib/enums';
import { getFileTypeCategory } from '$lib/utils/file-type';
import { supportsAudio } from '$lib/stores/server.svelte';
import { getFileTypeCategory } from '$lib/utils';
import { config } from '$lib/stores/settings.svelte';
import { modelOptions, selectedModelId } from '$lib/stores/models.svelte';
import type { ChatUploadedFile } from '$lib/types/chat';
import { modelsStore, modelOptions, selectedModelId } from '$lib/stores/models.svelte';
import { isRouterMode } from '$lib/stores/server.svelte';
import { chatStore } from '$lib/stores/chat.svelte';
import { activeMessages, usedModalities } from '$lib/stores/conversations.svelte';
import { useModelChangeValidation } from '$lib/hooks/use-model-change-validation.svelte';
interface Props {
canSend?: boolean;
@ -41,7 +43,74 @@
}: Props = $props();
let currentConfig = $derived(config());
let hasAudioModality = $derived(supportsAudio());
let isRouter = $derived(isRouterMode());
let conversationModel = $derived(
chatStore.getConversationModel(activeMessages() as DatabaseMessage[])
);
let previousConversationModel: string | null = null;
$effect(() => {
if (conversationModel && conversationModel !== previousConversationModel) {
previousConversationModel = conversationModel;
modelsStore.selectModelByName(conversationModel);
}
});
let activeModelId = $derived.by(() => {
if (!isRouter) return null;
const options = modelOptions();
const selectedId = selectedModelId();
if (selectedId) {
const model = options.find((m) => m.id === selectedId);
if (model) return model.model;
}
if (conversationModel) {
const model = options.find((m) => m.model === conversationModel);
if (model) return model.model;
}
return null;
});
let modelPropsVersion = $state(0); // Used to trigger reactivity after fetch
$effect(() => {
if (isRouter && activeModelId) {
const cached = modelsStore.getModelProps(activeModelId);
if (!cached) {
modelsStore.fetchModelProps(activeModelId).then(() => {
modelPropsVersion++;
});
}
}
});
let hasAudioModality = $derived.by(() => {
if (activeModelId) {
void modelPropsVersion;
return modelsStore.modelSupportsAudio(activeModelId);
}
return false;
});
let hasVisionModality = $derived.by(() => {
if (activeModelId) {
void modelPropsVersion;
return modelsStore.modelSupportsVision(activeModelId);
}
return false;
});
let hasAudioAttachments = $derived(
uploadedFiles.some((file) => getFileTypeCategory(file.type) === FileTypeCategory.AUDIO)
);
@ -49,19 +118,65 @@
hasAudioModality && !hasText && !hasAudioAttachments && currentConfig.autoMicOnEmpty
);
let isSelectedModelInCache = $derived.by(() => {
const currentModelId = selectedModelId();
let hasModelSelected = $derived(!isRouter || !!conversationModel || !!selectedModelId());
let isSelectedModelInCache = $derived.by(() => {
if (!isRouter) return true;
if (conversationModel) {
return modelOptions().some((option) => option.model === conversationModel);
}
const currentModelId = selectedModelId();
if (!currentModelId) return false;
return modelOptions().some((option) => option.id === currentModelId);
});
let submitTooltip = $derived.by(() => {
if (!hasModelSelected) {
return 'Please select a model first';
}
if (!isSelectedModelInCache) {
return 'Selected model is not available, please select another';
}
return '';
});
let selectorModelRef: ModelsSelector | undefined = $state(undefined);
export function openModelSelector() {
selectorModelRef?.open();
}
const { handleModelChange } = useModelChangeValidation({
getRequiredModalities: () => usedModalities(),
onValidationFailure: async (previousModelId) => {
if (previousModelId) {
await modelsStore.selectModelById(previousModelId);
}
}
});
</script>
<div class="flex w-full items-center gap-3 {className}" style="container-type: inline-size">
<ChatFormActionFileAttachments class="mr-auto" {disabled} {onFileUpload} />
<ChatFormActionFileAttachments
class="mr-auto"
{disabled}
{hasAudioModality}
{hasVisionModality}
{onFileUpload}
/>
<SelectorModel forceForegroundText={true} />
<ModelsSelector
bind:this={selectorModelRef}
currentModel={conversationModel}
forceForegroundText={true}
useGlobalSelection={true}
onModelChange={handleModelChange}
/>
{#if isLoading}
<Button
@ -73,16 +188,14 @@
<Square class="h-8 w-8 fill-destructive stroke-destructive" />
</Button>
{:else if shouldShowRecordButton}
<ChatFormActionRecord {disabled} {isLoading} {isRecording} {onMicClick} />
<ChatFormActionRecord {disabled} {hasAudioModality} {isLoading} {isRecording} {onMicClick} />
{:else}
<ChatFormActionSubmit
{canSend}
canSend={canSend && hasModelSelected && isSelectedModelInCache}
{disabled}
{isLoading}
tooltipLabel={isSelectedModelInCache
? ''
: 'Selected model is not available, please select another'}
isModelAvailable={isSelectedModelInCache}
tooltipLabel={submitTooltip}
showErrorState={hasModelSelected && !isSelectedModelInCache}
/>
{/if}
</div>

View File

@ -1,9 +1,11 @@
<script lang="ts">
import { generateModalityAwareAcceptString } from '$lib/utils/modality-file-validation';
import { generateModalityAwareAcceptString } from '$lib/utils';
interface Props {
accept?: string;
class?: string;
hasAudioModality?: boolean;
hasVisionModality?: boolean;
multiple?: boolean;
onFileSelect?: (files: File[]) => void;
}
@ -11,6 +13,8 @@
let {
accept = $bindable(),
class: className = '',
hasAudioModality = false,
hasVisionModality = false,
multiple = true,
onFileSelect
}: Props = $props();
@ -18,7 +22,13 @@
let fileInputElement: HTMLInputElement | undefined;
// Use modality-aware accept string by default, but allow override
let finalAccept = $derived(accept ?? generateModalityAwareAcceptString());
let finalAccept = $derived(
accept ??
generateModalityAwareAcceptString({
hasVision: hasVisionModality,
hasAudio: hasAudioModality
})
);
export function click() {
fileInputElement?.click();

View File

@ -1,5 +1,5 @@
<script lang="ts">
import autoResizeTextarea from '$lib/utils/autoresize-textarea';
import { autoResizeTextarea } from '$lib/utils';
import { onMount } from 'svelte';
interface Props {

View File

@ -1,8 +1,6 @@
<script lang="ts">
import { getDeletionInfo } from '$lib/stores/chat.svelte';
import { copyToClipboard } from '$lib/utils/copy';
import { isIMEComposing } from '$lib/utils/is-ime-composing';
import type { ApiChatCompletionToolCall } from '$lib/types/api';
import { chatStore } from '$lib/stores/chat.svelte';
import { copyToClipboard, isIMEComposing } from '$lib/utils';
import ChatMessageAssistant from './ChatMessageAssistant.svelte';
import ChatMessageUser from './ChatMessageUser.svelte';
@ -20,7 +18,7 @@
) => void;
onEditUserMessagePreserveResponses?: (message: DatabaseMessage, newContent: string) => void;
onNavigateToSibling?: (siblingId: string) => void;
onRegenerateWithBranching?: (message: DatabaseMessage) => void;
onRegenerateWithBranching?: (message: DatabaseMessage, modelOverride?: string) => void;
siblingInfo?: ChatMessageSiblingInfo | null;
}
@ -98,7 +96,7 @@
}
async function handleDelete() {
deletionInfo = await getDeletionInfo(message.id);
deletionInfo = await chatStore.getDeletionInfo(message.id);
showDeleteDialog = true;
}
@ -133,8 +131,8 @@
}
}
function handleRegenerate() {
onRegenerateWithBranching?.(message);
function handleRegenerate(modelOverride?: string) {
onRegenerateWithBranching?.(message, modelOverride);
}
function handleContinue() {

View File

@ -71,7 +71,7 @@
{/if}
{#if role === 'assistant' && onRegenerate}
<ActionButton icon={RefreshCw} tooltip="Regenerate" onclick={onRegenerate} />
<ActionButton icon={RefreshCw} tooltip="Regenerate" onclick={() => onRegenerate()} />
{/if}
{#if role === 'assistant' && onContinue}

View File

@ -1,16 +1,17 @@
<script lang="ts">
import {
BadgeModelName,
ModelBadge,
ChatMessageActions,
ChatMessageStatistics,
ChatMessageThinkingBlock,
CopyToClipboardIcon,
MarkdownContent,
SelectorModel
ModelsSelector
} from '$lib/components/app';
import { useProcessingState } from '$lib/hooks/use-processing-state.svelte';
import { useModelChangeValidation } from '$lib/hooks/use-model-change-validation.svelte';
import { isLoading } from '$lib/stores/chat.svelte';
import autoResizeTextarea from '$lib/utils/autoresize-textarea';
import { autoResizeTextarea, copyToClipboard } from '$lib/utils';
import { fade } from 'svelte/transition';
import { Check, X, Wrench } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
@ -18,10 +19,8 @@
import { INPUT_CLASSES } from '$lib/constants/input-classes';
import Label from '$lib/components/ui/label/label.svelte';
import { config } from '$lib/stores/settings.svelte';
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { isRouterMode } from '$lib/stores/server.svelte';
import { selectModel } from '$lib/stores/models.svelte';
import { copyToClipboard } from '$lib/utils/copy';
import type { ApiChatCompletionToolCall } from '$lib/types/api';
interface Props {
class?: string;
@ -44,7 +43,7 @@
onEditKeydown?: (event: KeyboardEvent) => void;
onEditedContentChange?: (content: string) => void;
onNavigateToSibling?: (siblingId: string) => void;
onRegenerate: () => void;
onRegenerate: (modelOverride?: string) => void;
onSaveEdit?: () => void;
onShowDeleteDialogChange: (show: boolean) => void;
onShouldBranchAfterEditChange?: (value: boolean) => void;
@ -93,9 +92,6 @@
let currentConfig = $derived(config());
let isRouter = $derived(isRouterMode());
let displayedModel = $derived((): string | null => {
if (!currentConfig.showModelInfo) return null;
// Only show model from streaming data, no fallbacks to server props
if (message.model) {
return message.model;
}
@ -103,15 +99,10 @@
return null;
});
async function handleModelChange(modelId: string) {
try {
await selectModel(modelId);
onRegenerate();
} catch (error) {
console.error('Failed to change model:', error);
}
}
const { handleModelChange } = useModelChangeValidation({
getRequiredModalities: () => conversationsStore.getModalitiesUpToMessage(message.id),
onSuccess: (modelName) => onRegenerate(modelName)
});
function handleCopyModel() {
const model = displayedModel();
@ -254,18 +245,15 @@
<div class="info my-6 grid gap-4">
{#if displayedModel()}
<span class="inline-flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
{#if isRouter && currentConfig.modelSelectorEnabled}
<SelectorModel
{#if isRouter}
<ModelsSelector
currentModel={displayedModel()}
onModelChange={handleModelChange}
disabled={isLoading()}
upToMessageId={message.id}
/>
{:else}
<BadgeModelName
model={displayedModel() || undefined}
onclick={handleCopyModel}
showCopyIcon={true}
/>
<ModelBadge model={displayedModel() || undefined} onclick={handleCopyModel} />
{/if}
{#if currentConfig.showMessageStats && message.timings && message.timings.predicted_n && message.timings.predicted_ms}

View File

@ -5,7 +5,7 @@
import { ChatAttachmentsList, MarkdownContent } from '$lib/components/app';
import { INPUT_CLASSES } from '$lib/constants/input-classes';
import { config } from '$lib/stores/settings.svelte';
import autoResizeTextarea from '$lib/utils/autoresize-textarea';
import { autoResizeTextarea } from '$lib/utils';
import ChatMessageActions from './ChatMessageActions.svelte';
interface Props {

View File

@ -1,17 +1,9 @@
<script lang="ts">
import { ChatMessage } from '$lib/components/app';
import { DatabaseStore } from '$lib/stores/database';
import {
activeConversation,
continueAssistantMessage,
deleteMessage,
editAssistantMessage,
editMessageWithBranching,
editUserMessagePreserveResponses,
navigateToSibling,
regenerateMessageWithBranching
} from '$lib/stores/chat.svelte';
import { getMessageSiblings } from '$lib/utils/branching';
import { DatabaseService } from '$lib/services/database';
import { chatStore } from '$lib/stores/chat.svelte';
import { conversationsStore, activeConversation } from '$lib/stores/conversations.svelte';
import { getMessageSiblings } from '$lib/utils';
interface Props {
class?: string;
@ -27,7 +19,7 @@
const conversation = activeConversation();
if (conversation) {
DatabaseStore.getConversationMessages(conversation.id).then((messages) => {
DatabaseService.getConversationMessages(conversation.id).then((messages) => {
allConversationMessages = messages;
});
} else {
@ -65,13 +57,13 @@
});
async function handleNavigateToSibling(siblingId: string) {
await navigateToSibling(siblingId);
await conversationsStore.navigateToSibling(siblingId);
}
async function handleEditWithBranching(message: DatabaseMessage, newContent: string) {
onUserAction?.();
await editMessageWithBranching(message.id, newContent);
await chatStore.editMessageWithBranching(message.id, newContent);
refreshAllMessages();
}
@ -83,15 +75,15 @@
) {
onUserAction?.();
await editAssistantMessage(message.id, newContent, shouldBranch);
await chatStore.editAssistantMessage(message.id, newContent, shouldBranch);
refreshAllMessages();
}
async function handleRegenerateWithBranching(message: DatabaseMessage) {
async function handleRegenerateWithBranching(message: DatabaseMessage, modelOverride?: string) {
onUserAction?.();
await regenerateMessageWithBranching(message.id);
await chatStore.regenerateMessageWithBranching(message.id, modelOverride);
refreshAllMessages();
}
@ -99,7 +91,7 @@
async function handleContinueAssistantMessage(message: DatabaseMessage) {
onUserAction?.();
await continueAssistantMessage(message.id);
await chatStore.continueAssistantMessage(message.id);
refreshAllMessages();
}
@ -110,13 +102,13 @@
) {
onUserAction?.();
await editUserMessagePreserveResponses(message.id, newContent);
await chatStore.editUserMessagePreserveResponses(message.id, newContent);
refreshAllMessages();
}
async function handleDeleteMessage(message: DatabaseMessage) {
await deleteMessage(message.id);
await chatStore.deleteMessage(message.id);
refreshAllMessages();
}

View File

@ -3,48 +3,35 @@
import {
ChatForm,
ChatScreenHeader,
ChatScreenWarning,
ChatMessages,
ChatScreenProcessingInfo,
DialogEmptyFileAlert,
DialogChatError,
ServerErrorSplash,
ServerLoadingSplash,
DialogConfirmation
} from '$lib/components/app';
import * as Alert from '$lib/components/ui/alert';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import {
AUTO_SCROLL_AT_BOTTOM_THRESHOLD,
AUTO_SCROLL_INTERVAL,
INITIAL_SCROLL_DELAY
} from '$lib/constants/auto-scroll';
import { chatStore, errorDialog, isLoading } from '$lib/stores/chat.svelte';
import {
conversationsStore,
activeMessages,
activeConversation,
deleteConversation,
dismissErrorDialog,
errorDialog,
isLoading,
sendMessage,
stopGeneration
} from '$lib/stores/chat.svelte';
activeConversation
} from '$lib/stores/conversations.svelte';
import { config } from '$lib/stores/settings.svelte';
import {
supportsVision,
supportsAudio,
serverLoading,
serverWarning,
serverStore
} from '$lib/stores/server.svelte';
import { parseFilesToMessageExtras } from '$lib/utils/convert-files-to-extra';
import { isFileTypeSupported } from '$lib/utils/file-type';
import { filterFilesByModalities } from '$lib/utils/modality-file-validation';
import { processFilesToChatUploaded } from '$lib/utils/process-uploaded-files';
import { serverLoading, serverError, serverStore, isRouterMode } from '$lib/stores/server.svelte';
import { modelsStore, modelOptions, selectedModelId } from '$lib/stores/models.svelte';
import { isFileTypeSupported, filterFilesByModalities } from '$lib/utils';
import { parseFilesToMessageExtras, processFilesToChatUploaded } from '$lib/utils/browser-only';
import { onMount } from 'svelte';
import { fade, fly, slide } from 'svelte/transition';
import { Trash2 } from '@lucide/svelte';
import { Trash2, AlertTriangle, RefreshCw } from '@lucide/svelte';
import ChatScreenDragOverlay from './ChatScreenDragOverlay.svelte';
import { ModelModality } from '$lib/enums';
let { showCenteredEmpty = false } = $props();
@ -84,20 +71,82 @@
let activeErrorDialog = $derived(errorDialog());
let isServerLoading = $derived(serverLoading());
let hasPropsError = $derived(!!serverError());
let isCurrentConversationLoading = $derived(isLoading());
let isRouter = $derived(isRouterMode());
let conversationModel = $derived(
chatStore.getConversationModel(activeMessages() as DatabaseMessage[])
);
let activeModelId = $derived.by(() => {
if (!isRouter) return null;
const options = modelOptions();
const selectedId = selectedModelId();
if (selectedId) {
const model = options.find((m) => m.id === selectedId);
if (model) return model.model;
}
if (conversationModel) {
const model = options.find((m) => m.model === conversationModel);
if (model) return model.model;
}
return null;
});
let modelPropsVersion = $state(0);
$effect(() => {
if (isRouter && activeModelId) {
const cached = modelsStore.getModelProps(activeModelId);
if (!cached) {
modelsStore.fetchModelProps(activeModelId).then(() => {
modelPropsVersion++;
});
}
}
});
let hasAudioModality = $derived.by(() => {
if (activeModelId) {
void modelPropsVersion;
return modelsStore.modelSupportsAudio(activeModelId);
}
return false;
});
let hasVisionModality = $derived.by(() => {
if (activeModelId) {
void modelPropsVersion;
return modelsStore.modelSupportsVision(activeModelId);
}
return false;
});
async function handleDeleteConfirm() {
const conversation = activeConversation();
if (conversation) {
await deleteConversation(conversation.id);
await conversationsStore.deleteConversation(conversation.id);
}
showDeleteDialog = false;
}
function handleDragEnter(event: DragEvent) {
event.preventDefault();
dragCounter++;
if (event.dataTransfer?.types.includes('Files')) {
isDragOver = true;
}
@ -105,7 +154,9 @@
function handleDragLeave(event: DragEvent) {
event.preventDefault();
dragCounter--;
if (dragCounter === 0) {
isDragOver = false;
}
@ -113,7 +164,7 @@
function handleErrorDialogOpenChange(open: boolean) {
if (!open) {
dismissErrorDialog();
chatStore.dismissErrorDialog();
}
}
@ -123,6 +174,7 @@
function handleDrop(event: DragEvent) {
event.preventDefault();
isDragOver = false;
dragCounter = 0;
@ -180,7 +232,9 @@
}
async function handleSendMessage(message: string, files?: ChatUploadedFile[]): Promise<boolean> {
const result = files ? await parseFilesToMessageExtras(files) : undefined;
const result = files
? await parseFilesToMessageExtras(files, activeModelId ?? undefined)
: undefined;
if (result?.emptyFiles && result.emptyFiles.length > 0) {
emptyFileNames = result.emptyFiles;
@ -200,7 +254,7 @@
userScrolledUp = false;
autoScrollEnabled = true;
}
await sendMessage(message, extras);
await chatStore.sendMessage(message, extras);
scrollChatToBottom();
return true;
@ -218,16 +272,20 @@
}
}
const { supportedFiles, unsupportedFiles, modalityReasons } =
filterFilesByModalities(generallySupported);
// Use model-specific capabilities for file validation
const capabilities = { hasVision: hasVisionModality, hasAudio: hasAudioModality };
const { supportedFiles, unsupportedFiles, modalityReasons } = filterFilesByModalities(
generallySupported,
capabilities
);
const allUnsupportedFiles = [...generallyUnsupported, ...unsupportedFiles];
if (allUnsupportedFiles.length > 0) {
const supportedTypes: string[] = ['text files', 'PDFs'];
if (supportsVision()) supportedTypes.push('images');
if (supportsAudio()) supportedTypes.push('audio files');
if (hasVisionModality) supportedTypes.push('images');
if (hasAudioModality) supportedTypes.push('audio files');
fileErrorData = {
generallyUnsupported,
@ -239,7 +297,10 @@
}
if (supportedFiles.length > 0) {
const processed = await processFilesToChatUploaded(supportedFiles);
const processed = await processFilesToChatUploaded(
supportedFiles,
activeModelId ?? undefined
);
uploadedFiles = [...uploadedFiles, ...processed];
}
}
@ -322,17 +383,37 @@
>
<ChatScreenProcessingInfo />
{#if serverWarning()}
<ChatScreenWarning class="pointer-events-auto mx-auto max-w-[48rem] px-4" />
{#if hasPropsError}
<div
class="pointer-events-auto mx-auto mb-3 max-w-[48rem] px-4"
in:fly={{ y: 10, duration: 250 }}
>
<Alert.Root variant="destructive">
<AlertTriangle class="h-4 w-4" />
<Alert.Title class="flex items-center justify-between">
<span>Server unavailable</span>
<button
onclick={() => serverStore.fetch()}
disabled={isServerLoading}
class="flex items-center gap-1.5 rounded-lg bg-destructive/20 px-2 py-1 text-xs font-medium hover:bg-destructive/30 disabled:opacity-50"
>
<RefreshCw class="h-3 w-3 {isServerLoading ? 'animate-spin' : ''}" />
{isServerLoading ? 'Retrying...' : 'Retry'}
</button>
</Alert.Title>
<Alert.Description>{serverError()}</Alert.Description>
</Alert.Root>
</div>
{/if}
<div class="conversation-chat-form pointer-events-auto rounded-t-3xl pb-4">
<ChatForm
disabled={hasPropsError}
isLoading={isCurrentConversationLoading}
onFileRemove={handleFileRemove}
onFileUpload={handleFileUpload}
onSend={handleSendMessage}
onStop={() => stopGeneration()}
onStop={() => chatStore.stopGeneration()}
showHelperText={false}
bind:uploadedFiles
/>
@ -342,9 +423,7 @@
{:else if isServerLoading}
<!-- Server Loading State -->
<ServerLoadingSplash />
{:else if serverStore.error && !serverStore.modelName}
<ServerErrorSplash error={serverStore.error} />
{:else if serverStore.modelName}
{:else}
<div
aria-label="Welcome screen with file drop zone"
class="flex h-full items-center justify-center"
@ -359,23 +438,40 @@
<h1 class="mb-4 text-3xl font-semibold tracking-tight">llama.cpp</h1>
<p class="text-lg text-muted-foreground">
{serverStore.supportedModalities.includes(ModelModality.AUDIO)
{serverStore.props?.modalities?.audio
? 'Record audio, type a message '
: 'Type a message'} or upload files to get started
</p>
</div>
{#if serverWarning()}
<ChatScreenWarning />
{#if hasPropsError}
<div class="mb-4" in:fly={{ y: 10, duration: 250 }}>
<Alert.Root variant="destructive">
<AlertTriangle class="h-4 w-4" />
<Alert.Title class="flex items-center justify-between">
<span>Server unavailable</span>
<button
onclick={() => serverStore.fetch()}
disabled={isServerLoading}
class="flex items-center gap-1.5 rounded-lg bg-destructive/20 px-2 py-1 text-xs font-medium hover:bg-destructive/30 disabled:opacity-50"
>
<RefreshCw class="h-3 w-3 {isServerLoading ? 'animate-spin' : ''}" />
{isServerLoading ? 'Retrying...' : 'Retry'}
</button>
</Alert.Title>
<Alert.Description>{serverError()}</Alert.Description>
</Alert.Root>
</div>
{/if}
<div in:fly={{ y: 10, duration: 250, delay: 300 }}>
<div in:fly={{ y: 10, duration: 250, delay: hasPropsError ? 0 : 300 }}>
<ChatForm
disabled={hasPropsError}
isLoading={isCurrentConversationLoading}
onFileRemove={handleFileRemove}
onFileUpload={handleFileUpload}
onSend={handleSendMessage}
onStop={() => stopGeneration()}
onStop={() => chatStore.stopGeneration()}
showHelperText={true}
bind:uploadedFiles
/>

View File

@ -1,34 +1,47 @@
<script lang="ts">
import { untrack } from 'svelte';
import { PROCESSING_INFO_TIMEOUT } from '$lib/constants/processing-info';
import { useProcessingState } from '$lib/hooks/use-processing-state.svelte';
import { slotsService } from '$lib/services/slots';
import { isLoading, activeMessages, activeConversation } from '$lib/stores/chat.svelte';
import { chatStore, isLoading, isChatStreaming } from '$lib/stores/chat.svelte';
import { activeMessages, activeConversation } from '$lib/stores/conversations.svelte';
import { config } from '$lib/stores/settings.svelte';
const processingState = useProcessingState();
let isCurrentConversationLoading = $derived(isLoading());
let isStreaming = $derived(isChatStreaming());
let hasProcessingData = $derived(processingState.processingState !== null);
let processingDetails = $derived(processingState.getProcessingDetails());
let showSlotsInfo = $derived(isCurrentConversationLoading || config().keepStatsVisible);
// Track loading state reactively by checking if conversation ID is in loading conversations array
let showProcessingInfo = $derived(
isCurrentConversationLoading || isStreaming || config().keepStatsVisible || hasProcessingData
);
$effect(() => {
const conversation = activeConversation();
untrack(() => chatStore.setActiveProcessingConversation(conversation?.id ?? null));
});
$effect(() => {
const keepStatsVisible = config().keepStatsVisible;
const shouldMonitor = keepStatsVisible || isCurrentConversationLoading || isStreaming;
if (keepStatsVisible || isCurrentConversationLoading) {
if (shouldMonitor) {
processingState.startMonitoring();
}
if (!isCurrentConversationLoading && !keepStatsVisible) {
setTimeout(() => {
if (!config().keepStatsVisible) {
if (!isCurrentConversationLoading && !isStreaming && !keepStatsVisible) {
const timeout = setTimeout(() => {
if (!config().keepStatsVisible && !isChatStreaming()) {
processingState.stopMonitoring();
}
}, PROCESSING_INFO_TIMEOUT);
return () => clearTimeout(timeout);
}
});
// Update processing state from stored timings
$effect(() => {
const conversation = activeConversation();
const messages = activeMessages() as DatabaseMessage[];
@ -36,47 +49,18 @@
if (keepStatsVisible && conversation) {
if (messages.length === 0) {
slotsService.clearConversationState(conversation.id);
untrack(() => chatStore.clearProcessingState(conversation.id));
return;
}
// Search backwards through messages to find most recent assistant message with timing data
// Using reverse iteration for performance - avoids array copy and stops at first match
let foundTimingData = false;
for (let i = messages.length - 1; i >= 0; i--) {
const message = messages[i];
if (message.role === 'assistant' && message.timings) {
foundTimingData = true;
slotsService
.updateFromTimingData(
{
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
},
conversation.id
)
.catch((error) => {
console.warn('Failed to update processing state from stored timings:', error);
});
break;
}
}
if (!foundTimingData) {
slotsService.clearConversationState(conversation.id);
if (!isCurrentConversationLoading && !isStreaming) {
untrack(() => chatStore.restoreProcessingStateFromMessages(messages, conversation.id));
}
}
});
</script>
<div class="chat-processing-info-container pointer-events-none" class:visible={showSlotsInfo}>
<div class="chat-processing-info-container pointer-events-none" class:visible={showProcessingInfo}>
<div class="chat-processing-info-content">
{#each processingDetails as detail (detail)}
<span class="chat-processing-info-detail pointer-events-auto">{detail}</span>

View File

@ -1,38 +0,0 @@
<script lang="ts">
import { AlertTriangle, RefreshCw } from '@lucide/svelte';
import { serverLoading, serverStore } from '$lib/stores/server.svelte';
import { fly } from 'svelte/transition';
interface Props {
class?: string;
}
let { class: className = '' }: Props = $props();
function handleRefreshServer() {
serverStore.fetchServerProps();
}
</script>
<div class="mb-3 {className}" in:fly={{ y: 10, duration: 250 }}>
<div
class="rounded-md border border-yellow-200 bg-yellow-50 px-3 py-2 dark:border-yellow-800 dark:bg-yellow-950"
>
<div class="flex items-center justify-between">
<div class="flex items-center">
<AlertTriangle class="h-4 w-4 text-yellow-600 dark:text-yellow-400" />
<p class="ml-2 text-sm text-yellow-800 dark:text-yellow-200">
Server `/props` endpoint not available - using cached data
</p>
</div>
<button
onclick={handleRefreshServer}
disabled={serverLoading()}
class="ml-3 flex items-center gap-1.5 rounded bg-yellow-100 px-2 py-1 text-xs font-medium text-yellow-800 hover:bg-yellow-200 disabled:opacity-50 dark:bg-yellow-900 dark:text-yellow-200 dark:hover:bg-yellow-800"
>
<RefreshCw class="h-3 w-3 {serverLoading() ? 'animate-spin' : ''}" />
{serverLoading() ? 'Checking...' : 'Retry'}
</button>
</div>
</div>
</div>

View File

@ -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'
}
]
},
@ -232,11 +228,6 @@
title: 'Developer',
icon: Code,
fields: [
{
key: 'modelSelectorEnabled',
label: 'Enable model selector',
type: 'checkbox'
},
{
key: 'showToolCalls',
label: 'Show tool call labels',
@ -342,7 +333,7 @@
}
}
updateMultipleConfig(processedConfig);
settingsStore.updateMultipleConfig(processedConfig);
onSave?.();
}

View File

@ -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);
}
</script>
@ -82,7 +81,7 @@
<button
type="button"
onclick={() => {
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 @@
<button
type="button"
onclick={() => {
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,13 +209,10 @@
</p>
{/if}
{:else if field.type === 'checkbox'}
{@const isDisabled = field.key === 'pdfAsImage' && !supportsVision()}
<div class="flex items-start space-x-3">
<Checkbox
id={field.key}
checked={Boolean(localConfig[field.key])}
disabled={isDisabled}
onCheckedChange={(checked) => onConfigChange(field.key, checked)}
class="mt-1"
/>
@ -224,9 +220,7 @@
<div class="space-y-1">
<label
for={field.key}
class="cursor-pointer pt-1 pb-0.5 text-sm leading-none font-medium {isDisabled
? 'text-muted-foreground'
: ''} flex items-center gap-1.5"
class="flex cursor-pointer items-center gap-1.5 pt-1 pb-0.5 text-sm leading-none font-medium"
>
{field.label}
@ -239,11 +233,6 @@
<p class="text-xs text-muted-foreground">
{field.help || SETTING_CONFIG_INFO[field.key]}
</p>
{:else if field.key === 'pdfAsImage' && !supportsVision()}
<p class="text-xs text-muted-foreground">
PDF-to-image processing requires a vision-capable model. PDFs will be processed as
text.
</p>
{/if}
</div>
</div>

View File

@ -1,7 +1,7 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import { forceSyncWithServerDefaults } from '$lib/stores/settings.svelte';
import { settingsStore } from '$lib/stores/settings.svelte';
import { RotateCcw } from '@lucide/svelte';
interface Props {
@ -18,7 +18,7 @@
}
function handleConfirmReset() {
forceSyncWithServerDefaults();
settingsStore.forceSyncWithServerDefaults();
onReset?.();
showResetDialog = false;

View File

@ -2,10 +2,9 @@
import { Download, Upload } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
import { DialogConversationSelection } from '$lib/components/app';
import { DatabaseStore } from '$lib/stores/database';
import type { ExportedConversations } from '$lib/types/database';
import { createMessageCountMap } from '$lib/utils/conversation-utils';
import { chatStore } from '$lib/stores/chat.svelte';
import { DatabaseService } from '$lib/services/database';
import { createMessageCountMap } from '$lib/utils';
import { conversationsStore } from '$lib/stores/conversations.svelte';
let exportedConversations = $state<DatabaseConversation[]>([]);
let importedConversations = $state<DatabaseConversation[]>([]);
@ -22,7 +21,7 @@
async function handleExportClick() {
try {
const allConversations = await DatabaseStore.getAllConversations();
const allConversations = await DatabaseService.getAllConversations();
if (allConversations.length === 0) {
alert('No conversations to export');
return;
@ -30,7 +29,7 @@
const conversationsWithMessages = await Promise.all(
allConversations.map(async (conv) => {
const messages = await DatabaseStore.getConversationMessages(conv.id);
const messages = await DatabaseService.getConversationMessages(conv.id);
return { conv, messages };
})
);
@ -48,7 +47,7 @@
try {
const allData: ExportedConversations = await Promise.all(
selectedConversations.map(async (conv) => {
const messages = await DatabaseStore.getConversationMessages(conv.id);
const messages = await DatabaseService.getConversationMessages(conv.id);
return { conv: $state.snapshot(conv), messages: $state.snapshot(messages) };
})
);
@ -136,9 +135,9 @@
.snapshot(fullImportData)
.filter((item) => selectedIds.has(item.conv.id));
await DatabaseStore.importConversations(selectedData);
await DatabaseService.importConversations(selectedData);
await chatStore.loadConversations();
await conversationsStore.loadConversations();
importedConversations = selectedConversations;
showImportSummary = true;

View File

@ -7,11 +7,7 @@
import * as Sidebar from '$lib/components/ui/sidebar';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import Input from '$lib/components/ui/input/input.svelte';
import {
conversations,
deleteConversation,
updateConversationName
} from '$lib/stores/chat.svelte';
import { conversationsStore, conversations } from '$lib/stores/conversations.svelte';
import ChatSidebarActions from './ChatSidebarActions.svelte';
const sidebar = Sidebar.useSidebar();
@ -56,7 +52,7 @@
showDeleteDialog = false;
setTimeout(() => {
deleteConversation(selectedConversation.id);
conversationsStore.deleteConversation(selectedConversation.id);
selectedConversation = null;
}, 100); // Wait for animation to finish
}
@ -67,7 +63,7 @@
showEditDialog = false;
updateConversationName(selectedConversation.id, editedName);
conversationsStore.updateConversationName(selectedConversation.id, editedName);
selectedConversation = null;
}

View File

@ -1,7 +1,8 @@
<script lang="ts">
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']
},

View File

@ -1,9 +1,7 @@
<script lang="ts">
import * as Dialog from '$lib/components/ui/dialog';
import type { DatabaseMessageExtra } from '$lib/types/database';
import { ChatAttachmentPreview } from '$lib/components/app';
import { formatFileSize } from '$lib/utils/formatters';
import { getAttachmentTypeLabel } from '$lib/utils/attachment-type';
import { formatFileSize } from '$lib/utils';
interface Props {
open: boolean;
@ -16,6 +14,8 @@
name?: string;
size?: number;
textContent?: string;
// For vision modality check
activeModelId?: string;
}
let {
@ -26,7 +26,8 @@
preview,
name,
size,
textContent
textContent,
activeModelId
}: Props = $props();
let chatAttachmentPreviewRef: ChatAttachmentPreview | undefined = $state();
@ -35,8 +36,6 @@
let displaySize = $derived(uploadedFile?.size || size);
let typeLabel = $derived(getAttachmentTypeLabel(uploadedFile, attachment));
$effect(() => {
if (open && chatAttachmentPreviewRef) {
chatAttachmentPreviewRef.reset();
@ -49,9 +48,8 @@
<Dialog.Header>
<Dialog.Title class="pr-8">{displayName}</Dialog.Title>
<Dialog.Description>
{typeLabel}
{#if displaySize}
{formatFileSize(displaySize)}
{formatFileSize(displaySize)}
{/if}
</Dialog.Description>
</Dialog.Header>
@ -63,6 +61,7 @@
{preview}
name={displayName}
{textContent}
{activeModelId}
/>
</Dialog.Content>
</Dialog.Root>

View File

@ -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}
/>
</Dialog.Content>
</Dialog.Portal>

View File

@ -3,9 +3,9 @@
import * as Table from '$lib/components/ui/table';
import { BadgeModality, CopyToClipboardIcon } from '$lib/components/app';
import { serverStore } from '$lib/stores/server.svelte';
import { modelsStore } from '$lib/stores/models.svelte';
import { ChatService } from '$lib/services/chat';
import type { ApiModelListResponse } from '$lib/types/api';
import { formatFileSize, formatParameters, formatNumber } from '$lib/utils/formatters';
import { formatFileSize, formatParameters, formatNumber } from '$lib/utils';
interface Props {
open?: boolean;
@ -14,8 +14,16 @@
let { open = $bindable(), onOpenChange }: Props = $props();
let serverProps = $derived(serverStore.serverProps);
let modalities = $derived(serverStore.supportedModalities);
let serverProps = $derived(serverStore.props);
let modelName = $derived(modelsStore.singleModelName);
// Get modalities from modelStore using the model ID from the first model
// For now it supports only for single-model mode, will be extended with further improvements for multi-model functioanlities
let modalities = $derived.by(() => {
if (!modelsData?.data?.[0]?.id) return [];
return modelsStore.getModelModalitiesArray(modelsData.data[0].id);
});
let modelsData = $state<ApiModelListResponse | null>(null);
let isLoadingModels = $state(false);
@ -77,12 +85,12 @@
class="resizable-text-container min-w-0 flex-1 truncate"
style:--threshold="12rem"
>
{serverStore.modelName}
{modelName}
</span>
<CopyToClipboardIcon
text={serverStore.modelName || ''}
canCopy={!!serverStore.modelName}
text={modelName || ''}
canCopy={!!modelName}
ariaLabel="Copy model name to clipboard"
/>
</div>

View File

@ -0,0 +1,76 @@
<script lang="ts">
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import { AlertTriangle, ArrowRight } from '@lucide/svelte';
import { goto } from '$app/navigation';
import { page } from '$app/state';
interface Props {
open: boolean;
modelName: string;
availableModels?: string[];
onOpenChange?: (open: boolean) => void;
}
let { open = $bindable(), modelName, availableModels = [], onOpenChange }: Props = $props();
function handleOpenChange(newOpen: boolean) {
open = newOpen;
onOpenChange?.(newOpen);
}
function handleSelectModel(model: string) {
// Build URL with selected model, preserving other params
const url = new URL(page.url);
url.searchParams.set('model', model);
handleOpenChange(false);
goto(url.toString());
}
</script>
<AlertDialog.Root {open} onOpenChange={handleOpenChange}>
<AlertDialog.Content class="max-w-lg">
<AlertDialog.Header>
<AlertDialog.Title class="flex items-center gap-2">
<AlertTriangle class="h-5 w-5 text-amber-500" />
Model Not Available
</AlertDialog.Title>
<AlertDialog.Description>
The requested model could not be found. Select an available model to continue.
</AlertDialog.Description>
</AlertDialog.Header>
<div class="space-y-3">
<div class="rounded-lg border border-amber-500/40 bg-amber-500/10 px-4 py-3 text-sm">
<p class="font-medium text-amber-600 dark:text-amber-400">
Requested: <code class="rounded bg-amber-500/20 px-1.5 py-0.5">{modelName}</code>
</p>
</div>
{#if availableModels.length > 0}
<div class="text-sm">
<p class="mb-2 font-medium text-muted-foreground">Select an available model:</p>
<div class="max-h-48 space-y-1 overflow-y-auto rounded-md border p-1">
{#each availableModels as model (model)}
<button
type="button"
class="group flex w-full items-center justify-between gap-2 rounded-sm px-3 py-2 text-left text-sm transition-colors hover:bg-accent hover:text-accent-foreground"
onclick={() => handleSelectModel(model)}
>
<span class="min-w-0 truncate font-mono text-xs">{model}</span>
<ArrowRight
class="h-4 w-4 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100"
/>
</button>
{/each}
</div>
</div>
{/if}
</div>
<AlertDialog.Footer>
<AlertDialog.Action onclick={() => handleOpenChange(false)}>Cancel</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>

View File

@ -25,7 +25,6 @@ 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';
@ -48,6 +47,7 @@ export { default as DialogConversationSelection } from './dialogs/DialogConversa
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
@ -55,14 +55,15 @@ 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 BadgeModelName } from './misc/BadgeModelName.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 SelectorModel } from './misc/SelectorModel.svelte';
export { default as SyntaxHighlightedCode } from './misc/SyntaxHighlightedCode.svelte';
export { default as ModelsSelector } from './models/ModelsSelector.svelte';
// Server

View File

@ -1,7 +1,6 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import * as Tooltip from '$lib/components/ui/tooltip';
import { TOOLTIP_DELAY_DURATION } from '$lib/constants/tooltip-config';
import type { Component } from 'svelte';
interface Props {
@ -27,7 +26,7 @@
}: Props = $props();
</script>
<Tooltip.Root delayDuration={TOOLTIP_DELAY_DURATION}>
<Tooltip.Root>
<Tooltip.Trigger>
<Button
{variant}

View File

@ -2,7 +2,6 @@
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import * as Tooltip from '$lib/components/ui/tooltip';
import { KeyboardShortcutInfo } from '$lib/components/app';
import { TOOLTIP_DELAY_DURATION } from '$lib/constants/tooltip-config';
import type { Component } from 'svelte';
interface ActionItem {
@ -40,7 +39,7 @@
onclick={(e) => e.stopPropagation()}
>
{#if triggerTooltip}
<Tooltip.Root delayDuration={TOOLTIP_DELAY_DURATION}>
<Tooltip.Root>
<Tooltip.Trigger>
{@render iconComponent(triggerIcon, 'h-3 w-3')}
<span class="sr-only">{triggerTooltip}</span>

View File

@ -1,5 +1,6 @@
<script lang="ts">
import { BadgeInfo } from '$lib/components/app';
import { copyToClipboard } from '$lib/utils';
import type { Component } from 'svelte';
interface Props {
@ -9,9 +10,13 @@
}
let { class: className = '', icon: Icon, value }: Props = $props();
function handleClick() {
void copyToClipboard(String(value));
}
</script>
<BadgeInfo class={className}>
<BadgeInfo class={className} onclick={handleClick}>
{#snippet icon()}
<Icon class="h-3 w-3" />
{/snippet}

View File

@ -1,8 +1,10 @@
<script lang="ts">
import { Eye, Mic } from '@lucide/svelte';
import { ModelModality } from '$lib/enums';
import { MODALITY_ICONS, MODALITY_LABELS } from '$lib/constants/icons';
import { cn } from '$lib/components/ui/utils';
type DisplayableModality = ModelModality.VISION | ModelModality.AUDIO;
interface Props {
modalities: ModelModality[];
class?: string;
@ -10,31 +12,17 @@
let { modalities, class: className = '' }: Props = $props();
function getModalityIcon(modality: ModelModality) {
switch (modality) {
case ModelModality.VISION:
return Eye;
case ModelModality.AUDIO:
return Mic;
default:
return null;
}
}
function getModalityLabel(modality: ModelModality): string {
switch (modality) {
case ModelModality.VISION:
return 'Vision';
case ModelModality.AUDIO:
return 'Audio';
default:
return 'Unknown';
}
}
// Filter to only modalities that have icons (VISION, AUDIO)
const displayableModalities = $derived(
modalities.filter(
(m): m is DisplayableModality => m === ModelModality.VISION || m === ModelModality.AUDIO
)
);
</script>
{#each modalities as modality, index (index)}
{@const IconComponent = getModalityIcon(modality)}
{#each displayableModalities as modality, index (index)}
{@const IconComponent = MODALITY_ICONS[modality]}
{@const label = MODALITY_LABELS[modality]}
<span
class={cn(
@ -46,6 +34,6 @@
<IconComponent class="h-3 w-3" />
{/if}
{getModalityLabel(modality)}
{label}
</span>
{/each}

View File

@ -1,6 +1,6 @@
<script lang="ts">
import { Copy } from '@lucide/svelte';
import { copyToClipboard } from '$lib/utils/copy';
import { copyToClipboard } from '$lib/utils';
interface Props {
ariaLabel?: string;

View File

@ -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';

View File

@ -0,0 +1,96 @@
<script lang="ts">
import hljs from 'highlight.js';
import { browser } from '$app/environment';
import { mode } from 'mode-watcher';
import githubDarkCss from 'highlight.js/styles/github-dark.css?inline';
import githubLightCss from 'highlight.js/styles/github.css?inline';
interface Props {
code: string;
language?: string;
class?: string;
maxHeight?: string;
maxWidth?: string;
}
let {
code,
language = 'text',
class: className = '',
maxHeight = '60vh',
maxWidth = ''
}: Props = $props();
let highlightedHtml = $state('');
function loadHighlightTheme(isDark: boolean) {
if (!browser) return;
const existingThemes = document.querySelectorAll('style[data-highlight-theme-preview]');
existingThemes.forEach((style) => style.remove());
const style = document.createElement('style');
style.setAttribute('data-highlight-theme-preview', 'true');
style.textContent = isDark ? githubDarkCss : githubLightCss;
document.head.appendChild(style);
}
$effect(() => {
const currentMode = mode.current;
const isDark = currentMode === 'dark';
loadHighlightTheme(isDark);
});
$effect(() => {
if (!code) {
highlightedHtml = '';
return;
}
try {
// Check if the language is supported
const lang = language.toLowerCase();
const isSupported = hljs.getLanguage(lang);
if (isSupported) {
const result = hljs.highlight(code, { language: lang });
highlightedHtml = result.value;
} else {
// Try auto-detection or fallback to plain text
const result = hljs.highlightAuto(code);
highlightedHtml = result.value;
}
} catch {
// Fallback to escaped plain text
highlightedHtml = code.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
});
</script>
<div
class="code-preview-wrapper overflow-auto rounded-lg border border-border bg-muted {className}"
style="max-height: {maxHeight};"
>
<pre class="m-0 overflow-x-auto p-4 max-w-[{maxWidth}]"><code class="hljs text-sm leading-relaxed"
>{@html highlightedHtml}</code
></pre>
</div>
<style>
.code-preview-wrapper {
font-family:
ui-monospace, SFMono-Regular, 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas,
'Liberation Mono', Menlo, monospace;
}
.code-preview-wrapper pre {
background: transparent;
}
.code-preview-wrapper code {
background: transparent;
}
</style>

View File

@ -1,9 +1,9 @@
<script lang="ts">
import { Package } from '@lucide/svelte';
import { BadgeInfo, CopyToClipboardIcon } from '$lib/components/app';
import { modelsStore } from '$lib/stores/models.svelte';
import { serverStore } from '$lib/stores/server.svelte';
import * as Tooltip from '$lib/components/ui/tooltip';
import { TOOLTIP_DELAY_DURATION } from '$lib/constants/tooltip-config';
interface Props {
class?: string;
@ -21,7 +21,7 @@
showTooltip = false
}: Props = $props();
let model = $derived(modelProp || serverStore.modelName);
let model = $derived(modelProp || modelsStore.singleModelName);
let isModelMode = $derived(serverStore.isModelMode);
</script>
@ -41,7 +41,7 @@
{#if model && isModelMode}
{#if showTooltip}
<Tooltip.Root delayDuration={TOOLTIP_DELAY_DURATION}>
<Tooltip.Root>
<Tooltip.Trigger>
{@render badgeContent()}
</Tooltip.Trigger>

View File

@ -1,26 +1,44 @@
<script lang="ts">
import { onMount, tick } from 'svelte';
import { ChevronDown, Loader2, Package } from '@lucide/svelte';
import { ChevronDown, EyeOff, Loader2, MicOff, Package, Power } from '@lucide/svelte';
import * as Tooltip from '$lib/components/ui/tooltip';
import { cn } from '$lib/components/ui/utils';
import { portalToBody } from '$lib/utils/portal-to-body';
import { portalToBody } from '$lib/utils';
import {
fetchModels,
modelsStore,
modelOptions,
modelsLoading,
modelsUpdating,
selectModel,
selectedModelId
selectedModelId,
routerModels,
propsCacheVersion,
singleModelName
} from '$lib/stores/models.svelte';
import { isRouterMode, serverStore } from '$lib/stores/server.svelte';
import { usedModalities, conversationsStore } from '$lib/stores/conversations.svelte';
import { ServerModelStatus } from '$lib/enums';
import { isRouterMode } from '$lib/stores/server.svelte';
import { DialogModelInformation } from '$lib/components/app';
import type { ModelOption } from '$lib/types/models';
import {
MENU_MAX_WIDTH,
MENU_OFFSET,
VIEWPORT_GUTTER
} from '$lib/constants/floating-ui-constraints';
interface Props {
class?: string;
currentModel?: string | null;
onModelChange?: (modelId: string, modelName: string) => void;
/** Callback when model changes. Return false to keep menu open (e.g., for validation failures) */
onModelChange?: (modelId: string, modelName: string) => Promise<boolean> | boolean | void;
disabled?: boolean;
forceForegroundText?: boolean;
/** When true, user's global selection takes priority over currentModel (for form selector) */
useGlobalSelection?: boolean;
/**
* When provided, only consider modalities from messages BEFORE this message.
* Used for regeneration - allows selecting models that don't support modalities
* used in later messages.
*/
upToMessageId?: string;
}
let {
@ -28,7 +46,9 @@
currentModel = null,
onModelChange,
disabled = false,
forceForegroundText = false
forceForegroundText = false,
useGlobalSelection = false,
upToMessageId
}: Props = $props();
let options = $derived(modelOptions());
@ -36,7 +56,78 @@
let updating = $derived(modelsUpdating());
let activeId = $derived(selectedModelId());
let isRouter = $derived(isRouterMode());
let serverModel = $derived(serverStore.modelName);
let serverModel = $derived(singleModelName());
// Reactive router models state - needed for proper reactivity of status checks
let currentRouterModels = $derived(routerModels());
let requiredModalities = $derived(
upToMessageId ? conversationsStore.getModalitiesUpToMessage(upToMessageId) : usedModalities()
);
function getModelStatus(modelId: string): ServerModelStatus | null {
const model = currentRouterModels.find((m) => m.id === modelId);
return (model?.status?.value as ServerModelStatus) ?? null;
}
/**
* Checks if a model supports all modalities used in the conversation.
* Returns true if the model can be selected, false if it should be disabled.
*/
function isModelCompatible(option: ModelOption): boolean {
void propsCacheVersion();
const modelModalities = modelsStore.getModelModalities(option.model);
if (!modelModalities) {
const status = getModelStatus(option.model);
if (status === ServerModelStatus.LOADED) {
if (requiredModalities.vision || requiredModalities.audio) return false;
}
return true;
}
if (requiredModalities.vision && !modelModalities.vision) return false;
if (requiredModalities.audio && !modelModalities.audio) return false;
return true;
}
/**
* Gets missing modalities for a model.
* Returns object with vision/audio booleans indicating what's missing.
*/
function getMissingModalities(option: ModelOption): { vision: boolean; audio: boolean } | null {
void propsCacheVersion();
const modelModalities = modelsStore.getModelModalities(option.model);
if (!modelModalities) {
const status = getModelStatus(option.model);
if (status === ServerModelStatus.LOADED) {
const missing = {
vision: requiredModalities.vision,
audio: requiredModalities.audio
};
if (missing.vision || missing.audio) return missing;
}
return null;
}
const missing = {
vision: requiredModalities.vision && !modelModalities.vision,
audio: requiredModalities.audio && !modelModalities.audio
};
if (!missing.vision && !missing.audio) return null;
return missing;
}
let isHighlightedCurrentModelActive = $derived(
!isRouter || !currentModel
@ -67,13 +158,9 @@
maxHeight: number;
} | null>(null);
const VIEWPORT_GUTTER = 8;
const MENU_OFFSET = 6;
const MENU_MAX_WIDTH = 320;
onMount(async () => {
try {
await fetchModels();
await modelsStore.fetch();
} catch (error) {
console.error('Unable to load models:', error);
}
@ -102,6 +189,16 @@
await tick();
updateMenuPosition();
requestAnimationFrame(() => updateMenuPosition());
modelsStore.fetchModalitiesForLoadedModels();
}
export function open() {
if (isRouter) {
openMenu();
} else {
showModelDialog = true;
}
}
function closeMenu() {
@ -225,16 +322,37 @@
};
}
function handleSelect(modelId: string) {
async function handleSelect(modelId: string) {
const option = options.find((opt) => opt.id === modelId);
if (option && onModelChange) {
if (!option) return;
let shouldCloseMenu = true;
if (onModelChange) {
// If callback provided, use it (for regenerate functionality)
onModelChange(option.id, option.model);
} else if (option) {
// Otherwise, just update the global selection (for form selector)
selectModel(option.id).catch(console.error);
const result = await onModelChange(option.id, option.model);
// If callback returns false, keep menu open (validation failed)
if (result === false) {
shouldCloseMenu = false;
}
} else {
// Update global selection
await modelsStore.selectModelById(option.id);
// Load the model if not already loaded (router mode)
if (isRouter && getModelStatus(option.model) !== ServerModelStatus.LOADED) {
try {
await modelsStore.loadModel(option.model);
} catch (error) {
console.error('Failed to load model:', error);
}
}
}
if (shouldCloseMenu) {
closeMenu();
}
closeMenu();
}
function getDisplayOption(): ModelOption | undefined {
@ -251,6 +369,14 @@
return undefined;
}
// When useGlobalSelection is true (form selector), prioritize user selection
// Otherwise (message display), prioritize currentModel
if (useGlobalSelection && activeId) {
const selected = options.find((option) => option.id === activeId);
if (selected) return selected;
}
// Show currentModel (from message payload or conversation)
if (currentModel) {
if (!isCurrentModelInCache()) {
return {
@ -264,11 +390,13 @@
return options.find((option) => option.model === currentModel);
}
// Fallback to user selection (for new chats before first message)
if (activeId) {
return options.find((option) => option.id === activeId);
}
return options[0];
// No selection - return undefined to show "Select model"
return undefined;
}
</script>
@ -357,20 +485,100 @@
<div class="my-1 h-px bg-border"></div>
{/if}
{#each options as option (option.id)}
<button
type="button"
{@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)}
<div
class={cn(
'flex w-full cursor-pointer items-center px-3 py-2 text-left text-sm transition hover:bg-muted focus:bg-muted focus:outline-none',
currentModel === option.model || activeId === option.id
'group flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition focus:outline-none',
isCompatible
? 'cursor-pointer hover:bg-muted focus:bg-muted'
: 'cursor-not-allowed opacity-50',
isSelected
? 'bg-accent text-accent-foreground'
: 'text-popover-foreground hover:bg-accent hover:text-accent-foreground'
: isCompatible
? 'hover:bg-accent hover:text-accent-foreground'
: '',
isLoaded ? 'text-popover-foreground' : 'text-muted-foreground'
)}
role="option"
aria-selected={currentModel === option.model || activeId === option.id}
onclick={() => handleSelect(option.id)}
aria-selected={isSelected}
aria-disabled={!isCompatible}
tabindex={isCompatible ? 0 : -1}
onclick={() => isCompatible && handleSelect(option.id)}
onkeydown={(e) => {
if (isCompatible && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault();
handleSelect(option.id);
}
}}
>
<span class="truncate">{option.model}</span>
</button>
<span class="min-w-0 flex-1 truncate">{option.model}</span>
{#if missingModalities}
<span class="flex shrink-0 items-center gap-1 text-muted-foreground/70">
{#if missingModalities.vision}
<Tooltip.Root>
<Tooltip.Trigger>
<EyeOff class="h-3.5 w-3.5" />
</Tooltip.Trigger>
<Tooltip.Content class="z-[9999]">
<p>No vision support</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
{#if missingModalities.audio}
<Tooltip.Root>
<Tooltip.Trigger>
<MicOff class="h-3.5 w-3.5" />
</Tooltip.Trigger>
<Tooltip.Content class="z-[9999]">
<p>No audio support</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
</span>
{/if}
{#if isLoading}
<Tooltip.Root>
<Tooltip.Trigger>
<Loader2 class="h-4 w-4 shrink-0 animate-spin text-muted-foreground" />
</Tooltip.Trigger>
<Tooltip.Content class="z-[9999]">
<p>Loading model...</p>
</Tooltip.Content>
</Tooltip.Root>
{:else if isLoaded}
<Tooltip.Root>
<Tooltip.Trigger>
<button
type="button"
class="relative ml-2 flex h-4 w-4 shrink-0 items-center justify-center"
onclick={(e) => {
e.stopPropagation();
modelsStore.unloadModel(option.model);
}}
>
<span
class="mr-2 h-2 w-2 rounded-full bg-green-500 transition-opacity group-hover:opacity-0"
></span>
<Power
class="absolute mr-2 h-4 w-4 text-red-500 opacity-0 transition-opacity group-hover:opacity-100 hover:text-red-600"
/>
</button>
</Tooltip.Trigger>
<Tooltip.Content class="z-[9999]">
<p>Unload model</p>
</Tooltip.Content>
</Tooltip.Root>
{:else}
<span class="mx-2 h-2 w-2 rounded-full bg-muted-foreground/50"></span>
{/if}
</div>
{/each}
</div>
</div>

View File

@ -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', {

View File

@ -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() {

View File

@ -0,0 +1,23 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="alert-description"
class={cn(
'col-start-2 grid justify-items-start gap-1 text-sm text-muted-foreground [&_p]:leading-relaxed',
className
)}
{...restProps}
>
{@render children?.()}
</div>

View File

@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="alert-title"
class={cn('col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight', className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@ -0,0 +1,44 @@
<script lang="ts" module>
import { type VariantProps, tv } from 'tailwind-variants';
export const alertVariants = tv({
base: 'relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current',
variants: {
variant: {
default: 'bg-card text-card-foreground',
destructive:
'text-destructive bg-card *:data-[slot=alert-description]:text-destructive/90 [&>svg]:text-current'
}
},
defaultVariants: {
variant: 'default'
}
});
export type AlertVariant = VariantProps<typeof alertVariants>['variant'];
</script>
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
let {
ref = $bindable(null),
class: className,
variant = 'default',
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
variant?: AlertVariant;
} = $props();
</script>
<div
bind:this={ref}
data-slot="alert"
class={cn(alertVariants({ variant }), className)}
{...restProps}
role="alert"
>
{@render children?.()}
</div>

View File

@ -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
};

View File

@ -1,5 +1,4 @@
<script lang="ts">
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
import {
@ -37,17 +36,15 @@
<svelte:window onkeydown={sidebar.handleShortcutKeydown} />
<Tooltip.Provider delayDuration={0}>
<div
data-slot="sidebar-wrapper"
style="--sidebar-width: {SIDEBAR_WIDTH}; --sidebar-width-icon: {SIDEBAR_WIDTH_ICON}; {style}"
class={cn(
'group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar',
className
)}
bind:this={ref}
{...restProps}
>
{@render children?.()}
</div>
</Tooltip.Provider>
<div
data-slot="sidebar-wrapper"
style="--sidebar-width: {SIDEBAR_WIDTH}; --sidebar-width-icon: {SIDEBAR_WIDTH_ICON}; {style}"
class={cn(
'group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar',
className
)}
bind:this={ref}
{...restProps}
>
{@render children?.()}
</div>

View File

@ -0,0 +1 @@
export const DEFAULT_CONTEXT = 4096;

View File

@ -0,0 +1,3 @@
export const VIEWPORT_GUTTER = 8;
export const MENU_OFFSET = 6;
export const MENU_MAX_WIDTH = 320;

View File

@ -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;

View File

@ -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';

View File

@ -4,7 +4,6 @@ export const SETTING_CONFIG_DEFAULT: Record<string, string | number | boolean> =
apiKey: '',
systemMessage: '',
theme: 'system',
showTokensPerSecond: false,
showThoughtInProgress: false,
showToolCalls: false,
disableReasoningFormat: false,
@ -13,10 +12,8 @@ export const SETTING_CONFIG_DEFAULT: Record<string, string | number | boolean> =
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',
@ -82,7 +79,6 @@ export const SETTING_CONFIG_INFO: Record<string, string> = {
'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.',
@ -93,13 +89,11 @@ export const SETTING_CONFIG_INFO: Record<string, string> = {
'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:

View File

@ -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',

View File

@ -18,4 +18,4 @@ export {
export { ModelModality } from './model';
export { ServerMode, ServerModelStatus } from './server';
export { ServerRole, ServerModelStatus } from './server';

View File

@ -1,19 +1,20 @@
/**
* Server mode enum - used for single/multi-model mode
* Server role enum - used for single/multi-model mode
*/
export enum ServerMode {
export enum ServerRole {
/** Single model mode - server running with a specific model loaded */
MODEL = 'MODEL',
MODEL = 'model',
/** Router mode - server managing multiple model instances */
ROUTER = 'ROUTER'
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'
UNLOADED = 'unloaded',
LOADING = 'loading',
LOADED = 'loaded',
FAILED = 'failed'
}

View File

@ -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<void>;
}
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<boolean> {
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
};
}

View File

@ -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<void>;
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<ApiProcessingState | null>(null);
let lastKnownState = $state<ApiProcessingState | null>(null);
let unsubscribe: (() => void) | null = null;
async function startMonitoring(): Promise<void> {
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 {

View File

@ -1,57 +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,
ApiModelListResponse
} from '$lib/types/api';
import { getJsonHeaders } from '$lib/utils';
import { AttachmentType } from '$lib/enums';
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';
/**
* 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<string, AbortController> = new Map();
// ─────────────────────────────────────────────────────────────────────────────
// Messaging
// ─────────────────────────────────────────────────────────────────────────────
/**
* Sends a chat completion request to the llama.cpp server.
@ -63,10 +48,11 @@ export class ChatService {
* @returns {Promise<string | void>} 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<string | void> {
const {
stream,
@ -76,6 +62,7 @@ export class ChatService {
onReasoningChunk,
onToolCallChunk,
onModel,
onTimings,
// Generation parameters
temperature,
max_tokens,
@ -100,20 +87,12 @@ export class ChatService {
// Other parameters
samplers,
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) {
@ -133,7 +112,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 +122,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) {
@ -195,20 +172,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);
}
@ -216,7 +188,7 @@ export class ChatService {
}
if (stream) {
await this.handleStreamResponse(
await ChatService.handleStreamResponse(
response,
onChunk,
onComplete,
@ -224,12 +196,13 @@ export class ChatService {
onReasoningChunk,
onToolCallChunk,
onModel,
onTimings,
conversationId,
abortController.signal
signal
);
return;
} else {
return this.handleNonStreamResponse(
return ChatService.handleNonStreamResponse(
response,
onComplete,
onError,
@ -269,11 +242,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
@ -285,7 +260,7 @@ export class ChatService {
* @returns {Promise<void>} 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?: (
@ -298,6 +273,7 @@ export class ChatService {
onReasoningChunk?: (chunk: string) => void,
onToolCallChunk?: (chunk: string) => void,
onModel?: (model: string) => void,
onTimings?: (timings: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => void,
conversationId?: string,
abortSignal?: AbortSignal
): Promise<void> {
@ -331,7 +307,7 @@ export class ChatService {
return;
}
aggregatedToolCalls = this.mergeToolCallDeltas(
aggregatedToolCalls = ChatService.mergeToolCallDeltas(
aggregatedToolCalls,
toolCalls,
toolCallIndexOffset
@ -386,14 +362,14 @@ export class ChatService {
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;
}
@ -461,7 +437,7 @@ export class ChatService {
* @returns {Promise<string>} Promise that resolves to the generated content string
* @throws {Error} if the response cannot be parsed or is malformed
*/
private async handleNonStreamResponse(
private static async handleNonStreamResponse(
response: Response,
onComplete?: (
response: string,
@ -483,7 +459,7 @@ export class ChatService {
const data: ApiChatCompletionResponse = JSON.parse(responseText);
const responseModel = this.extractModelName(data);
const responseModel = ChatService.extractModelName(data);
if (responseModel) {
onModel?.(responseModel);
}
@ -499,7 +475,7 @@ export class ChatService {
let serializedToolCalls: string | undefined;
if (toolCalls && toolCalls.length > 0) {
const mergedToolCalls = this.mergeToolCallDeltas([], toolCalls);
const mergedToolCalls = ChatService.mergeToolCallDeltas([], toolCalls);
if (mergedToolCalls.length > 0) {
serializedToolCalls = JSON.stringify(mergedToolCalls);
@ -535,7 +511,7 @@ export class ChatService {
* @param indexOffset - Optional offset to apply to the index of new tool calls
* @returns {ApiChatCompletionToolCall[]} The merged array of tool calls
*/
private mergeToolCallDeltas(
private static mergeToolCallDeltas(
existing: ApiChatCompletionToolCall[],
deltas: ApiChatCompletionToolCallDelta[],
indexOffset = 0
@ -583,6 +559,10 @@ export class ChatService {
return result;
}
// ─────────────────────────────────────────────────────────────────────────────
// Conversion
// ─────────────────────────────────────────────────────────────────────────────
/**
* Converts a database message with attachments to API chat message format.
* Processes various attachment types (images, text files, PDFs) and formats them
@ -693,19 +673,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<ApiLlamaCppServerProps> {
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) {
@ -721,18 +699,12 @@ export class ChatService {
}
/**
* Get model information from /models endpoint
* Get model information from /models endpoint (to be refactored)
*/
static async getModels(): Promise<ApiModelListResponse> {
try {
const currentConfig = config();
const apiKey = currentConfig.apiKey?.toString().trim();
const response = await fetch(`./models`, {
headers: {
'Content-Type': 'application/json',
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {})
}
headers: getJsonHeaders()
});
if (!response.ok) {
@ -748,49 +720,30 @@ export class ChatService {
}
/**
* Aborts any ongoing chat completion request.
* Cancels the current request and cleans up the abort controller.
*
* @public
*/
public abort(conversationId?: string): void {
if (conversationId) {
const abortController = this.abortControllers.get(conversationId);
if (abortController) {
abortController.abort();
this.abortControllers.delete(conversationId);
}
} else {
for (const controller of this.abortControllers.values()) {
controller.abort();
}
this.abortControllers.clear();
}
}
/**
* 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;
}
@ -800,7 +753,7 @@ export class ChatService {
const systemMsg: ApiChatMessageData = {
role: 'system',
content: systemMessage
content: trimmedSystemMessage
};
return [systemMsg, ...messages];
@ -811,7 +764,7 @@ export class ChatService {
* @param response - HTTP response object
* @returns Promise<Error> - Parsed error with context info if available
*/
private async parseErrorResponse(response: Response): Promise<Error> {
private static async parseErrorResponse(response: Response): Promise<Error> {
try {
const errorText = await response.text();
const errorData: ApiErrorResponse = JSON.parse(errorText);
@ -828,7 +781,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<string, unknown> | undefined => {
return typeof value === 'object' && value !== null
? (value as Record<string, unknown>)
@ -861,31 +825,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();

View File

@ -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<DatabaseConversation, string>;
@ -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<DatabaseMessage, 'id'>): Promise<DatabaseMessage> {
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<string[]> {
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<DatabaseMessage[]> {
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.

View File

@ -1,3 +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';

View File

@ -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<ApiModelListResponse> {
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<ApiModelListResponse> {
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<ApiModelListResponse>;
}
/**
* Fetch list of all models with detailed metadata (ROUTER mode)
* Returns models with load status, paths, and other metadata
*/
static async listRouter(): Promise<ApiRouterModelsListResponse> {
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<ApiRouterModelsListResponse>;
}
// ─────────────────────────────────────────────────────────────────────────────
// 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<ApiRouterModelsLoadResponse> {
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<ApiRouterModelsLoadResponse>;
}
/**
* Unload a model (ROUTER mode)
* POST /models/unload
* @param modelId - Model identifier to unload
*/
static async unload(modelId: string): Promise<ApiRouterModelsUnloadResponse> {
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<ApiRouterModelsUnloadResponse>;
}
// ─────────────────────────────────────────────────────────────────────────────
// 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;
}
}

View File

@ -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', () => {

View File

@ -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
*/

View File

@ -1,4 +1,4 @@
import { config } from '$lib/stores/settings.svelte';
import { getAuthHeaders } from '$lib/utils';
/**
* PropsService - Server properties management
@ -12,9 +12,13 @@ import { config } from '$lib/stores/settings.svelte';
* - Parse and validate server response
*
* **Used by:**
* - ServerStore: Primary consumer for server state management
* - serverStore: Primary consumer for server state management
*/
export class PropsService {
// ─────────────────────────────────────────────────────────────────────────────
// Fetching
// ─────────────────────────────────────────────────────────────────────────────
/**
* Fetches server properties from the /props endpoint
*
@ -22,13 +26,8 @@ export class PropsService {
* @throws {Error} If the request fails or returns invalid data
*/
static async fetch(): Promise<ApiLlamaCppServerProps> {
const currentConfig = config();
const apiKey = currentConfig.apiKey?.toString().trim();
const response = await fetch('./props', {
headers: {
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {})
}
headers: getAuthHeaders()
});
if (!response.ok) {
@ -40,4 +39,29 @@ export class PropsService {
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
* @returns {Promise<ApiLlamaCppServerProps>} Server properties for the model
* @throws {Error} If the request fails or returns invalid data
*/
static async fetchForModel(modelId: string): Promise<ApiLlamaCppServerProps> {
const url = new URL('./props', window.location.href);
url.searchParams.set('model', modelId);
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;
}
}

View File

@ -1,312 +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<string, ApiProcessingState | null> = 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);
}
}
}
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<void> {
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<number | null> {
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<string, unknown>
): Promise<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 = 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<ApiProcessingState | null> {
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();

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,627 @@
import { browser } from '$app/environment';
import { goto } from '$app/navigation';
import { toast } from 'svelte-sonner';
import { DatabaseService } from '$lib/services/database';
import { config } from '$lib/stores/settings.svelte';
import { filterByLeafNodeId, findLeafNode } from '$lib/utils';
import { AttachmentType } from '$lib/enums';
/**
* conversationsStore - Persistent conversation data and lifecycle management
*
* **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.
* Managed by chatStore, a "chat" is ephemeral and exists during active AI interactions.
* - **Conversation**: The persistent database entity storing all messages and metadata.
* A "conversation" survives across sessions, page reloads, and browser restarts.
* It contains the complete message history, branching structure, and conversation metadata.
*
* This store manages all conversation-level data and operations including creation, loading,
* deletion, and navigation. It maintains the list of conversations and the currently active
* conversation with its message history, providing reactive state for UI components.
*
* **Architecture & Relationships:**
* - **conversationsStore** (this class): Persistent conversation data management
* - Manages conversation list and active conversation state
* - Handles conversation CRUD operations via DatabaseService
* - Maintains active message array for current conversation
* - Coordinates branching navigation (currNode tracking)
*
* - **chatStore**: Uses conversation data as context for active AI streaming
* - **DatabaseService**: Low-level IndexedDB storage for conversations and messages
*
* **Key Features:**
* - **Conversation Lifecycle**: Create, load, update, delete conversations
* - **Message Management**: Active message array with branching support
* - **Import/Export**: JSON-based conversation backup and restore
* - **Branch Navigation**: Navigate between message tree branches
* - **Title Management**: Auto-update titles with confirmation dialogs
* - **Reactive State**: Svelte 5 runes for automatic UI updates
*
* **State Properties:**
* - `conversations`: All conversations sorted by last modified
* - `activeConversation`: Currently viewed conversation
* - `activeMessages`: Messages in current conversation path
* - `isInitialized`: Store initialization status
*/
class ConversationsStore {
// ─────────────────────────────────────────────────────────────────────────────
// State
// ─────────────────────────────────────────────────────────────────────────────
/** List of all conversations */
conversations = $state<DatabaseConversation[]>([]);
/** Currently active conversation */
activeConversation = $state<DatabaseConversation | null>(null);
/** Messages in the active conversation (filtered by currNode path) */
activeMessages = $state<DatabaseMessage[]>([]);
/** Whether the store has been initialized */
isInitialized = $state(false);
/** Callback for title update confirmation dialog */
titleUpdateConfirmationCallback?: (currentTitle: string, newTitle: string) => Promise<boolean>;
// ─────────────────────────────────────────────────────────────────────────────
// Modalities
// ─────────────────────────────────────────────────────────────────────────────
/**
* Modalities used in the active conversation.
* Computed from attachments in activeMessages.
* Used to filter available models - models must support all used modalities.
*/
usedModalities: ModelModalities = $derived.by(() => {
return this.calculateModalitiesFromMessages(this.activeMessages);
});
/**
* Calculate modalities from a list of messages.
* Helper method used by both usedModalities and getModalitiesUpToMessage.
*/
private calculateModalitiesFromMessages(messages: DatabaseMessage[]): ModelModalities {
const modalities: ModelModalities = { vision: false, audio: false };
for (const message of messages) {
if (!message.extra) continue;
for (const extra of message.extra) {
if (extra.type === AttachmentType.IMAGE) {
modalities.vision = true;
}
// PDF only requires vision if processed as images
if (extra.type === AttachmentType.PDF) {
const pdfExtra = extra as DatabaseMessageExtraPdfFile;
if (pdfExtra.processedAsImages) {
modalities.vision = true;
}
}
if (extra.type === AttachmentType.AUDIO) {
modalities.audio = true;
}
}
if (modalities.vision && modalities.audio) break;
}
return modalities;
}
/**
* Get modalities used in messages BEFORE the specified message.
* Used for regeneration - only consider context that was available when generating this message.
*/
getModalitiesUpToMessage(messageId: string): ModelModalities {
const messageIndex = this.activeMessages.findIndex((m) => m.id === messageId);
if (messageIndex === -1) {
return this.usedModalities;
}
const messagesBefore = this.activeMessages.slice(0, messageIndex);
return this.calculateModalitiesFromMessages(messagesBefore);
}
constructor() {
if (browser) {
this.initialize();
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Lifecycle
// ─────────────────────────────────────────────────────────────────────────────
/**
* Initializes the conversations store by loading conversations from the database
*/
async initialize(): Promise<void> {
try {
await this.loadConversations();
this.isInitialized = true;
} catch (error) {
console.error('Failed to initialize conversations store:', error);
}
}
/**
* Loads all conversations from the database
*/
async loadConversations(): Promise<void> {
this.conversations = await DatabaseService.getAllConversations();
}
// ─────────────────────────────────────────────────────────────────────────────
// Conversation CRUD
// ─────────────────────────────────────────────────────────────────────────────
/**
* Creates a new conversation and navigates to it
* @param name - Optional name for the conversation
* @returns The ID of the created conversation
*/
async createConversation(name?: string): Promise<string> {
const conversationName = name || `Chat ${new Date().toLocaleString()}`;
const conversation = await DatabaseService.createConversation(conversationName);
this.conversations.unshift(conversation);
this.activeConversation = conversation;
this.activeMessages = [];
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
*/
async loadConversation(convId: string): Promise<boolean> {
try {
const conversation = await DatabaseService.getConversation(convId);
if (!conversation) {
return false;
}
this.activeConversation = conversation;
if (conversation.currNode) {
const allMessages = await DatabaseService.getConversationMessages(convId);
this.activeMessages = filterByLeafNodeId(
allMessages,
conversation.currNode,
false
) as DatabaseMessage[];
} else {
this.activeMessages = await DatabaseService.getConversationMessages(convId);
}
return true;
} catch (error) {
console.error('Failed to load conversation:', error);
return false;
}
}
/**
* Clears the active conversation and messages
* Used when navigating away from chat or starting fresh
*/
clearActiveConversation(): void {
this.activeConversation = null;
this.activeMessages = [];
// Active processing conversation is now managed by chatStore
}
// ─────────────────────────────────────────────────────────────────────────────
// Message Management
// ─────────────────────────────────────────────────────────────────────────────
/**
* Refreshes active messages based on currNode after branch navigation
*/
async refreshActiveMessages(): Promise<void> {
if (!this.activeConversation) return;
const allMessages = await DatabaseService.getConversationMessages(this.activeConversation.id);
if (allMessages.length === 0) {
this.activeMessages = [];
return;
}
const leafNodeId =
this.activeConversation.currNode ||
allMessages.reduce((latest, msg) => (msg.timestamp > latest.timestamp ? msg : latest)).id;
const currentPath = filterByLeafNodeId(allMessages, leafNodeId, false) as DatabaseMessage[];
this.activeMessages.length = 0;
this.activeMessages.push(...currentPath);
}
/**
* 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<void> {
try {
await DatabaseService.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);
}
}
/**
* 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 True if title was updated, false if cancelled
*/
async updateConversationTitleWithConfirmation(
convId: string,
newTitle: string,
onConfirmationNeeded?: (currentTitle: string, newTitle: string) => Promise<boolean>
): Promise<boolean> {
try {
const currentConfig = config();
if (currentConfig.askForTitleConfirmation && onConfirmationNeeded) {
const conversation = await DatabaseService.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;
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Navigation
// ─────────────────────────────────────────────────────────────────────────────
/**
* Updates the current node of the active conversation
* @param nodeId - The new current node ID
*/
async updateCurrentNode(nodeId: string): Promise<void> {
if (!this.activeConversation) return;
await DatabaseService.updateCurrentNode(this.activeConversation.id, nodeId);
this.activeConversation.currNode = nodeId;
}
/**
* Updates conversation lastModified timestamp and moves it to top of list
*/
updateConversationTimestamp(): void {
if (!this.activeConversation) return;
const chatIndex = this.conversations.findIndex((c) => c.id === this.activeConversation!.id);
if (chatIndex !== -1) {
this.conversations[chatIndex].lastModified = Date.now();
const updatedConv = this.conversations.splice(chatIndex, 1)[0];
this.conversations.unshift(updatedConv);
}
}
/**
* Navigates to a specific sibling branch by updating currNode and refreshing messages
* @param siblingId - The sibling message ID to navigate to
*/
async navigateToSibling(siblingId: string): Promise<void> {
if (!this.activeConversation) return;
const allMessages = await DatabaseService.getConversationMessages(this.activeConversation.id);
const rootMessage = allMessages.find((m) => m.type === 'root' && m.parent === null);
const currentFirstUserMessage = this.activeMessages.find(
(m) => m.role === 'user' && m.parent === rootMessage?.id
);
const currentLeafNodeId = findLeafNode(allMessages, siblingId);
await DatabaseService.updateCurrentNode(this.activeConversation.id, currentLeafNodeId);
this.activeConversation.currNode = currentLeafNodeId;
await this.refreshActiveMessages();
// Only show title dialog if we're navigating between different first user message siblings
if (rootMessage && this.activeMessages.length > 0) {
const newFirstUserMessage = this.activeMessages.find(
(m) => m.role === 'user' && m.parent === rootMessage.id
);
if (
newFirstUserMessage &&
newFirstUserMessage.content.trim() &&
(!currentFirstUserMessage ||
newFirstUserMessage.id !== currentFirstUserMessage.id ||
newFirstUserMessage.content.trim() !== currentFirstUserMessage.content.trim())
) {
await this.updateConversationTitleWithConfirmation(
this.activeConversation.id,
newFirstUserMessage.content.trim(),
this.titleUpdateConfirmationCallback
);
}
}
}
/**
* Deletes a conversation and all its messages
* @param convId - The conversation ID to delete
*/
async deleteConversation(convId: string): Promise<void> {
try {
await DatabaseService.deleteConversation(convId);
this.conversations = this.conversations.filter((c) => c.id !== convId);
if (this.activeConversation?.id === convId) {
this.activeConversation = null;
this.activeMessages = [];
await goto(`?new_chat=true#/`);
}
} catch (error) {
console.error('Failed to delete conversation:', error);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Import/Export
// ─────────────────────────────────────────────────────────────────────────────
/**
* Downloads a conversation as JSON file
* @param convId - The conversation ID to download
*/
async downloadConversation(convId: string): Promise<void> {
let conversation: DatabaseConversation | null;
let messages: DatabaseMessage[];
if (this.activeConversation?.id === convId) {
conversation = this.activeConversation;
messages = this.activeMessages;
} else {
conversation = await DatabaseService.getConversation(convId);
if (!conversation) return;
messages = await DatabaseService.getConversationMessages(convId);
}
this.triggerDownload({ conv: conversation, messages });
}
/**
* Exports all conversations with their messages as a JSON file
* @returns The list of exported conversations
*/
async exportAllConversations(): Promise<DatabaseConversation[]> {
const allConversations = await DatabaseService.getAllConversations();
if (allConversations.length === 0) {
throw new Error('No conversations to export');
}
const allData = await Promise.all(
allConversations.map(async (conv) => {
const messages = await DatabaseService.getConversationMessages(conv.id);
return { conv, messages };
})
);
const blob = new Blob([JSON.stringify(allData, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `all_conversations_${new Date().toISOString().split('T')[0]}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast.success(`All conversations (${allConversations.length}) prepared for download`);
return allConversations;
}
/**
* Imports conversations from a JSON file
* Opens file picker and processes the selected file
* @returns The list of imported conversations
*/
async importConversations(): Promise<DatabaseConversation[]> {
return new Promise((resolve, reject) => {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json';
input.onchange = async (e) => {
const file = (e.target as HTMLInputElement)?.files?.[0];
if (!file) {
reject(new Error('No file selected'));
return;
}
try {
const text = await file.text();
const parsedData = JSON.parse(text);
let importedData: ExportedConversations;
if (Array.isArray(parsedData)) {
importedData = parsedData;
} else if (
parsedData &&
typeof parsedData === 'object' &&
'conv' in parsedData &&
'messages' in parsedData
) {
importedData = [parsedData];
} else {
throw new Error('Invalid file format');
}
const result = await DatabaseService.importConversations(importedData);
toast.success(`Imported ${result.imported} conversation(s), skipped ${result.skipped}`);
await this.loadConversations();
const importedConversations = (
Array.isArray(importedData) ? importedData : [importedData]
).map((item) => item.conv);
resolve(importedConversations);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Unknown error';
console.error('Failed to import conversations:', err);
toast.error('Import failed', { description: message });
reject(new Error(`Import failed: ${message}`));
}
};
input.click();
});
}
/**
* Gets all messages for a specific conversation
* @param convId - The conversation ID
* @returns Array of messages
*/
async getConversationMessages(convId: string): Promise<DatabaseMessage[]> {
return await DatabaseService.getConversationMessages(convId);
}
/**
* Adds a message to the active messages array
* Used by chatStore when creating new messages
* @param message - The message to add
*/
addMessageToActive(message: DatabaseMessage): void {
this.activeMessages.push(message);
}
/**
* Updates a message at a specific index in active messages
* Creates a new object to trigger Svelte 5 reactivity
* @param index - The index of the message to update
* @param updates - Partial message data to update
*/
updateMessageAtIndex(index: number, updates: Partial<DatabaseMessage>): void {
if (index !== -1 && this.activeMessages[index]) {
// Create new object to trigger Svelte 5 reactivity
this.activeMessages[index] = { ...this.activeMessages[index], ...updates };
}
}
/**
* Finds the index of a message in active messages
* @param messageId - The message ID to find
* @returns The index of the message, or -1 if not found
*/
findMessageIndex(messageId: string): number {
return this.activeMessages.findIndex((m) => m.id === messageId);
}
/**
* Removes messages from active messages starting at an index
* @param startIndex - The index to start removing from
*/
sliceActiveMessages(startIndex: number): void {
this.activeMessages = this.activeMessages.slice(0, startIndex);
}
/**
* Removes a message from active messages by index
* @param index - The index to remove
* @returns The removed message or undefined
*/
removeMessageAtIndex(index: number): DatabaseMessage | undefined {
if (index !== -1) {
return this.activeMessages.splice(index, 1)[0];
}
return undefined;
}
/**
* Triggers file download in browser
* @param data - The data to download
* @param filename - Optional filename for the download
*/
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?.trim() || '';
const truncatedSuffix = conversationName
.toLowerCase()
.replace(/[^a-z0-9]/gi, '_')
.replace(/_+/g, '_')
.substring(0, 20);
const downloadFilename = filename || `conversation_${conversation.id}_${truncatedSuffix}.json`;
const blob = new Blob([JSON.stringify(data, null, 2)], { 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);
}
// ─────────────────────────────────────────────────────────────────────────────
// Utilities
// ─────────────────────────────────────────────────────────────────────────────
/**
* Sets the callback function for title update confirmations
* @param callback - Function to call when confirmation is needed
*/
setTitleUpdateConfirmationCallback(
callback: (currentTitle: string, newTitle: string) => Promise<boolean>
): void {
this.titleUpdateConfirmationCallback = callback;
}
}
export const conversationsStore = new ConversationsStore();
export const conversations = () => conversationsStore.conversations;
export const activeConversation = () => conversationsStore.activeConversation;
export const activeMessages = () => conversationsStore.activeMessages;
export const isConversationsInitialized = () => conversationsStore.isInitialized;
export const usedModalities = () => conversationsStore.usedModalities;

View File

@ -1,76 +1,221 @@
import { SvelteSet } from 'svelte/reactivity';
import { ModelsService } from '$lib/services/models';
import { persisted } from '$lib/stores/persisted.svelte';
import { SELECTED_MODEL_LOCALSTORAGE_KEY } from '$lib/constants/localstorage-keys';
import type { ModelOption } from '$lib/types/models';
type PersistedModelSelection = {
id: string;
model: string;
};
import { PropsService } from '$lib/services/props';
import { ServerModelStatus, ModelModality } from '$lib/enums';
import { serverStore } from '$lib/stores/server.svelte';
/**
* modelsStore - Reactive store for model management in both MODEL and ROUTER modes
*
* This store manages:
* - Available models list
* - Selected model for new conversations
* - Loaded models tracking (ROUTER mode)
* - Model usage tracking per conversation
* - Automatic unloading of unused models
*
* **Architecture & Relationships:**
* - **ModelsService**: Stateless service for model API communication
* - **PropsService**: Stateless service for props/modalities fetching
* - **modelsStore** (this class): Reactive store for model state
* - **conversationsStore**: Tracks which conversations use which models
*
* **API Inconsistency Workaround:**
* In MODEL mode, `/props` returns modalities for the single model.
* In ROUTER mode, `/props` has no modalities - must use `/props?model=<id>` per model.
* This store normalizes this behavior so consumers don't need to know the server mode.
*
* **Key Features:**
* - **MODEL mode**: Single model, always loaded
* - **ROUTER mode**: Multi-model with load/unload capability
* - **Auto-unload**: Automatically unloads models not used by any conversation
* - **Lazy loading**: ensureModelLoaded() loads models on demand
*/
class ModelsStore {
private _models = $state<ModelOption[]>([]);
private _loading = $state(false);
private _updating = $state(false);
private _error = $state<string | null>(null);
private _selectedModelId = $state<string | null>(null);
private _selectedModelName = $state<string | null>(null);
private _persistedSelection = persisted<PersistedModelSelection | null>(
SELECTED_MODEL_LOCALSTORAGE_KEY,
null
);
// ─────────────────────────────────────────────────────────────────────────────
// State
// ─────────────────────────────────────────────────────────────────────────────
constructor() {
const persisted = this._persistedSelection.value;
if (persisted) {
this._selectedModelId = persisted.id;
this._selectedModelName = persisted.model;
}
}
models = $state<ModelOption[]>([]);
routerModels = $state<ApiModelDataEntry[]>([]);
loading = $state(false);
updating = $state(false);
error = $state<string | null>(null);
selectedModelId = $state<string | null>(null);
selectedModelName = $state<string | null>(null);
get models(): ModelOption[] {
return this._models;
}
private modelUsage = $state<Map<string, SvelteSet<string>>>(new Map());
private modelLoadingStates = $state<Map<string, boolean>>(new Map());
get loading(): boolean {
return this._loading;
}
/**
* Model-specific props cache
* Key: modelId, Value: props data including modalities
*/
private modelPropsCache = $state<Map<string, ApiLlamaCppServerProps>>(new Map());
private modelPropsFetching = $state<Set<string>>(new Set());
get updating(): boolean {
return this._updating;
}
/**
* Version counter for props cache - used to trigger reactivity when props are updated
*/
propsCacheVersion = $state(0);
get error(): string | null {
return this._error;
}
get selectedModelId(): string | null {
return this._selectedModelId;
}
get selectedModelName(): string | null {
return this._selectedModelName;
}
// ─────────────────────────────────────────────────────────────────────────────
// Computed Getters
// ─────────────────────────────────────────────────────────────────────────────
get selectedModel(): ModelOption | null {
if (!this._selectedModelId) {
return null;
}
return this._models.find((model) => model.id === this._selectedModelId) ?? null;
if (!this.selectedModelId) return null;
return this.models.find((model) => model.id === this.selectedModelId) ?? null;
}
async fetch(force = false): Promise<void> {
if (this._loading) return;
if (this._models.length > 0 && !force) return;
get loadedModelIds(): string[] {
return this.routerModels
.filter((m) => m.status.value === ServerModelStatus.LOADED)
.map((m) => m.id);
}
this._loading = true;
this._error = null;
get loadingModelIds(): string[] {
return Array.from(this.modelLoadingStates.entries())
.filter(([, loading]) => loading)
.map(([id]) => id);
}
/**
* Get model name in MODEL mode (single model).
* Extracts from model_path or model_alias from server props.
* In ROUTER mode, returns null (model is per-conversation).
*/
get singleModelName(): string | null {
if (serverStore.isRouterMode) return null;
const props = serverStore.props;
if (props?.model_alias) return props.model_alias;
if (!props?.model_path) return null;
return props.model_path.split(/(\\|\/)/).pop() || null;
}
// ─────────────────────────────────────────────────────────────────────────────
// Modalities
// ─────────────────────────────────────────────────────────────────────────────
/**
* Get modalities for a specific model
* Returns cached modalities from model props
*/
getModelModalities(modelId: string): ModelModalities | null {
// First check if modalities are stored in the model option
const model = this.models.find((m) => m.model === modelId || m.id === modelId);
if (model?.modalities) {
return model.modalities;
}
// Fall back to props cache
const props = this.modelPropsCache.get(modelId);
if (props?.modalities) {
return {
vision: props.modalities.vision ?? false,
audio: props.modalities.audio ?? false
};
}
return null;
}
/**
* Check if a model supports vision modality
*/
modelSupportsVision(modelId: string): boolean {
return this.getModelModalities(modelId)?.vision ?? false;
}
/**
* Check if a model supports audio modality
*/
modelSupportsAudio(modelId: string): boolean {
return this.getModelModalities(modelId)?.audio ?? false;
}
/**
* Get model modalities as an array of ModelModality enum values
*/
getModelModalitiesArray(modelId: string): ModelModality[] {
const modalities = this.getModelModalities(modelId);
if (!modalities) return [];
const result: ModelModality[] = [];
if (modalities.vision) result.push(ModelModality.VISION);
if (modalities.audio) result.push(ModelModality.AUDIO);
return result;
}
/**
* Get props for a specific model (from cache)
*/
getModelProps(modelId: string): ApiLlamaCppServerProps | null {
return this.modelPropsCache.get(modelId) ?? null;
}
/**
* Check if props are being fetched for a model
*/
isModelPropsFetching(modelId: string): boolean {
return this.modelPropsFetching.has(modelId);
}
// ─────────────────────────────────────────────────────────────────────────────
// Status Queries
// ─────────────────────────────────────────────────────────────────────────────
isModelLoaded(modelId: string): boolean {
const model = this.routerModels.find((m) => m.id === modelId);
return model?.status.value === ServerModelStatus.LOADED || false;
}
isModelOperationInProgress(modelId: string): boolean {
return this.modelLoadingStates.get(modelId) ?? false;
}
getModelStatus(modelId: string): ServerModelStatus | null {
const model = this.routerModels.find((m) => m.id === modelId);
return model?.status.value ?? null;
}
getModelUsage(modelId: string): SvelteSet<string> {
return this.modelUsage.get(modelId) ?? new SvelteSet<string>();
}
isModelInUse(modelId: string): boolean {
const usage = this.modelUsage.get(modelId);
return usage !== undefined && usage.size > 0;
}
// ─────────────────────────────────────────────────────────────────────────────
// Data Fetching
// ─────────────────────────────────────────────────────────────────────────────
/**
* Fetch list of models from server and detect server role
* Also fetches modalities for MODEL mode (single model)
*/
async fetch(force = false): Promise<void> {
if (this.loading) return;
if (this.models.length > 0 && !force) return;
this.loading = true;
this.error = null;
try {
// Ensure server props are loaded (for role detection and MODEL mode modalities)
if (!serverStore.props) {
await serverStore.fetch();
}
const response = await ModelsService.list();
const models: ModelOption[] = response.data.map((item, index) => {
const models: ModelOption[] = response.data.map((item: ApiModelDataEntry, index: number) => {
const details = response.models?.[index];
const rawCapabilities = Array.isArray(details?.capabilities) ? details?.capabilities : [];
const displayNameSource =
@ -82,56 +227,367 @@ class ModelsStore {
name: displayName,
model: details?.model || item.id,
description: details?.description,
capabilities: rawCapabilities.filter((value): value is string => Boolean(value)),
capabilities: rawCapabilities.filter((value: unknown): value is string => Boolean(value)),
details: details?.details,
meta: item.meta ?? null
} satisfies ModelOption;
});
this._models = models;
this.models = models;
const selection = this.determineInitialSelection(models);
this._selectedModelId = selection.id;
this._selectedModelName = selection.model;
this._persistedSelection.value =
selection.id && selection.model ? { id: selection.id, model: selection.model } : null;
// In MODEL mode, populate modalities from serverStore.props (single model)
// WORKAROUND: In MODEL mode, /props returns modalities for the single model,
// but /v1/models doesn't include modalities. We bridge this gap here.
const serverProps = serverStore.props;
if (serverStore.isModelMode && this.models.length > 0 && serverProps?.modalities) {
const modalities: ModelModalities = {
vision: serverProps.modalities.vision ?? false,
audio: serverProps.modalities.audio ?? false
};
// Cache props for the single model
this.modelPropsCache.set(this.models[0].model, serverProps);
// Update model with modalities
this.models = this.models.map((model, index) =>
index === 0 ? { ...model, modalities } : model
);
}
} catch (error) {
this._models = [];
this._error = error instanceof Error ? error.message : 'Failed to load models';
this.models = [];
this.error = error instanceof Error ? error.message : 'Failed to load models';
throw error;
} finally {
this._loading = false;
this.loading = false;
}
}
async select(modelId: string): Promise<void> {
if (!modelId || this._updating) {
return;
/**
* Fetch router models with full metadata (ROUTER mode only)
* This fetches the /models endpoint which returns status info for each model
*/
async fetchRouterModels(): Promise<void> {
try {
const response = await ModelsService.listRouter();
this.routerModels = response.data;
await this.fetchModalitiesForLoadedModels();
} catch (error) {
console.warn('Failed to fetch router models:', error);
this.routerModels = [];
}
}
if (this._selectedModelId === modelId) {
return;
}
/**
* Fetch props for a specific model from /props endpoint
* Uses caching to avoid redundant requests
*
* @param modelId - Model identifier to fetch props for
* @returns Props data or null if fetch failed
*/
async fetchModelProps(modelId: string): Promise<ApiLlamaCppServerProps | null> {
// Return cached props if available
const cached = this.modelPropsCache.get(modelId);
if (cached) return cached;
const option = this._models.find((model) => model.id === modelId);
if (!option) {
throw new Error('Selected model is not available');
}
// Avoid duplicate fetches
if (this.modelPropsFetching.has(modelId)) return null;
this._updating = true;
this._error = null;
this.modelPropsFetching.add(modelId);
try {
this._selectedModelId = option.id;
this._selectedModelName = option.model;
this._persistedSelection.value = { id: option.id, model: option.model };
const props = await PropsService.fetchForModel(modelId);
this.modelPropsCache.set(modelId, props);
return props;
} catch (error) {
console.warn(`Failed to fetch props for model ${modelId}:`, error);
return null;
} finally {
this._updating = false;
this.modelPropsFetching.delete(modelId);
}
}
/**
* Fetch modalities for all loaded models from /props endpoint
* This updates the modalities field in models array
*/
async fetchModalitiesForLoadedModels(): Promise<void> {
const loadedModelIds = this.loadedModelIds;
if (loadedModelIds.length === 0) return;
// Fetch props for each loaded model in parallel
const propsPromises = loadedModelIds.map((modelId) => this.fetchModelProps(modelId));
try {
const results = await Promise.all(propsPromises);
// Update models with modalities
this.models = this.models.map((model) => {
const modelIndex = loadedModelIds.indexOf(model.model);
if (modelIndex === -1) return model;
const props = results[modelIndex];
if (!props?.modalities) return model;
const modalities: ModelModalities = {
vision: props.modalities.vision ?? false,
audio: props.modalities.audio ?? false
};
return { ...model, modalities };
});
// Increment version to trigger reactivity
this.propsCacheVersion++;
} catch (error) {
console.warn('Failed to fetch modalities for loaded models:', error);
}
}
/**
* Update modalities for a specific model
* Called when a model is loaded or when we need fresh modality data
*/
async updateModelModalities(modelId: string): Promise<void> {
try {
const props = await this.fetchModelProps(modelId);
if (!props?.modalities) return;
const modalities: ModelModalities = {
vision: props.modalities.vision ?? false,
audio: props.modalities.audio ?? false
};
this.models = this.models.map((model) =>
model.model === modelId ? { ...model, modalities } : model
);
// Increment version to trigger reactivity
this.propsCacheVersion++;
} catch (error) {
console.warn(`Failed to update modalities for model ${modelId}:`, error);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Model Selection
// ─────────────────────────────────────────────────────────────────────────────
/**
* Select a model for new conversations
*/
async selectModelById(modelId: string): Promise<void> {
if (!modelId || this.updating) return;
if (this.selectedModelId === modelId) return;
const option = this.models.find((model) => model.id === modelId);
if (!option) throw new Error('Selected model is not available');
this.updating = true;
this.error = null;
try {
this.selectedModelId = option.id;
this.selectedModelName = option.model;
} finally {
this.updating = false;
}
}
/**
* Select a model by its model name (used for syncing with conversation model)
* @param modelName - Model name to select (e.g., "unsloth/gemma-3-12b-it-GGUF:latest")
*/
selectModelByName(modelName: string): void {
const option = this.models.find((model) => model.model === modelName);
if (option) {
this.selectedModelId = option.id;
this.selectedModelName = option.model;
}
}
clearSelection(): void {
this.selectedModelId = null;
this.selectedModelName = null;
}
findModelByName(modelName: string): ModelOption | null {
return this.models.find((model) => model.model === modelName) ?? null;
}
findModelById(modelId: string): ModelOption | null {
return this.models.find((model) => model.id === modelId) ?? null;
}
hasModel(modelName: string): boolean {
return this.models.some((model) => model.model === modelName);
}
// ─────────────────────────────────────────────────────────────────────────────
// Loading/Unloading Models
// ─────────────────────────────────────────────────────────────────────────────
/**
* WORKAROUND: Polling for model status after load/unload operations.
*
* Currently, the `/models/load` and `/models/unload` endpoints return success
* before the operation actually completes on the server. This means an immediate
* request to `/models` returns stale status (e.g., "loading" after load request,
* "loaded" after unload request).
*
* TODO: Remove this polling once llama-server properly waits for the operation
* to complete before returning success from `/load` and `/unload` endpoints.
* At that point, a single `fetchRouterModels()` call after the operation will
* be sufficient to get the correct status.
*/
/** Polling interval in ms for checking model status */
private static readonly STATUS_POLL_INTERVAL = 500;
/** Maximum polling attempts before giving up */
private static readonly STATUS_POLL_MAX_ATTEMPTS = 60; // 30 seconds max
/**
* Poll for expected model status after load/unload operation.
* Keeps polling until the model reaches the expected status or max attempts reached.
*
* @param modelId - Model identifier to check
* @param expectedStatus - Expected status to wait for
* @returns Promise that resolves when expected status is reached
*/
private async pollForModelStatus(
modelId: string,
expectedStatus: ServerModelStatus
): Promise<void> {
for (let attempt = 0; attempt < ModelsStore.STATUS_POLL_MAX_ATTEMPTS; attempt++) {
await this.fetchRouterModels();
const currentStatus = this.getModelStatus(modelId);
if (currentStatus === expectedStatus) {
return;
}
// Wait before next poll
await new Promise((resolve) => setTimeout(resolve, ModelsStore.STATUS_POLL_INTERVAL));
}
console.warn(
`Model ${modelId} did not reach expected status ${expectedStatus} after ${ModelsStore.STATUS_POLL_MAX_ATTEMPTS} attempts`
);
}
/**
* Load a model (ROUTER mode)
* @param modelId - Model identifier to load
*/
async loadModel(modelId: string): Promise<void> {
if (this.isModelLoaded(modelId)) {
return;
}
if (this.modelLoadingStates.get(modelId)) return;
this.modelLoadingStates.set(modelId, true);
this.error = null;
try {
await ModelsService.load(modelId);
// Poll until model is loaded
await this.pollForModelStatus(modelId, ServerModelStatus.LOADED);
await this.updateModelModalities(modelId);
} catch (error) {
this.error = error instanceof Error ? error.message : 'Failed to load model';
throw error;
} finally {
this.modelLoadingStates.set(modelId, false);
}
}
/**
* Unload a model (ROUTER mode)
* @param modelId - Model identifier to unload
*/
async unloadModel(modelId: string): Promise<void> {
if (!this.isModelLoaded(modelId)) {
return;
}
if (this.modelLoadingStates.get(modelId)) return;
this.modelLoadingStates.set(modelId, true);
this.error = null;
try {
await ModelsService.unload(modelId);
await this.pollForModelStatus(modelId, ServerModelStatus.UNLOADED);
} catch (error) {
this.error = error instanceof Error ? error.message : 'Failed to unload model';
throw error;
} finally {
this.modelLoadingStates.set(modelId, false);
}
}
/**
* Ensure a model is loaded before use
* @param modelId - Model identifier to ensure is loaded
*/
async ensureModelLoaded(modelId: string): Promise<void> {
if (this.isModelLoaded(modelId)) {
return;
}
await this.loadModel(modelId);
}
// ─────────────────────────────────────────────────────────────────────────────
// Usage Tracking
// ─────────────────────────────────────────────────────────────────────────────
/**
* Register that a conversation is using a model
*/
registerModelUsage(modelId: string, conversationId: string): void {
const usage = this.modelUsage.get(modelId) ?? new SvelteSet<string>();
usage.add(conversationId);
this.modelUsage.set(modelId, usage);
}
/**
* Unregister that a conversation is using a model
* @param modelId - Model identifier
* @param conversationId - Conversation identifier
* @param autoUnload - Whether to automatically unload the model if no longer used
*/
async unregisterModelUsage(
modelId: string,
conversationId: string,
autoUnload = true
): Promise<void> {
const usage = this.modelUsage.get(modelId);
if (usage) {
usage.delete(conversationId);
if (usage.size === 0) {
this.modelUsage.delete(modelId);
if (autoUnload && this.isModelLoaded(modelId)) {
await this.unloadModel(modelId);
}
}
}
}
/**
* Clear all usage for a conversation (when conversation is deleted)
*/
async clearConversationUsage(conversationId: string): Promise<void> {
for (const [modelId, usage] of this.modelUsage.entries()) {
if (usage.has(conversationId)) await this.unregisterModelUsage(modelId, conversationId);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Utilities
// ─────────────────────────────────────────────────────────────────────────────
private toDisplayName(id: string): string {
const segments = id.split(/\\|\//);
const candidate = segments.pop();
@ -139,49 +595,32 @@ class ModelsStore {
return candidate && candidate.trim().length > 0 ? candidate : id;
}
/**
* Determines which model should be selected after fetching the models list.
* Priority: current selection > persisted selection > first available model > none
*/
private determineInitialSelection(models: ModelOption[]): {
id: string | null;
model: string | null;
} {
const persisted = this._persistedSelection.value;
let nextSelectionId = this._selectedModelId ?? persisted?.id ?? null;
let nextSelectionName = this._selectedModelName ?? persisted?.model ?? null;
if (nextSelectionId) {
const match = models.find((m) => m.id === nextSelectionId);
if (match) {
nextSelectionId = match.id;
nextSelectionName = match.model;
} else if (models[0]) {
nextSelectionId = models[0].id;
nextSelectionName = models[0].model;
} else {
nextSelectionId = null;
nextSelectionName = null;
}
} else if (models[0]) {
nextSelectionId = models[0].id;
nextSelectionName = models[0].model;
}
return { id: nextSelectionId, model: nextSelectionName };
clear(): void {
this.models = [];
this.routerModels = [];
this.loading = false;
this.updating = false;
this.error = null;
this.selectedModelId = null;
this.selectedModelName = null;
this.modelUsage.clear();
this.modelLoadingStates.clear();
this.modelPropsCache.clear();
this.modelPropsFetching.clear();
}
}
export const modelsStore = new ModelsStore();
export const modelOptions = () => modelsStore.models;
export const routerModels = () => modelsStore.routerModels;
export const modelsLoading = () => modelsStore.loading;
export const modelsUpdating = () => modelsStore.updating;
export const modelsError = () => modelsStore.error;
export const selectedModelId = () => modelsStore.selectedModelId;
export const selectedModelName = () => modelsStore.selectedModelName;
export const selectedModelOption = () => modelsStore.selectedModel;
export const fetchModels = modelsStore.fetch.bind(modelsStore);
export const selectModel = modelsStore.select.bind(modelsStore);
export const loadedModelIds = () => modelsStore.loadedModelIds;
export const loadingModelIds = () => modelsStore.loadingModelIds;
export const propsCacheVersion = () => modelsStore.propsCacheVersion;
export const singleModelName = () => modelsStore.singleModelName;

View File

@ -1,405 +1,141 @@
import { browser } from '$app/environment';
import { SERVER_PROPS_LOCALSTORAGE_KEY } from '$lib/constants/localstorage-keys';
import { PropsService } from '$lib/services/props';
import { config } from '$lib/stores/settings.svelte';
import { ServerMode, ModelModality } from '$lib/enums';
import { updateConfig } from '$lib/stores/settings.svelte';
import { ServerRole } from '$lib/enums';
/**
* ServerStore - Server state management and capability detection
* serverStore - Server connection state, configuration, and role detection
*
* This store manages communication with the llama.cpp server to retrieve and maintain
* server properties, model information, and capability detection. It provides reactive
* state for server connectivity, model capabilities, and endpoint availability.
* This store manages the server connection state and properties fetched from `/props`.
* It provides reactive state for server configuration and role detection.
*
* **Architecture & Relationships:**
* - **ServerStore** (this class): Server state and capability management
* - Fetches and caches server properties from `/props` endpoint
* - Detects model capabilities (vision, audio support)
* - Tests endpoint availability (slots endpoint)
* - Provides reactive server state for UI components
*
* - **ChatService**: Uses server properties for request validation
* - **SlotsService**: Depends on slots endpoint availability detection
* - **UI Components**: Subscribe to server state for capability-based rendering
* - **PropsService**: Stateless service for fetching `/props` data
* - **serverStore** (this class): Reactive store for server state
* - **modelsStore**: Independent store for model management (uses PropsService directly)
*
* **Key Features:**
* - **Server Properties**: Model path, context size, build information
* - **Capability Detection**: Vision and audio modality support
* - **Endpoint Testing**: Slots endpoint availability checking
* - **Error Handling**: User-friendly error messages for connection issues
* - **Reactive State**: Svelte 5 runes for automatic UI updates
* - **State Management**: Loading states and error recovery
*
* **Server Capabilities Detected:**
* - Model name extraction from file path
* - Vision support (multimodal image processing)
* - Audio support (speech processing)
* - Slots endpoint availability (for processing state monitoring)
* - Context window size and token limits
* - **Server State**: Connection status, loading, error handling
* - **Role Detection**: MODEL (single model) vs ROUTER (multi-model)
* - **Default Params**: Server-wide generation defaults
*/
class ServerStore {
constructor() {
if (!browser) return;
// ─────────────────────────────────────────────────────────────────────────────
// State
// ─────────────────────────────────────────────────────────────────────────────
const cachedProps = this.readCachedServerProps();
if (cachedProps) {
this._serverProps = cachedProps;
}
props = $state<ApiLlamaCppServerProps | null>(null);
loading = $state(false);
error = $state<string | null>(null);
role = $state<ServerRole | null>(null);
private fetchPromise: Promise<void> | null = null;
// ─────────────────────────────────────────────────────────────────────────────
// Getters
// ─────────────────────────────────────────────────────────────────────────────
get defaultParams(): ApiLlamaCppServerProps['default_generation_settings']['params'] | null {
return this.props?.default_generation_settings?.params || null;
}
private _serverProps = $state<ApiLlamaCppServerProps | null>(null);
private _loading = $state(false);
private _error = $state<string | null>(null);
private _serverWarning = $state<string | null>(null);
private _slotsEndpointAvailable = $state<boolean | null>(null);
private _serverMode = $state<ServerMode | null>(null);
private _selectedModel = $state<string | null>(null);
private _availableModels = $state<ApiRouterModelMeta[]>([]);
private _modelLoadingStates = $state<Map<string, boolean>>(new Map());
private fetchServerPropsPromise: Promise<void> | null = null;
private readCachedServerProps(): ApiLlamaCppServerProps | null {
if (!browser) return null;
try {
const raw = localStorage.getItem(SERVER_PROPS_LOCALSTORAGE_KEY);
if (!raw) return null;
return JSON.parse(raw) as ApiLlamaCppServerProps;
} catch (error) {
console.warn('Failed to read cached server props from localStorage:', error);
return null;
}
get contextSize(): number | null {
return this.props?.default_generation_settings?.n_ctx ?? null;
}
private persistServerProps(props: ApiLlamaCppServerProps | null): void {
if (!browser) return;
try {
if (props) {
localStorage.setItem(SERVER_PROPS_LOCALSTORAGE_KEY, JSON.stringify(props));
} else {
localStorage.removeItem(SERVER_PROPS_LOCALSTORAGE_KEY);
}
} catch (error) {
console.warn('Failed to persist server props to localStorage:', error);
}
get slotsEndpointAvailable(): boolean {
return this.props?.endpoint_slots ?? false;
}
get serverProps(): ApiLlamaCppServerProps | null {
return this._serverProps;
}
get loading(): boolean {
return this._loading;
}
get error(): string | null {
return this._error;
}
get serverWarning(): string | null {
return this._serverWarning;
}
get modelName(): string | null {
if (this._serverProps?.model_alias) {
return this._serverProps.model_alias;
}
if (!this._serverProps?.model_path) return null;
return this._serverProps.model_path.split(/(\\|\/)/).pop() || null;
}
get supportedModalities(): ModelModality[] {
const modalities: ModelModality[] = [];
if (this._serverProps?.modalities?.audio) {
modalities.push(ModelModality.AUDIO);
}
if (this._serverProps?.modalities?.vision) {
modalities.push(ModelModality.VISION);
}
return modalities;
}
get supportsVision(): boolean {
return this._serverProps?.modalities?.vision ?? false;
}
get supportsAudio(): boolean {
return this._serverProps?.modalities?.audio ?? false;
}
get slotsEndpointAvailable(): boolean | null {
return this._slotsEndpointAvailable;
}
get serverDefaultParams():
| ApiLlamaCppServerProps['default_generation_settings']['params']
| null {
return this._serverProps?.default_generation_settings?.params || null;
}
/**
* Get current server mode
*/
get serverMode(): ServerMode | null {
return this._serverMode;
}
/**
* Detect if server is running in router mode (multi-model management)
*/
get isRouterMode(): boolean {
return this._serverMode === ServerMode.ROUTER;
return this.role === ServerRole.ROUTER;
}
/**
* Detect if server is running in model mode (single model loaded)
*/
get isModelMode(): boolean {
return this._serverMode === ServerMode.MODEL;
return this.role === ServerRole.MODEL;
}
/**
* Get currently selected model in router mode
*/
get selectedModel(): string | null {
return this._selectedModel;
}
// ─────────────────────────────────────────────────────────────────────────────
// Data Handling
// ─────────────────────────────────────────────────────────────────────────────
/**
* Get list of available models
*/
get availableModels(): ApiRouterModelMeta[] {
return this._availableModels;
}
async fetch(): Promise<void> {
if (this.fetchPromise) return this.fetchPromise;
/**
* Check if a specific model is currently loading
*/
isModelLoading(modelName: string): boolean {
return this._modelLoadingStates.get(modelName) ?? false;
}
/**
* Check if slots endpoint is available based on server properties and endpoint support
*/
private async checkSlotsEndpointAvailability(): Promise<void> {
if (!this._serverProps) {
this._slotsEndpointAvailable = false;
return;
}
if (this._serverProps.total_slots <= 0) {
this._slotsEndpointAvailable = false;
return;
}
try {
const currentConfig = config();
const apiKey = currentConfig.apiKey?.toString().trim();
const response = await fetch(`./slots`, {
headers: {
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {})
}
});
if (response.status === 501) {
console.info('Slots endpoint not implemented - server started without --slots flag');
this._slotsEndpointAvailable = false;
return;
}
this._slotsEndpointAvailable = true;
} catch (error) {
console.warn('Unable to test slots endpoint availability:', error);
this._slotsEndpointAvailable = false;
}
}
/**
* Fetches server properties from the server
*/
async fetchServerProps(options: { silent?: boolean } = {}): Promise<void> {
const { silent = false } = options;
const isSilent = silent && this._serverProps !== null;
if (this.fetchServerPropsPromise) {
return this.fetchServerPropsPromise;
}
if (!isSilent) {
this._loading = true;
this._error = null;
this._serverWarning = null;
}
const hadProps = this._serverProps !== null;
this.loading = true;
this.error = null;
const fetchPromise = (async () => {
try {
const props = await PropsService.fetch();
this._serverProps = props;
this.persistServerProps(props);
this._error = null;
this._serverWarning = null;
// Detect server mode based on model_path
if (props.model_path === 'none') {
this._serverMode = ServerMode.ROUTER;
console.info('Server running in ROUTER mode (multi-model management)');
// Auto-enable model selector in router mode
if (browser) {
updateConfig('modelSelectorEnabled', true);
}
} else {
this._serverMode = ServerMode.MODEL;
console.info('Server running in MODEL mode (single model)');
}
await this.checkSlotsEndpointAvailability();
this.props = props;
this.error = null;
this.detectRole(props);
} catch (error) {
if (isSilent && hadProps) {
console.warn('Silent server props refresh failed, keeping cached data:', error);
return;
}
this.handleFetchServerPropsError(error, hadProps);
this.error = this.getErrorMessage(error);
console.error('Error fetching server properties:', error);
} finally {
if (!isSilent) {
this._loading = false;
}
this.fetchServerPropsPromise = null;
this.loading = false;
this.fetchPromise = null;
}
})();
this.fetchServerPropsPromise = fetchPromise;
this.fetchPromise = fetchPromise;
await fetchPromise;
}
/**
* Handles fetch failures by attempting to recover cached server props and
* updating the user-facing error or warning state appropriately.
*/
private handleFetchServerPropsError(error: unknown, hadProps: boolean): void {
const { errorMessage, isOfflineLikeError, isServerSideError } = this.normalizeFetchError(error);
let cachedProps: ApiLlamaCppServerProps | null = null;
if (!hadProps) {
cachedProps = this.readCachedServerProps();
if (cachedProps) {
this._serverProps = cachedProps;
this._error = null;
if (isOfflineLikeError || isServerSideError) {
this._serverWarning = errorMessage;
}
console.warn(
'Failed to refresh server properties, using cached values from localStorage:',
errorMessage
);
} else {
this._error = errorMessage;
}
} else {
this._error = null;
if (isOfflineLikeError || isServerSideError) {
this._serverWarning = errorMessage;
}
console.warn(
'Failed to refresh server properties, continuing with cached values:',
errorMessage
);
}
console.error('Error fetching server properties:', error);
}
private normalizeFetchError(error: unknown): {
errorMessage: string;
isOfflineLikeError: boolean;
isServerSideError: boolean;
} {
let errorMessage = 'Failed to connect to server';
let isOfflineLikeError = false;
let isServerSideError = false;
private getErrorMessage(error: unknown): string {
if (error instanceof Error) {
const message = error.message || '';
if (error.name === 'TypeError' && message.includes('fetch')) {
errorMessage = 'Server is not running or unreachable';
isOfflineLikeError = true;
return 'Server is not running or unreachable';
} else if (message.includes('ECONNREFUSED')) {
errorMessage = 'Connection refused - server may be offline';
isOfflineLikeError = true;
return 'Connection refused - server may be offline';
} else if (message.includes('ENOTFOUND')) {
errorMessage = 'Server not found - check server address';
isOfflineLikeError = true;
return 'Server not found - check server address';
} else if (message.includes('ETIMEDOUT')) {
errorMessage = 'Request timed out - the server took too long to respond';
isOfflineLikeError = true;
return 'Request timed out';
} else if (message.includes('503')) {
errorMessage = 'Server temporarily unavailable - try again shortly';
isServerSideError = true;
return 'Server temporarily unavailable';
} else if (message.includes('500')) {
errorMessage = 'Server error - check server logs';
isServerSideError = true;
return 'Server error - check server logs';
} else if (message.includes('404')) {
errorMessage = 'Server endpoint not found';
return 'Server endpoint not found';
} else if (message.includes('403') || message.includes('401')) {
errorMessage = 'Access denied';
return 'Access denied';
}
}
return { errorMessage, isOfflineLikeError, isServerSideError };
return 'Failed to connect to server';
}
/**
* Clears the server state
*/
clear(): void {
this._serverProps = null;
this._error = null;
this._serverWarning = null;
this._loading = false;
this._slotsEndpointAvailable = null;
this._serverMode = null;
this._selectedModel = null;
this._availableModels = [];
this._modelLoadingStates.clear();
this.fetchServerPropsPromise = null;
this.persistServerProps(null);
this.props = null;
this.error = null;
this.loading = false;
this.role = null;
this.fetchPromise = null;
}
// ─────────────────────────────────────────────────────────────────────────────
// Utilities
// ─────────────────────────────────────────────────────────────────────────────
private detectRole(props: ApiLlamaCppServerProps): void {
const newRole = props?.role === ServerRole.ROUTER ? ServerRole.ROUTER : ServerRole.MODEL;
if (this.role !== newRole) {
this.role = newRole;
console.info(`Server running in ${newRole === ServerRole.ROUTER ? 'ROUTER' : 'MODEL'} mode`);
}
}
}
export const serverStore = new ServerStore();
export const serverProps = () => serverStore.serverProps;
export const serverProps = () => serverStore.props;
export const serverLoading = () => serverStore.loading;
export const serverError = () => serverStore.error;
export const serverWarning = () => serverStore.serverWarning;
export const modelName = () => serverStore.modelName;
export const supportedModalities = () => serverStore.supportedModalities;
export const supportsVision = () => serverStore.supportsVision;
export const supportsAudio = () => serverStore.supportsAudio;
export const serverRole = () => serverStore.role;
export const slotsEndpointAvailable = () => serverStore.slotsEndpointAvailable;
export const serverDefaultParams = () => serverStore.serverDefaultParams;
// Server mode exports
export const serverMode = () => serverStore.serverMode;
export const defaultParams = () => serverStore.defaultParams;
export const contextSize = () => serverStore.contextSize;
export const isRouterMode = () => serverStore.isRouterMode;
export const isModelMode = () => serverStore.isModelMode;
export const selectedModel = () => serverStore.selectedModel;
export const availableModels = () => serverStore.availableModels;

View File

@ -1,12 +1,12 @@
/**
* SettingsStore - Application configuration and theme management
* settingsStore - Application configuration and theme management
*
* This store manages all application settings including AI model parameters, UI preferences,
* and theme configuration. It provides persistent storage through localStorage with reactive
* state management using Svelte 5 runes.
*
* **Architecture & Relationships:**
* - **SettingsStore** (this class): Configuration state management
* - **settingsStore** (this class): Configuration state management
* - Manages AI model parameters (temperature, max tokens, etc.)
* - Handles theme switching and persistence
* - Provides localStorage synchronization
@ -33,23 +33,39 @@
import { browser } from '$app/environment';
import { SETTING_CONFIG_DEFAULT } from '$lib/constants/settings-config';
import { normalizeFloatingPoint } from '$lib/utils/precision';
import { ParameterSyncService } from '$lib/services/parameter-sync';
import { serverStore } from '$lib/stores/server.svelte';
import { setConfigValue, getConfigValue, configToParameterRecord } from '$lib/utils/config-helpers';
import {
configToParameterRecord,
normalizeFloatingPoint,
getConfigValue,
setConfigValue
} from '$lib/utils';
import {
CONFIG_LOCALSTORAGE_KEY,
USER_OVERRIDES_LOCALSTORAGE_KEY
} from '$lib/constants/localstorage-keys';
class SettingsStore {
// ─────────────────────────────────────────────────────────────────────────────
// State
// ─────────────────────────────────────────────────────────────────────────────
config = $state<SettingsConfigType>({ ...SETTING_CONFIG_DEFAULT });
theme = $state<string>('auto');
isInitialized = $state(false);
userOverrides = $state<Set<string>>(new Set());
// ─────────────────────────────────────────────────────────────────────────────
// Utilities (private helpers)
// ─────────────────────────────────────────────────────────────────────────────
/**
* Helper method to get server defaults with null safety
* Centralizes the pattern of getting and extracting server defaults
*/
private getServerDefaults(): Record<string, string | number | boolean> {
const serverParams = serverStore.serverDefaultParams;
const serverParams = serverStore.defaultParams;
return serverParams ? ParameterSyncService.extractServerDefaults(serverParams) : {};
}
@ -59,6 +75,10 @@ class SettingsStore {
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Lifecycle
// ─────────────────────────────────────────────────────────────────────────────
/**
* Initialize the settings store by loading from localStorage
*/
@ -80,7 +100,7 @@ class SettingsStore {
if (!browser) return;
try {
const storedConfigRaw = localStorage.getItem('config');
const storedConfigRaw = localStorage.getItem(CONFIG_LOCALSTORAGE_KEY);
const savedVal = JSON.parse(storedConfigRaw || '{}');
// Merge with defaults to prevent breaking changes
@ -90,7 +110,9 @@ class SettingsStore {
};
// Load user overrides
const savedOverrides = JSON.parse(localStorage.getItem('userOverrides') || '[]');
const savedOverrides = JSON.parse(
localStorage.getItem(USER_OVERRIDES_LOCALSTORAGE_KEY) || '[]'
);
this.userOverrides = new Set(savedOverrides);
} catch (error) {
console.warn('Failed to parse config from localStorage, using defaults:', error);
@ -107,6 +129,10 @@ class SettingsStore {
this.theme = localStorage.getItem('theme') || 'auto';
}
// ─────────────────────────────────────────────────────────────────────────────
// Config Updates
// ─────────────────────────────────────────────────────────────────────────────
/**
* Update a specific configuration setting
* @param key - The configuration key to update
@ -170,9 +196,12 @@ class SettingsStore {
if (!browser) return;
try {
localStorage.setItem('config', JSON.stringify(this.config));
localStorage.setItem(CONFIG_LOCALSTORAGE_KEY, JSON.stringify(this.config));
localStorage.setItem('userOverrides', JSON.stringify(Array.from(this.userOverrides)));
localStorage.setItem(
USER_OVERRIDES_LOCALSTORAGE_KEY,
JSON.stringify(Array.from(this.userOverrides))
);
} catch (error) {
console.error('Failed to save config to localStorage:', error);
}
@ -204,6 +233,10 @@ class SettingsStore {
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Reset
// ─────────────────────────────────────────────────────────────────────────────
/**
* Reset configuration to defaults
*/
@ -229,28 +262,38 @@ class SettingsStore {
}
/**
* Get a specific configuration value
* @param key - The configuration key to get
* @returns The configuration value
* Reset a parameter to server default (or webui default if no server default)
*/
getConfig<K extends keyof SettingsConfigType>(key: K): SettingsConfigType[K] {
return this.config[key];
resetParameterToServerDefault(key: string): void {
const serverDefaults = this.getServerDefaults();
if (serverDefaults[key] !== undefined) {
const value = normalizeFloatingPoint(serverDefaults[key]);
this.config[key as keyof SettingsConfigType] =
value as SettingsConfigType[keyof SettingsConfigType];
} else {
if (key in SETTING_CONFIG_DEFAULT) {
const defaultValue = getConfigValue(SETTING_CONFIG_DEFAULT, key);
setConfigValue(this.config, key, defaultValue);
}
}
this.userOverrides.delete(key);
this.saveConfig();
}
/**
* Get the entire configuration object
* @returns The complete configuration object
*/
getAllConfig(): SettingsConfigType {
return { ...this.config };
}
// ─────────────────────────────────────────────────────────────────────────────
// Server Sync
// ─────────────────────────────────────────────────────────────────────────────
/**
* Initialize settings with props defaults when server properties are first loaded
* This sets up the default values from /props endpoint
*/
syncWithServerDefaults(): void {
const serverParams = serverStore.serverDefaultParams;
const serverParams = serverStore.defaultParams;
if (!serverParams) {
console.warn('No server parameters available for initialization');
@ -278,15 +321,6 @@ class SettingsStore {
console.log('Current user overrides after sync:', Array.from(this.userOverrides));
}
/**
* Clear all user overrides (for debugging)
*/
clearAllUserOverrides(): void {
this.userOverrides.clear();
this.saveConfig();
console.log('Cleared all user overrides');
}
/**
* Reset all parameters to their default values (from props)
* This is used by the "Reset to Default" functionality
@ -315,6 +349,27 @@ class SettingsStore {
this.saveConfig();
}
// ─────────────────────────────────────────────────────────────────────────────
// Utilities
// ─────────────────────────────────────────────────────────────────────────────
/**
* Get a specific configuration value
* @param key - The configuration key to get
* @returns The configuration value
*/
getConfig<K extends keyof SettingsConfigType>(key: K): SettingsConfigType[K] {
return this.config[key];
}
/**
* Get the entire configuration object
* @returns The complete configuration object
*/
getAllConfig(): SettingsConfigType {
return { ...this.config };
}
/**
* Get parameter information including source for a specific parameter
*/
@ -330,29 +385,6 @@ class SettingsStore {
);
}
/**
* Reset a parameter to server default (or webui default if no server default)
*/
resetParameterToServerDefault(key: string): void {
const serverDefaults = this.getServerDefaults();
if (serverDefaults[key] !== undefined) {
const value = normalizeFloatingPoint(serverDefaults[key]);
this.config[key as keyof SettingsConfigType] =
value as SettingsConfigType[keyof SettingsConfigType];
} else {
if (key in SETTING_CONFIG_DEFAULT) {
const defaultValue = getConfigValue(SETTING_CONFIG_DEFAULT, key);
setConfigValue(this.config, key, defaultValue);
}
}
this.userOverrides.delete(key);
this.saveConfig();
}
/**
* Get diff between current settings and server defaults
*/
@ -367,30 +399,19 @@ class SettingsStore {
return ParameterSyncService.createParameterDiff(configAsRecord, serverDefaults);
}
/**
* Clear all user overrides (for debugging)
*/
clearAllUserOverrides(): void {
this.userOverrides.clear();
this.saveConfig();
console.log('Cleared all user overrides');
}
}
// Create and export the settings store instance
export const settingsStore = new SettingsStore();
// Export reactive getters for easy access in components
export const config = () => settingsStore.config;
export const theme = () => settingsStore.theme;
export const isInitialized = () => settingsStore.isInitialized;
// Export bound methods for easy access
export const updateConfig = settingsStore.updateConfig.bind(settingsStore);
export const updateMultipleConfig = settingsStore.updateMultipleConfig.bind(settingsStore);
export const updateTheme = settingsStore.updateTheme.bind(settingsStore);
export const resetConfig = settingsStore.resetConfig.bind(settingsStore);
export const resetTheme = settingsStore.resetTheme.bind(settingsStore);
export const resetAll = settingsStore.resetAll.bind(settingsStore);
export const getConfig = settingsStore.getConfig.bind(settingsStore);
export const getAllConfig = settingsStore.getAllConfig.bind(settingsStore);
export const syncWithServerDefaults = settingsStore.syncWithServerDefaults.bind(settingsStore);
export const forceSyncWithServerDefaults =
settingsStore.forceSyncWithServerDefaults.bind(settingsStore);
export const getParameterInfo = settingsStore.getParameterInfo.bind(settingsStore);
export const resetParameterToServerDefault =
settingsStore.resetParameterToServerDefault.bind(settingsStore);
export const getParameterDiff = settingsStore.getParameterDiff.bind(settingsStore);
export const clearAllUserOverrides = settingsStore.clearAllUserOverrides.bind(settingsStore);

View File

@ -1,4 +1,4 @@
import type { ServerModelStatus } from '$lib/enums';
import type { ServerModelStatus, ServerRole } from '$lib/enums';
import type { ChatMessagePromptProgress } from './chat';
export interface ApiChatMessageContentPart {
@ -37,11 +37,38 @@ export interface ApiChatMessageData {
timestamp?: number;
}
/**
* Model status object from /models endpoint
*/
export interface ApiModelStatus {
/** Status value: loaded, unloaded, loading, failed */
value: ServerModelStatus;
/** Command line arguments used when loading (only for loaded models) */
args?: string[];
}
/**
* Model entry from /models endpoint (ROUTER mode)
* Based on actual API response structure
*/
export interface ApiModelDataEntry {
/** Model identifier (e.g., "ggml-org/Qwen2.5-Omni-7B-GGUF:latest") */
id: string;
/** Model name (optional, usually same as id - not always returned by API) */
name?: string;
/** Object type, always "model" */
object: string;
created: number;
/** Owner, usually "llamacpp" */
owned_by: string;
/** Creation timestamp */
created: number;
/** Whether model files are in HuggingFace cache */
in_cache: boolean;
/** Path to model manifest file */
path: string;
/** Current status of the model */
status: ApiModelStatus;
/** Legacy meta field (may be present in older responses) */
meta?: Record<string, unknown> | null;
}
@ -140,6 +167,7 @@ export interface ApiLlamaCppServerProps {
};
total_slots: number;
model_path: string;
role: ServerRole;
modalities: {
vision: boolean;
audio: boolean;
@ -316,8 +344,12 @@ export interface ApiProcessingState {
cacheTokens?: number;
}
/**
* Router model metadata - extended from ApiModelDataEntry with additional router-specific fields
* @deprecated Use ApiModelDataEntry instead - the /models endpoint returns this structure directly
*/
export interface ApiRouterModelMeta {
/** Model identifier (e.g., "unsloth/phi-4-GGUF:q4_k_m") */
/** Model identifier (e.g., "ggml-org/Qwen2.5-Omni-7B-GGUF:latest") */
name: string;
/** Path to model file or manifest */
path: string;
@ -326,9 +358,9 @@ export interface ApiRouterModelMeta {
/** Whether model is in HuggingFace cache */
in_cache: boolean;
/** Port where model instance is running (0 if not loaded) */
port: number;
port?: number;
/** Current status of the model */
status: ServerModelStatus;
status: ApiModelStatus;
/** Error message if status is FAILED */
error?: string;
}
@ -366,10 +398,13 @@ export interface ApiRouterModelsStatusResponse {
}
/**
* Response with list of all models
* Response with list of all models from /models endpoint
* Note: This is the same as ApiModelListResponse - the endpoint returns the same structure
* regardless of server mode (MODEL or ROUTER)
*/
export interface ApiRouterModelsListResponse {
models: ApiRouterModelMeta[];
object: string;
data: ApiModelDataEntry[];
}
/**

View File

@ -0,0 +1,70 @@
/**
* Unified exports for all type definitions
* Import types from '$lib/types' for cleaner imports
*/
// API types
export type {
ApiChatMessageContentPart,
ApiContextSizeError,
ApiErrorResponse,
ApiChatMessageData,
ApiModelStatus,
ApiModelDataEntry,
ApiModelDetails,
ApiModelListResponse,
ApiLlamaCppServerProps,
ApiChatCompletionRequest,
ApiChatCompletionToolCallFunctionDelta,
ApiChatCompletionToolCallDelta,
ApiChatCompletionToolCall,
ApiChatCompletionStreamChunk,
ApiChatCompletionResponse,
ApiSlotData,
ApiProcessingState,
ApiRouterModelMeta,
ApiRouterModelsLoadRequest,
ApiRouterModelsLoadResponse,
ApiRouterModelsStatusRequest,
ApiRouterModelsStatusResponse,
ApiRouterModelsListResponse,
ApiRouterModelsUnloadRequest,
ApiRouterModelsUnloadResponse
} from './api';
// Chat types
export type {
ChatMessageType,
ChatRole,
ChatUploadedFile,
ChatAttachmentDisplayItem,
ChatAttachmentPreviewItem,
ChatMessageSiblingInfo,
ChatMessagePromptProgress,
ChatMessageTimings
} from './chat';
// Database types
export type {
DatabaseConversation,
DatabaseMessageExtraAudioFile,
DatabaseMessageExtraImageFile,
DatabaseMessageExtraLegacyContext,
DatabaseMessageExtraPdfFile,
DatabaseMessageExtraTextFile,
DatabaseMessageExtra,
DatabaseMessage,
ExportedConversation,
ExportedConversations
} from './database';
// Model types
export type { ModelModalities, ModelOption } from './models';
// Settings types
export type {
SettingsConfigValue,
SettingsFieldConfig,
SettingsChatServiceOptions,
SettingsConfigType
} from './settings';

View File

@ -1,11 +1,21 @@
import type { ApiModelDataEntry, ApiModelDetails } from '$lib/types/api';
/**
* Model modalities - vision and audio capabilities
*/
export interface ModelModalities {
vision: boolean;
audio: boolean;
}
export interface ModelOption {
id: string;
name: string;
model: string;
description?: string;
capabilities: string[];
/** Model modalities from /props endpoint */
modalities?: ModelModalities;
details?: ApiModelDetails['details'];
meta?: ApiModelDataEntry['meta'];
}

View File

@ -14,6 +14,12 @@ export interface SettingsFieldConfig {
export interface SettingsChatServiceOptions {
stream?: boolean;
// Model (required in ROUTER mode, optional in MODEL mode)
model?: string;
// System message to inject
systemMessage?: string;
// Disable reasoning format (use 'none' instead of 'auto')
disableReasoningFormat?: boolean;
// Generation parameters
temperature?: number;
max_tokens?: number;
@ -45,6 +51,7 @@ export interface SettingsChatServiceOptions {
onReasoningChunk?: (chunk: string) => void;
onToolCallChunk?: (chunk: string) => void;
onModel?: (model: string) => void;
onTimings?: (timings: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => void;
onComplete?: (
response: string,
reasoningContent?: string,

View File

@ -0,0 +1,22 @@
import { config } from '$lib/stores/settings.svelte';
/**
* Get authorization headers for API requests
* Includes Bearer token if API key is configured
*/
export function getAuthHeaders(): Record<string, string> {
const currentConfig = config();
const apiKey = currentConfig.apiKey?.toString().trim();
return apiKey ? { Authorization: `Bearer ${apiKey}` } : {};
}
/**
* Get standard JSON headers with optional authorization
*/
export function getJsonHeaders(): Record<string, string> {
return {
'Content-Type': 'application/json',
...getAuthHeaders()
};
}

View File

@ -0,0 +1,61 @@
import { FileTypeCategory } from '$lib/enums';
import { getFileTypeCategory, getFileTypeCategoryByExtension, isImageFile } from '$lib/utils';
export interface AttachmentDisplayItemsOptions {
uploadedFiles?: ChatUploadedFile[];
attachments?: DatabaseMessageExtra[];
}
/**
* Gets the file type category from an uploaded file, checking both MIME type and extension
*/
function getUploadedFileCategory(file: ChatUploadedFile): FileTypeCategory | null {
const categoryByMime = getFileTypeCategory(file.type);
if (categoryByMime) {
return categoryByMime;
}
return getFileTypeCategoryByExtension(file.name);
}
/**
* Creates a unified list of display items from uploaded files and stored attachments.
* Items are returned in reverse order (newest first).
*/
export function getAttachmentDisplayItems(
options: AttachmentDisplayItemsOptions
): ChatAttachmentDisplayItem[] {
const { uploadedFiles = [], attachments = [] } = options;
const items: ChatAttachmentDisplayItem[] = [];
// Add uploaded files (ChatForm)
for (const file of uploadedFiles) {
items.push({
id: file.id,
name: file.name,
size: file.size,
preview: file.preview,
isImage: getUploadedFileCategory(file) === FileTypeCategory.IMAGE,
uploadedFile: file,
textContent: file.textContent
});
}
// Add stored attachments (ChatMessage)
for (const [index, attachment] of attachments.entries()) {
const isImage = isImageFile(attachment);
items.push({
id: `attachment-${index}`,
name: attachment.name,
preview: isImage && 'base64Url' in attachment ? attachment.base64Url : undefined,
isImage,
attachment,
attachmentIndex: index,
textContent: 'content' in attachment ? attachment.content : undefined
});
}
return items.reverse();
}

View File

@ -1,7 +1,22 @@
import { AttachmentType, FileTypeCategory } from '$lib/enums';
import { getFileTypeCategory } from '$lib/utils/file-type';
import { getFileTypeLabel } from '$lib/utils/file-preview';
import type { DatabaseMessageExtra } from '$lib/types/database';
import { getFileTypeCategory, getFileTypeCategoryByExtension } from '$lib/utils';
/**
* Gets the file type category from an uploaded file, checking both MIME type and extension
* @param uploadedFile - The uploaded file to check
* @returns The file type category or null if not recognized
*/
function getUploadedFileCategory(uploadedFile: ChatUploadedFile): FileTypeCategory | null {
// First try MIME type
const categoryByMime = getFileTypeCategory(uploadedFile.type);
if (categoryByMime) {
return categoryByMime;
}
// Fallback to extension (browsers don't always provide correct MIME types)
return getFileTypeCategoryByExtension(uploadedFile.name);
}
/**
* Determines if an attachment or uploaded file is a text file
@ -14,7 +29,7 @@ export function isTextFile(
uploadedFile?: ChatUploadedFile
): boolean {
if (uploadedFile) {
return getFileTypeCategory(uploadedFile.type) === FileTypeCategory.TEXT;
return getUploadedFileCategory(uploadedFile) === FileTypeCategory.TEXT;
}
if (attachment) {
@ -37,7 +52,7 @@ export function isImageFile(
uploadedFile?: ChatUploadedFile
): boolean {
if (uploadedFile) {
return getFileTypeCategory(uploadedFile.type) === FileTypeCategory.IMAGE;
return getUploadedFileCategory(uploadedFile) === FileTypeCategory.IMAGE;
}
if (attachment) {
@ -58,7 +73,7 @@ export function isPdfFile(
uploadedFile?: ChatUploadedFile
): boolean {
if (uploadedFile) {
return uploadedFile.type === 'application/pdf';
return getUploadedFileCategory(uploadedFile) === FileTypeCategory.PDF;
}
if (attachment) {
@ -79,7 +94,7 @@ export function isAudioFile(
uploadedFile?: ChatUploadedFile
): boolean {
if (uploadedFile) {
return getFileTypeCategory(uploadedFile.type) === FileTypeCategory.AUDIO;
return getUploadedFileCategory(uploadedFile) === FileTypeCategory.AUDIO;
}
if (attachment) {
@ -88,38 +103,3 @@ export function isAudioFile(
return false;
}
/**
* Gets a human-readable type label for display
* @param uploadedFile - Optional uploaded file
* @param attachment - Optional database attachment
* @returns A formatted type label string
*/
export function getAttachmentTypeLabel(
attachment?: DatabaseMessageExtra,
uploadedFile?: ChatUploadedFile
): string {
if (uploadedFile) {
// For uploaded files, use the file type label utility
return getFileTypeLabel(uploadedFile.type);
}
if (attachment) {
// For attachments, convert enum to readable format
switch (attachment.type) {
case AttachmentType.IMAGE:
return 'image';
case AttachmentType.AUDIO:
return 'audio';
case AttachmentType.PDF:
return 'pdf';
case AttachmentType.TEXT:
case AttachmentType.LEGACY_CONTEXT:
return 'text';
default:
return 'unknown';
}
}
return 'unknown';
}

View File

@ -0,0 +1,35 @@
/**
* Browser-only utility exports
*
* These utilities require browser APIs (DOM, Canvas, MediaRecorder, etc.)
* and cannot be imported during SSR. Import from '$lib/utils/browser-only'
* only in client-side code or components that are not server-rendered.
*/
// Audio utilities (MediaRecorder API)
export {
AudioRecorder,
convertToWav,
createAudioFile,
isAudioRecordingSupported
} from './audio-recording';
// PDF processing utilities (pdfjs-dist with DOMMatrix)
export {
convertPDFToText,
convertPDFToImage,
isPdfFile as isPdfFileFromFile,
isApplicationMimeType
} from './pdf-processing';
// File conversion utilities (depends on pdf-processing)
export { parseFilesToMessageExtras, type FileProcessingResult } from './convert-files-to-extra';
// File upload processing utilities (depends on pdf-processing, svg-to-png, webp-to-png)
export { processFilesToChatUploaded } from './process-uploaded-files';
// SVG utilities (Canvas/Image API)
export { svgBase64UrlToPngDataURL, isSvgFile, isSvgMimeType } from './svg-to-png';
// WebP utilities (Canvas/Image API)
export { webpBase64UrlToPngDataURL, isWebpFile, isWebpMimeType } from './webp-to-png';

View File

@ -5,8 +5,6 @@
* with dynamic keys while maintaining TypeScript type safety.
*/
import type { SettingsConfigType } from '$lib/types/settings';
/**
* Type-safe helper to access config properties dynamically
* Provides better type safety than direct casting to Record

View File

@ -3,8 +3,8 @@ import { isSvgMimeType, svgBase64UrlToPngDataURL } from './svg-to-png';
import { isWebpMimeType, webpBase64UrlToPngDataURL } from './webp-to-png';
import { FileTypeCategory, AttachmentType } from '$lib/enums';
import { config, settingsStore } from '$lib/stores/settings.svelte';
import { supportsVision } from '$lib/stores/server.svelte';
import { getFileTypeCategory } from '$lib/utils/file-type';
import { modelsStore } from '$lib/stores/models.svelte';
import { getFileTypeCategory } from '$lib/utils';
import { readFileAsText, isLikelyTextFile } from './text-files';
import { toast } from 'svelte-sonner';
@ -31,7 +31,8 @@ export interface FileProcessingResult {
}
export async function parseFilesToMessageExtras(
files: ChatUploadedFile[]
files: ChatUploadedFile[],
activeModelId?: string
): Promise<FileProcessingResult> {
const extras: DatabaseMessageExtra[] = [];
const emptyFiles: string[] = [];
@ -80,7 +81,10 @@ export async function parseFilesToMessageExtras(
// Always get base64 data for preview functionality
const base64Data = await readFileAsBase64(file.file);
const currentConfig = config();
const hasVisionSupport = supportsVision();
// Use per-model vision check for router mode
const hasVisionSupport = activeModelId
? modelsStore.modelSupportsVision(activeModelId)
: false;
// Force PDF-to-text for non-vision models
let shouldProcessAsImages = Boolean(currentConfig.pdfAsImage) && hasVisionSupport;

View File

@ -1,10 +1,38 @@
/**
* Gets a display label for a file type
* @param fileType - The file type/mime type
* @returns Formatted file type label
* Gets a display label for a file type from various input formats
*
* Handles:
* - MIME types: 'application/pdf' 'PDF'
* - AttachmentType values: 'PDF', 'AUDIO' 'PDF', 'AUDIO'
* - File names: 'document.pdf' 'PDF'
* - Unknown: returns 'FILE'
*
* @param input - MIME type, AttachmentType value, or file name
* @returns Formatted file type label (uppercase)
*/
export function getFileTypeLabel(fileType: string): string {
return fileType.split('/').pop()?.toUpperCase() || 'FILE';
export function getFileTypeLabel(input: string | undefined): string {
if (!input) return 'FILE';
// Handle MIME types (contains '/')
if (input.includes('/')) {
const subtype = input.split('/').pop();
if (subtype) {
// Handle special cases like 'vnd.ms-excel' → 'EXCEL'
if (subtype.includes('.')) {
return subtype.split('.').pop()?.toUpperCase() || 'FILE';
}
return subtype.toUpperCase();
}
}
// Handle file names (contains '.')
if (input.includes('.')) {
const ext = input.split('.').pop();
if (ext) return ext.toUpperCase();
}
// Handle AttachmentType or other plain strings
return input.toUpperCase();
}
/**

View File

@ -4,42 +4,151 @@ import {
PDF_FILE_TYPES,
TEXT_FILE_TYPES
} from '$lib/constants/supported-file-types';
import { FileTypeCategory } from '$lib/enums';
import {
FileExtensionAudio,
FileExtensionImage,
FileExtensionPdf,
FileExtensionText,
FileTypeCategory,
MimeTypeApplication,
MimeTypeAudio,
MimeTypeImage,
MimeTypeText
} from '$lib/enums';
export function getFileTypeCategory(mimeType: string): FileTypeCategory | null {
if (
Object.values(IMAGE_FILE_TYPES).some((type) =>
(type.mimeTypes as readonly string[]).includes(mimeType)
)
) {
return FileTypeCategory.IMAGE;
}
switch (mimeType) {
// Images
case MimeTypeImage.JPEG:
case MimeTypeImage.PNG:
case MimeTypeImage.GIF:
case MimeTypeImage.WEBP:
case MimeTypeImage.SVG:
return FileTypeCategory.IMAGE;
if (
Object.values(AUDIO_FILE_TYPES).some((type) =>
(type.mimeTypes as readonly string[]).includes(mimeType)
)
) {
return FileTypeCategory.AUDIO;
}
// Audio
case MimeTypeAudio.MP3_MPEG:
case MimeTypeAudio.MP3:
case MimeTypeAudio.MP4:
case MimeTypeAudio.WAV:
case MimeTypeAudio.WEBM:
case MimeTypeAudio.WEBM_OPUS:
return FileTypeCategory.AUDIO;
if (
Object.values(PDF_FILE_TYPES).some((type) =>
(type.mimeTypes as readonly string[]).includes(mimeType)
)
) {
return FileTypeCategory.PDF;
}
// PDF
case MimeTypeApplication.PDF:
return FileTypeCategory.PDF;
if (
Object.values(TEXT_FILE_TYPES).some((type) =>
(type.mimeTypes as readonly string[]).includes(mimeType)
)
) {
return FileTypeCategory.TEXT;
}
// Text
case MimeTypeText.PLAIN:
case MimeTypeText.MARKDOWN:
case MimeTypeText.ASCIIDOC:
case MimeTypeText.JAVASCRIPT:
case MimeTypeText.JAVASCRIPT_APP:
case MimeTypeText.TYPESCRIPT:
case MimeTypeText.JSX:
case MimeTypeText.TSX:
case MimeTypeText.CSS:
case MimeTypeText.HTML:
case MimeTypeText.JSON:
case MimeTypeText.XML_TEXT:
case MimeTypeText.XML_APP:
case MimeTypeText.YAML_TEXT:
case MimeTypeText.YAML_APP:
case MimeTypeText.CSV:
case MimeTypeText.PYTHON:
case MimeTypeText.JAVA:
case MimeTypeText.CPP_SRC:
case MimeTypeText.C_SRC:
case MimeTypeText.C_HDR:
case MimeTypeText.PHP:
case MimeTypeText.RUBY:
case MimeTypeText.GO:
case MimeTypeText.RUST:
case MimeTypeText.SHELL:
case MimeTypeText.BAT:
case MimeTypeText.SQL:
case MimeTypeText.R:
case MimeTypeText.SCALA:
case MimeTypeText.KOTLIN:
case MimeTypeText.SWIFT:
case MimeTypeText.DART:
case MimeTypeText.VUE:
case MimeTypeText.SVELTE:
case MimeTypeText.LATEX:
case MimeTypeText.BIBTEX:
return FileTypeCategory.TEXT;
return null;
default:
return null;
}
}
export function getFileTypeCategoryByExtension(filename: string): FileTypeCategory | null {
const extension = filename.toLowerCase().substring(filename.lastIndexOf('.'));
switch (extension) {
// Images
case FileExtensionImage.JPG:
case FileExtensionImage.JPEG:
case FileExtensionImage.PNG:
case FileExtensionImage.GIF:
case FileExtensionImage.WEBP:
case FileExtensionImage.SVG:
return FileTypeCategory.IMAGE;
// Audio
case FileExtensionAudio.MP3:
case FileExtensionAudio.WAV:
return FileTypeCategory.AUDIO;
// PDF
case FileExtensionPdf.PDF:
return FileTypeCategory.PDF;
// Text
case FileExtensionText.TXT:
case FileExtensionText.MD:
case FileExtensionText.ADOC:
case FileExtensionText.JS:
case FileExtensionText.TS:
case FileExtensionText.JSX:
case FileExtensionText.TSX:
case FileExtensionText.CSS:
case FileExtensionText.HTML:
case FileExtensionText.HTM:
case FileExtensionText.JSON:
case FileExtensionText.XML:
case FileExtensionText.YAML:
case FileExtensionText.YML:
case FileExtensionText.CSV:
case FileExtensionText.LOG:
case FileExtensionText.PY:
case FileExtensionText.JAVA:
case FileExtensionText.CPP:
case FileExtensionText.C:
case FileExtensionText.H:
case FileExtensionText.PHP:
case FileExtensionText.RB:
case FileExtensionText.GO:
case FileExtensionText.RS:
case FileExtensionText.SH:
case FileExtensionText.BAT:
case FileExtensionText.SQL:
case FileExtensionText.R:
case FileExtensionText.SCALA:
case FileExtensionText.KT:
case FileExtensionText.SWIFT:
case FileExtensionText.DART:
case FileExtensionText.VUE:
case FileExtensionText.SVELTE:
case FileExtensionText.TEX:
case FileExtensionText.BIB:
return FileTypeCategory.TEXT;
default:
return null;
}
}
export function getFileTypeByExtension(filename: string): string | null {

View File

@ -0,0 +1,87 @@
/**
* Unified exports for all utility functions
* Import utilities from '$lib/utils' for cleaner imports
*
* For browser-only utilities (pdf-processing, audio-recording, svg-to-png,
* webp-to-png, process-uploaded-files, convert-files-to-extra), use:
* import { ... } from '$lib/utils/browser-only'
*/
// API utilities
export { getAuthHeaders, getJsonHeaders } from './api-headers';
export { validateApiKey } from './api-key-validation';
// Attachment utilities
export {
getAttachmentDisplayItems,
type AttachmentDisplayItemsOptions
} from './attachment-display';
export { isTextFile, isImageFile, isPdfFile, isAudioFile } from './attachment-type';
// Textarea utilities
export { default as autoResizeTextarea } from './autoresize-textarea';
// Branching utilities
export {
filterByLeafNodeId,
findLeafNode,
findDescendantMessages,
getMessageSiblings,
getMessageDisplayList,
hasMessageSiblings,
getNextSibling,
getPreviousSibling
} from './branching';
// Config helpers
export { setConfigValue, getConfigValue, configToParameterRecord } from './config-helpers';
// Conversation utilities
export { createMessageCountMap, getMessageCount } from './conversation-utils';
// Clipboard utilities
export { copyToClipboard, copyCodeToClipboard } from './copy';
// File preview utilities
export { getFileTypeLabel, getPreviewText } from './file-preview';
// File type utilities
export {
getFileTypeCategory,
getFileTypeCategoryByExtension,
getFileTypeByExtension,
isFileTypeSupported
} from './file-type';
// Formatting utilities
export { formatFileSize, formatParameters, formatNumber } from './formatters';
// IME utilities
export { isIMEComposing } from './is-ime-composing';
// LaTeX utilities
export { maskInlineLaTeX, preprocessLaTeX } from './latex-protection';
// Modality file validation utilities
export {
isFileTypeSupportedByModel,
filterFilesByModalities,
generateModalityErrorMessage,
generateModalityAwareAcceptString,
type ModalityCapabilities
} from './modality-file-validation';
// Model name utilities
export { normalizeModelName, isValidModelName } from './model-names';
// Portal utilities
export { portalToBody } from './portal-to-body';
// Precision utilities
export { normalizeFloatingPoint, normalizeNumber } from './precision';
// Syntax highlighting utilities
export { getLanguageFromFilename } from './syntax-highlight-language';
// Text file utilities
export { isTextFileByName, readFileAsText, isLikelyTextFile } from './text-files';

View File

@ -3,8 +3,7 @@
* Ensures only compatible file types are processed based on model capabilities
*/
import { getFileTypeCategory } from '$lib/utils/file-type';
import { supportsVision, supportsAudio } from '$lib/stores/server.svelte';
import { getFileTypeCategory } from '$lib/utils';
import {
FileExtensionAudio,
FileExtensionImage,
@ -17,13 +16,24 @@ import {
FileTypeCategory
} from '$lib/enums';
/** Modality capabilities for file validation */
export interface ModalityCapabilities {
hasVision: boolean;
hasAudio: boolean;
}
/**
* Check if a file type is supported by the current model's modalities
* Check if a file type is supported by the given modalities
* @param filename - The filename to check
* @param mimeType - The MIME type of the file
* @returns true if the file type is supported by the current model
* @param capabilities - The modality capabilities to check against
* @returns true if the file type is supported
*/
export function isFileTypeSupportedByModel(filename: string, mimeType?: string): boolean {
export function isFileTypeSupportedByModel(
filename: string,
mimeType: string | undefined,
capabilities: ModalityCapabilities
): boolean {
const category = mimeType ? getFileTypeCategory(mimeType) : null;
// If we can't determine the category from MIME type, fall back to general support check
@ -44,11 +54,11 @@ export function isFileTypeSupportedByModel(filename: string, mimeType?: string):
case FileTypeCategory.IMAGE:
// Images require vision support
return supportsVision();
return capabilities.hasVision;
case FileTypeCategory.AUDIO:
// Audio files require audio support
return supportsAudio();
return capabilities.hasAudio;
default:
// Unknown categories - be conservative and allow
@ -59,9 +69,13 @@ export function isFileTypeSupportedByModel(filename: string, mimeType?: string):
/**
* Filter files based on model modalities and return supported/unsupported lists
* @param files - Array of files to filter
* @param capabilities - The modality capabilities to check against
* @returns Object with supportedFiles and unsupportedFiles arrays
*/
export function filterFilesByModalities(files: File[]): {
export function filterFilesByModalities(
files: File[],
capabilities: ModalityCapabilities
): {
supportedFiles: File[];
unsupportedFiles: File[];
modalityReasons: Record<string, string>;
@ -70,8 +84,7 @@ export function filterFilesByModalities(files: File[]): {
const unsupportedFiles: File[] = [];
const modalityReasons: Record<string, string> = {};
const hasVision = supportsVision();
const hasAudio = supportsAudio();
const { hasVision, hasAudio } = capabilities;
for (const file of files) {
const category = getFileTypeCategory(file.type);
@ -119,16 +132,17 @@ export function filterFilesByModalities(files: File[]): {
* Generate a user-friendly error message for unsupported files
* @param unsupportedFiles - Array of unsupported files
* @param modalityReasons - Reasons why files are unsupported
* @param capabilities - The modality capabilities to check against
* @returns Formatted error message
*/
export function generateModalityErrorMessage(
unsupportedFiles: File[],
modalityReasons: Record<string, string>
modalityReasons: Record<string, string>,
capabilities: ModalityCapabilities
): string {
if (unsupportedFiles.length === 0) return '';
const hasVision = supportsVision();
const hasAudio = supportsAudio();
const { hasVision, hasAudio } = capabilities;
let message = '';
@ -152,12 +166,12 @@ export function generateModalityErrorMessage(
}
/**
* Generate file input accept string based on current model modalities
* Generate file input accept string based on model modalities
* @param capabilities - The modality capabilities to check against
* @returns Accept string for HTML file input element
*/
export function generateModalityAwareAcceptString(): string {
const hasVision = supportsVision();
const hasAudio = supportsAudio();
export function generateModalityAwareAcceptString(capabilities: ModalityCapabilities): string {
const { hasVision, hasAudio } = capabilities;
const acceptedExtensions: string[] = [];
const acceptedMimeTypes: string[] = [];

View File

@ -2,12 +2,19 @@ import { describe, expect, it } from 'vitest';
import { isValidModelName, normalizeModelName } from './model-names';
describe('normalizeModelName', () => {
it('extracts filename from forward slash path', () => {
expect(normalizeModelName('models/model-name-1')).toBe('model-name-1');
expect(normalizeModelName('path/to/model/model-name-2')).toBe('model-name-2');
it('preserves Hugging Face org/model format (single slash)', () => {
// Single slash is treated as Hugging Face format and preserved
expect(normalizeModelName('meta-llama/Llama-3.1-8B')).toBe('meta-llama/Llama-3.1-8B');
expect(normalizeModelName('models/model-name-1')).toBe('models/model-name-1');
});
it('extracts filename from backslash path', () => {
it('extracts filename from multi-segment paths', () => {
// Multiple slashes -> extract just the filename
expect(normalizeModelName('path/to/model/model-name-2')).toBe('model-name-2');
expect(normalizeModelName('/absolute/path/to/model')).toBe('model');
});
it('extracts filename from backslash paths', () => {
expect(normalizeModelName('C\\Models\\model-name-1')).toBe('model-name-1');
expect(normalizeModelName('path\\to\\model\\model-name-2')).toBe('model-name-2');
});

View File

@ -2,10 +2,11 @@ import { isSvgMimeType, svgBase64UrlToPngDataURL } from './svg-to-png';
import { isTextFileByName } from './text-files';
import { isWebpMimeType, webpBase64UrlToPngDataURL } from './webp-to-png';
import { FileTypeCategory } from '$lib/enums';
import { getFileTypeCategory } from '$lib/utils/file-type';
import { supportsVision } from '$lib/stores/server.svelte';
import { modelsStore } from '$lib/stores/models.svelte';
import { settingsStore } from '$lib/stores/settings.svelte';
import { toast } from 'svelte-sonner';
import { getFileTypeCategory } from '$lib/utils';
import { convertPDFToText } from './pdf-processing';
/**
* Read a file as a data URL (base64 encoded)
@ -47,7 +48,10 @@ function readFileAsUTF8(file: File): Promise<string> {
* @param files - Array of File objects to process
* @returns Promise resolving to array of ChatUploadedFile objects
*/
export async function processFilesToChatUploaded(files: File[]): Promise<ChatUploadedFile[]> {
export async function processFilesToChatUploaded(
files: File[],
activeModelId?: string
): Promise<ChatUploadedFile[]> {
const results: ChatUploadedFile[] = [];
for (const file of files) {
@ -92,11 +96,19 @@ export async function processFilesToChatUploaded(files: File[]): Promise<ChatUpl
results.push(base);
}
} else if (getFileTypeCategory(file.type) === FileTypeCategory.PDF) {
// PDFs handled later when building extras; keep metadata only
results.push(base);
// Extract text content from PDF for preview
try {
const textContent = await convertPDFToText(file);
results.push({ ...base, textContent });
} catch (err) {
console.warn('Failed to extract text from PDF, adding without content:', err);
results.push(base);
}
// Show suggestion toast if vision model is available but PDF as image is disabled
const hasVisionSupport = supportsVision();
const hasVisionSupport = activeModelId
? modelsStore.modelSupportsVision(activeModelId)
: false;
const currentConfig = settingsStore.config;
if (hasVisionSupport && !currentConfig.pdfAsImage) {
toast.info(`You can enable parsing PDF as images with vision models.`, {

View File

@ -0,0 +1,145 @@
/**
* Maps file extensions to highlight.js language identifiers
*/
export function getLanguageFromFilename(filename: string): string {
const extension = filename.toLowerCase().substring(filename.lastIndexOf('.'));
switch (extension) {
// JavaScript / TypeScript
case '.js':
case '.mjs':
case '.cjs':
return 'javascript';
case '.ts':
case '.mts':
case '.cts':
return 'typescript';
case '.jsx':
return 'javascript';
case '.tsx':
return 'typescript';
// Web
case '.html':
case '.htm':
return 'html';
case '.css':
return 'css';
case '.scss':
return 'scss';
case '.less':
return 'less';
case '.vue':
return 'html';
case '.svelte':
return 'html';
// Data formats
case '.json':
return 'json';
case '.xml':
return 'xml';
case '.yaml':
case '.yml':
return 'yaml';
case '.toml':
return 'ini';
case '.csv':
return 'plaintext';
// Programming languages
case '.py':
return 'python';
case '.java':
return 'java';
case '.kt':
case '.kts':
return 'kotlin';
case '.scala':
return 'scala';
case '.cpp':
case '.cc':
case '.cxx':
case '.c++':
return 'cpp';
case '.c':
return 'c';
case '.h':
case '.hpp':
return 'cpp';
case '.cs':
return 'csharp';
case '.go':
return 'go';
case '.rs':
return 'rust';
case '.rb':
return 'ruby';
case '.php':
return 'php';
case '.swift':
return 'swift';
case '.dart':
return 'dart';
case '.r':
return 'r';
case '.lua':
return 'lua';
case '.pl':
case '.pm':
return 'perl';
// Shell
case '.sh':
case '.bash':
case '.zsh':
return 'bash';
case '.bat':
case '.cmd':
return 'dos';
case '.ps1':
return 'powershell';
// Database
case '.sql':
return 'sql';
// Markup / Documentation
case '.md':
case '.markdown':
return 'markdown';
case '.tex':
case '.latex':
return 'latex';
case '.adoc':
case '.asciidoc':
return 'asciidoc';
// Config
case '.ini':
case '.cfg':
case '.conf':
return 'ini';
case '.dockerfile':
return 'dockerfile';
case '.nginx':
return 'nginx';
// Other
case '.graphql':
case '.gql':
return 'graphql';
case '.proto':
return 'protobuf';
case '.diff':
case '.patch':
return 'diff';
case '.log':
return 'plaintext';
case '.txt':
return 'plaintext';
default:
return 'plaintext';
}
}

View File

@ -1,18 +1,19 @@
<script lang="ts">
import '../app.css';
import { page } from '$app/state';
import { untrack } from 'svelte';
import { ChatSidebar, DialogConversationTitleUpdate } from '$lib/components/app';
import {
activeMessages,
isLoading,
setTitleUpdateConfirmationCallback
} from '$lib/stores/chat.svelte';
import { isLoading } from '$lib/stores/chat.svelte';
import { conversationsStore, activeMessages } from '$lib/stores/conversations.svelte';
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
import { serverStore } from '$lib/stores/server.svelte';
import * as Tooltip from '$lib/components/ui/tooltip';
import { isRouterMode, serverStore } from '$lib/stores/server.svelte';
import { config, settingsStore } from '$lib/stores/settings.svelte';
import { ModeWatcher } from 'mode-watcher';
import { Toaster } from 'svelte-sonner';
import { goto } from '$app/navigation';
import { modelsStore } from '$lib/stores/models.svelte';
import { TOOLTIP_DELAY_DURATION } from '$lib/constants/tooltip-config';
let { children } = $props();
@ -90,20 +91,42 @@
}
});
// Initialize server properties on app load
// Initialize server properties on app load (run once)
$effect(() => {
serverStore.fetchServerProps();
// Only fetch if we don't already have props
if (!serverStore.props) {
untrack(() => {
serverStore.fetch();
});
}
});
// Sync settings when server props are loaded
$effect(() => {
const serverProps = serverStore.serverProps;
const serverProps = serverStore.props;
if (serverProps?.default_generation_settings?.params) {
settingsStore.syncWithServerDefaults();
}
});
// Fetch router models when in router mode (for status and modalities)
// Wait for models to be loaded first, run only once
let routerModelsFetched = false;
$effect(() => {
const isRouter = isRouterMode();
const modelsCount = modelsStore.models.length;
// Only fetch router models once when we have models loaded and in router mode
if (isRouter && modelsCount > 0 && !routerModelsFetched) {
routerModelsFetched = true;
untrack(() => {
modelsStore.fetchRouterModels();
});
}
});
// Monitor API key changes and redirect to error page if removed or changed when required
$effect(() => {
const apiKey = config().apiKey;
@ -135,46 +158,50 @@
// Set up title update confirmation callback
$effect(() => {
setTitleUpdateConfirmationCallback(async (currentTitle: string, newTitle: string) => {
return new Promise<boolean>((resolve) => {
titleUpdateCurrentTitle = currentTitle;
titleUpdateNewTitle = newTitle;
titleUpdateResolve = resolve;
titleUpdateDialogOpen = true;
});
});
conversationsStore.setTitleUpdateConfirmationCallback(
async (currentTitle: string, newTitle: string) => {
return new Promise<boolean>((resolve) => {
titleUpdateCurrentTitle = currentTitle;
titleUpdateNewTitle = newTitle;
titleUpdateResolve = resolve;
titleUpdateDialogOpen = true;
});
}
);
});
</script>
<ModeWatcher />
<Tooltip.Provider delayDuration={TOOLTIP_DELAY_DURATION}>
<ModeWatcher />
<Toaster richColors />
<Toaster richColors />
<DialogConversationTitleUpdate
bind:open={titleUpdateDialogOpen}
currentTitle={titleUpdateCurrentTitle}
newTitle={titleUpdateNewTitle}
onConfirm={handleTitleUpdateConfirm}
onCancel={handleTitleUpdateCancel}
/>
<DialogConversationTitleUpdate
bind:open={titleUpdateDialogOpen}
currentTitle={titleUpdateCurrentTitle}
newTitle={titleUpdateNewTitle}
onConfirm={handleTitleUpdateConfirm}
onCancel={handleTitleUpdateCancel}
/>
<Sidebar.Provider bind:open={sidebarOpen}>
<div class="flex h-screen w-full" style:height="{innerHeight}px">
<Sidebar.Root class="h-full">
<ChatSidebar bind:this={chatSidebar} />
</Sidebar.Root>
<Sidebar.Provider bind:open={sidebarOpen}>
<div class="flex h-screen w-full" style:height="{innerHeight}px">
<Sidebar.Root class="h-full">
<ChatSidebar bind:this={chatSidebar} />
</Sidebar.Root>
<Sidebar.Trigger
class="transition-left absolute left-0 z-[900] h-8 w-8 duration-200 ease-linear {sidebarOpen
? 'md:left-[var(--sidebar-width)]'
: ''}"
style="translate: 1rem 1rem;"
/>
<Sidebar.Trigger
class="transition-left absolute left-0 z-[900] h-8 w-8 duration-200 ease-linear {sidebarOpen
? 'md:left-[var(--sidebar-width)]'
: ''}"
style="translate: 1rem 1rem;"
/>
<Sidebar.Inset class="flex flex-1 flex-col overflow-hidden">
{@render children?.()}
</Sidebar.Inset>
</div>
</Sidebar.Provider>
<Sidebar.Inset class="flex flex-1 flex-col overflow-hidden">
{@render children?.()}
</Sidebar.Inset>
</div>
</Sidebar.Provider>
</Tooltip.Provider>
<svelte:window onkeydown={handleKeydown} bind:innerHeight />

View File

@ -1,21 +1,79 @@
<script lang="ts">
import { ChatScreen } from '$lib/components/app';
import { chatStore, isInitialized } from '$lib/stores/chat.svelte';
import { ChatScreen, DialogModelNotAvailable } from '$lib/components/app';
import { chatStore } from '$lib/stores/chat.svelte';
import { conversationsStore, isConversationsInitialized } from '$lib/stores/conversations.svelte';
import { modelsStore, modelOptions } from '$lib/stores/models.svelte';
import { onMount } from 'svelte';
import { page } from '$app/state';
import { replaceState } from '$app/navigation';
let qParam = $derived(page.url.searchParams.get('q'));
let modelParam = $derived(page.url.searchParams.get('model'));
let newChatParam = $derived(page.url.searchParams.get('new_chat'));
onMount(async () => {
if (!isInitialized) {
await chatStore.initialize();
// Dialog state for model not available error
let showModelNotAvailable = $state(false);
let requestedModelName = $state('');
let availableModelNames = $derived(modelOptions().map((m) => m.model));
/**
* Clear URL params after message is sent to prevent re-sending on refresh
*/
function clearUrlParams() {
const url = new URL(page.url);
url.searchParams.delete('q');
url.searchParams.delete('model');
url.searchParams.delete('new_chat');
replaceState(url.toString(), {});
}
async function handleUrlParams() {
await modelsStore.fetch();
if (modelParam) {
const model = modelsStore.findModelByName(modelParam);
if (model) {
try {
await modelsStore.selectModelById(model.id);
} catch (error) {
console.error('Failed to select model:', error);
requestedModelName = modelParam;
showModelNotAvailable = true;
return;
}
} else {
requestedModelName = modelParam;
showModelNotAvailable = true;
return;
}
}
chatStore.clearActiveConversation();
// Handle ?q= parameter - create new conversation and send message
if (qParam !== null) {
await chatStore.createConversation();
await conversationsStore.createConversation();
await chatStore.sendMessage(qParam);
clearUrlParams();
} else if (modelParam || newChatParam === 'true') {
clearUrlParams();
}
}
onMount(async () => {
if (!isConversationsInitialized()) {
await conversationsStore.initialize();
}
conversationsStore.clearActiveConversation();
chatStore.clearUIState();
// Handle URL params only if we have ?q= or ?model= or ?new_chat=true
if (qParam !== null || modelParam !== null || newChatParam === 'true') {
await handleUrlParams();
}
});
</script>
@ -25,3 +83,9 @@
</svelte:head>
<ChatScreen showCenteredEmpty={true} />
<DialogModelNotAvailable
bind:open={showModelNotAvailable}
modelName={requestedModelName}
availableModels={availableModelNames}
/>

View File

@ -1,5 +1,5 @@
import type { PageLoad } from './$types';
import { validateApiKey } from '$lib/utils/api-key-validation';
import { validateApiKey } from '$lib/utils';
export const load: PageLoad = async ({ fetch }) => {
await validateApiKey(fetch);

View File

@ -1,20 +1,78 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { goto, replaceState } from '$app/navigation';
import { page } from '$app/state';
import { afterNavigate } from '$app/navigation';
import { ChatScreen } from '$lib/components/app';
import { ChatScreen, DialogModelNotAvailable } from '$lib/components/app';
import { chatStore, isLoading } from '$lib/stores/chat.svelte';
import {
chatStore,
conversationsStore,
activeConversation,
isLoading,
stopGeneration,
activeMessages
} from '$lib/stores/chat.svelte';
import { selectModel, modelOptions, selectedModelId } from '$lib/stores/models.svelte';
} from '$lib/stores/conversations.svelte';
import { modelsStore, modelOptions, selectedModelId } from '$lib/stores/models.svelte';
let chatId = $derived(page.params.id);
let currentChatId: string | undefined = undefined;
// URL parameters for prompt and model selection
let qParam = $derived(page.url.searchParams.get('q'));
let modelParam = $derived(page.url.searchParams.get('model'));
// Dialog state for model not available error
let showModelNotAvailable = $state(false);
let requestedModelName = $state('');
let availableModelNames = $derived(modelOptions().map((m) => m.model));
// Track if URL params have been processed for this chat
let urlParamsProcessed = $state(false);
/**
* Clear URL params after message is sent to prevent re-sending on refresh
*/
function clearUrlParams() {
const url = new URL(page.url);
url.searchParams.delete('q');
url.searchParams.delete('model');
replaceState(url.toString(), {});
}
async function handleUrlParams() {
// Ensure models are loaded first
await modelsStore.fetch();
// Handle model parameter - select model if provided
if (modelParam) {
const model = modelsStore.findModelByName(modelParam);
if (model) {
try {
await modelsStore.selectModelById(model.id);
} catch (error) {
console.error('Failed to select model:', error);
requestedModelName = modelParam;
showModelNotAvailable = true;
return;
}
} else {
// Model not found - show error dialog
requestedModelName = modelParam;
showModelNotAvailable = true;
return;
}
}
// Handle ?q= parameter - send message in current conversation
if (qParam !== null) {
await chatStore.sendMessage(qParam);
// Clear URL params after message is sent
clearUrlParams();
} else if (modelParam) {
// Clear params even if no message was sent (just model selection)
clearUrlParams();
}
urlParamsProcessed = true;
}
async function selectModelFromLastAssistantResponse() {
const messages = activeMessages();
if (messages.length === 0) return;
@ -43,7 +101,7 @@
if (matchingModel) {
try {
await selectModel(matchingModel.id);
await modelsStore.selectModelById(matchingModel.id);
console.log(`Automatically loaded model: ${lastMessageWithModel.model} from last message`);
} catch (error) {
console.warn('Failed to automatically select model from last message:', error);
@ -60,16 +118,27 @@
$effect(() => {
if (chatId && chatId !== currentChatId) {
currentChatId = chatId;
urlParamsProcessed = false; // Reset for new chat
// Skip loading if this conversation is already active (e.g., just created)
if (activeConversation()?.id === chatId) {
// Still handle URL params even if conversation is active
if ((qParam !== null || modelParam !== null) && !urlParamsProcessed) {
handleUrlParams();
}
return;
}
(async () => {
const success = await chatStore.loadConversation(chatId);
const success = await conversationsStore.loadConversation(chatId);
if (success) {
chatStore.syncLoadingStateForChat(chatId);
if (!success) {
// Handle URL params after conversation is loaded
if ((qParam !== null || modelParam !== null) && !urlParamsProcessed) {
await handleUrlParams();
}
} else {
await goto('#/');
}
})();
@ -81,7 +150,7 @@
const handleBeforeUnload = () => {
if (isLoading()) {
console.log('Page unload detected while streaming - aborting stream');
stopGeneration();
chatStore.stopGeneration();
}
};
@ -99,3 +168,9 @@
</svelte:head>
<ChatScreen />
<DialogModelNotAvailable
bind:open={showModelNotAvailable}
modelName={requestedModelName}
availableModels={availableModelNames}
/>

Some files were not shown because too many files have changed in this diff Show More