Merge commit '2cd20b72ed3565ac6935911ca0d9b5d73ae70d0d' into concedo_experimental

# Conflicts:
#	CONTRIBUTING.md
#	docs/backend/CANN.md
#	docs/backend/SYCL.md
#	docs/backend/snapdragon/README.md
#	docs/backend/snapdragon/windows.md
#	docs/build.md
#	docs/multimodal/MobileVLM.md
#	docs/ops.md
#	docs/ops/WebGPU.csv
#	examples/debug/README.md
#	examples/llama.vim
#	examples/model-conversion/README.md
#	examples/sycl/README.md
#	ggml/src/ggml-cpu/amx/mmq.cpp
#	ggml/src/ggml-cpu/arch/x86/repack.cpp
#	ggml/src/ggml-hexagon/ggml-hexagon.cpp
#	ggml/src/ggml-hexagon/htp-drv.cpp
#	ggml/src/ggml-hexagon/htp/flash-attn-ops.c
#	ggml/src/ggml-hexagon/htp/hvx-base.h
#	ggml/src/ggml-hexagon/htp/hvx-copy.h
#	ggml/src/ggml-hexagon/htp/hvx-inverse.h
#	ggml/src/ggml-hexagon/htp/hvx-reduce.h
#	ggml/src/ggml-hexagon/htp/matmul-ops.c
#	ggml/src/ggml-hexagon/htp/rope-ops.c
#	ggml/src/ggml-hexagon/htp/worker-pool.c
#	ggml/src/ggml-opencl/ggml-opencl.cpp
#	ggml/src/ggml-opencl/kernels/cpy.cl
#	ggml/src/ggml-sycl/common.hpp
#	ggml/src/ggml-sycl/quants.hpp
#	ggml/src/ggml-sycl/softmax.cpp
#	ggml/src/ggml-vulkan/CMakeLists.txt
#	ggml/src/ggml-webgpu/ggml-webgpu-shader-lib.hpp
#	ggml/src/ggml-webgpu/ggml-webgpu.cpp
#	scripts/pr2wt.sh
#	scripts/server-bench.py
#	scripts/snapdragon/windows/run-cli.ps1
#	tests/test-alloc.cpp
#	tests/test-backend-ops.cpp
#	tests/test-chat.cpp
#	tools/cli/cli.cpp
#	tools/completion/README.md
#	tools/cvector-generator/cvector-generator.cpp
#	tools/imatrix/README.md
#	tools/perplexity/README.md
#	tools/server/public_simplechat/readme.md
#	tools/server/tests/README.md
This commit is contained in:
Concedo
2026-03-10 22:11:08 +08:00
107 changed files with 1037 additions and 314 deletions
+1 -1
View File
@@ -68,7 +68,7 @@
#define TN_POS_EMBD "%s.position_embd.weight"
#define TN_CLASS_EMBD "v.class_embd"
#define TN_PATCH_EMBD "v.patch_embd.weight" // not rename tensor with ".0" postfix for backwrad compat
#define TN_PATCH_EMBD "v.patch_embd.weight" // not rename tensor with ".0" postfix for backward compat
#define TN_PATCH_EMBD_1 "v.patch_embd.weight.1"
#define TN_PATCH_BIAS "v.patch_embd.bias"
#define TN_NORM_EMBD "v.norm_embd.%s"
+2 -2
View File
@@ -46,7 +46,7 @@ struct clip_hparams {
float image_std[3];
// for models using dynamic image size, we need to have a smaller image size to warmup
// otherwise, user will get OOM everytime they load the model
// otherwise, user will get OOM every time they load the model
int32_t warmup_image_size = 0;
int32_t warmup_audio_size = 3000;
@@ -221,7 +221,7 @@ struct clip_model {
// embeddings
ggml_tensor * class_embedding = nullptr;
ggml_tensor * patch_embeddings_0 = nullptr;
ggml_tensor * patch_embeddings_1 = nullptr; // second Conv2D kernel when we decouple Conv3D along temproal dimension (Qwen2VL)
ggml_tensor * patch_embeddings_1 = nullptr; // second Conv2D kernel when we decouple Conv3D along temporal dimension (Qwen2VL)
ggml_tensor * patch_bias = nullptr;
ggml_tensor * position_embeddings = nullptr;
ggml_tensor * norm_embd_w = nullptr;
+1 -1
View File
@@ -2491,7 +2491,7 @@ static void normalize_image_u8_to_f32(const clip_image_u8 & src, clip_image_f32
}
}
// set of tools to manupulate images
// set of tools to manipulate images
// in the future, we can have HW acceleration by allowing this struct to access 3rd party lib like imagick or opencv
struct img_tool {
enum resize_algo {
@@ -186,7 +186,7 @@ def trunc_normal_tf_(
best when :math:`a \\leq \text{mean} \\leq b`.
NOTE: this 'tf' variant behaves closer to Tensorflow / JAX impl where the
bounds [a, b] are applied when sampling the normal distribution with mean=0, std=1.0
and the result is subsquently scaled and shifted by the mean and std args.
and the result is subsequently scaled and shifted by the mean and std args.
Args:
tensor: an n-dimensional `torch.Tensor`
mean: the mean of the normal distribution
+1 -1
View File
@@ -560,7 +560,7 @@ bool mtmd_audio_preprocessor_whisper::preprocess(const float * s
for (size_t off = 0; off < (size_t) out_full.n_len; off += frames_per_chunk) {
int n_len = std::min(frames_per_chunk, (size_t) out_full.n_len - off);
if ((size_t) n_len < frames_per_chunk) {
break; // last uncomplete chunk will always be a padded chunk, safe to ignore
break; // last incomplete chunk will always be a padded chunk, safe to ignore
}
mtmd_audio_mel out_chunk;
+1 -1
View File
@@ -100,7 +100,7 @@ Examples:
## Memory/Disk Requirements
When running the larger models, make sure you have enough disk space to store all the intermediate files.
As the models are currently fully loaded into memory, you will need adequate disk space to save them and sufficient RAM to load them. At the moment, memory and disk requirements are the same. For exmaple (Llama 3.1):
As the models are currently fully loaded into memory, you will need adequate disk space to save them and sufficient RAM to load them. At the moment, memory and disk requirements are the same. For example (Llama 3.1):
| Model | Original size | Quantized size (Q4_K_M) |
| ----: | ------------: | ----------------------: |
Binary file not shown.
+3 -3
View File
@@ -36,7 +36,7 @@
const params = signal({
n_predict: 358, // 358 is a nice number
temperature: 0.8, // adapt all following parameters to optimized min-p requierements. If for non-english, set to 0.6 or lower
temperature: 0.8, // adapt all following parameters to optimized min-p requirements. If for non-english, set to 0.6 or lower
repeat_last_n: 0, // 0 = disable penalty, -1 = context size
repeat_penalty: 1.0, // 1.0 = disabled
dry_multiplier: 0.0, // 0.0 = disabled, 0.8 works well
@@ -108,7 +108,7 @@
let importedTemplates = local_storage_getDataAsObject('user_templates')
if (importedTemplates) {
// saved templates were successfuly imported.
// saved templates were successfully imported.
console.log('Processing saved templates and updating default template')
params.value = { ...params.value, image_data: [] };
@@ -129,7 +129,7 @@
}
function userTemplateResetToDefault() {
console.log('Reseting themplate to default')
console.log('Reseting template to default')
selectedUserTemplate.value.name = 'default';
selectedUserTemplate.value.data = savedUserTemplates.value['default'];
}
+1 -1
View File
@@ -63,7 +63,7 @@ export function trim_repeat_garbage_at_end(sIn, maxSubL=10, maxMatchLenThreshold
/**
* Simple minded logic to help remove repeating garbage at end of the string, till it cant.
* Simple minded logic to help remove repeating garbage at end of the string, till it can't.
* If its not able to trim, then it will try to skip a char at end and then trim, a few times.
* This ensures that even if there are multiple runs of garbage with different patterns, the
* logic still tries to munch through them.
+2 -2
View File
@@ -318,7 +318,7 @@ class SimpleChat {
}
/**
* Allow setting of system prompt, but only at begining.
* Allow setting of system prompt, but only at beginning.
* @param {string} sysPrompt
* @param {string} msgTag
*/
@@ -333,7 +333,7 @@ class SimpleChat {
console.error(`ERRR:SimpleChat:SC:${msgTag}:You need to specify system prompt before any user query, ignoring...`);
} else {
if (this.xchat[0].content !== sysPrompt) {
console.error(`ERRR:SimpleChat:SC:${msgTag}:You cant change system prompt, mid way through, ignoring...`);
console.error(`ERRR:SimpleChat:SC:${msgTag}:You can't change system prompt, mid way through, ignoring...`);
}
}
}
+2 -2
View File
@@ -44,7 +44,7 @@ export function el_create_button(id, callback, name=undefined, innerText=undefin
}
/**
* Create a para and set it up. Optionaly append it to a passed parent.
* Create a para and set it up. Optionally append it to a passed parent.
* @param {string} text
* @param {HTMLElement | undefined} elParent
* @param {string | undefined} id
@@ -111,7 +111,7 @@ export function el_creatediv_boolbutton(id, label, texts, defaultValue, cb, clas
/**
* Create a select ui element, with a set of options to select from.
* * options: an object which contains name-value pairs
* * defaultOption: the value whose name should be choosen, by default.
* * defaultOption: the value whose name should be chosen, by default.
* * cb : the call back returns the name string of the option selected.
*
* @param {string} id
@@ -8,6 +8,7 @@
tooltip: string;
variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link';
size?: 'default' | 'sm' | 'lg' | 'icon';
iconSize?: string;
class?: string;
disabled?: boolean;
onclick: () => void;
@@ -21,6 +22,7 @@
size = 'sm',
class: className = '',
disabled = false,
iconSize = 'h-3 w-3',
onclick,
'aria-label': ariaLabel
}: Props = $props();
@@ -38,7 +40,7 @@
>
{@const IconComponent = icon}
<IconComponent class="h-3 w-3" />
<IconComponent class={iconSize} />
</Button>
</Tooltip.Trigger>
@@ -1,6 +1,6 @@
<script lang="ts">
import { ModelModality } from '$lib/enums';
import { MODALITY_ICONS, MODALITY_LABELS } from '$lib/constants/icons';
import { MODALITY_ICONS, MODALITY_LABELS } from '$lib/constants';
import { cn } from '$lib/components/ui/utils';
type DisplayableModality = ModelModality.VISION | ModelModality.AUDIO;
@@ -5,9 +5,11 @@
ChatFormFileInputInvisible,
ChatFormTextarea
} from '$lib/components/app';
import { INPUT_CLASSES } from '$lib/constants/css-classes';
import { SETTING_CONFIG_DEFAULT } from '$lib/constants/settings-config';
import { CLIPBOARD_CONTENT_QUOTE_PREFIX } from '$lib/constants/chat-form';
import {
CLIPBOARD_CONTENT_QUOTE_PREFIX,
INPUT_CLASSES,
SETTING_CONFIG_DEFAULT
} from '$lib/constants';
import { KeyboardKey, MimeTypeText } from '$lib/enums';
import { config } from '$lib/stores/settings.svelte';
import { modelOptions, selectedModelId } from '$lib/stores/models.svelte';
@@ -4,8 +4,7 @@
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 { FILE_TYPE_ICONS } from '$lib/constants/icons';
import { TOOLTIP_DELAY_DURATION } from '$lib/constants/tooltip-config';
import { FILE_TYPE_ICONS, TOOLTIP_DELAY_DURATION } from '$lib/constants';
interface Props {
class?: string;
@@ -11,7 +11,7 @@
import { getFileTypeCategory } from '$lib/utils';
import { config } from '$lib/stores/settings.svelte';
import { modelsStore, modelOptions, selectedModelId } from '$lib/stores/models.svelte';
import { isRouterMode } from '$lib/stores/server.svelte';
import { isRouterMode, serverError } from '$lib/stores/server.svelte';
import { chatStore } from '$lib/stores/chat.svelte';
import { activeMessages } from '$lib/stores/conversations.svelte';
@@ -45,6 +45,7 @@
let currentConfig = $derived(config());
let isRouter = $derived(isRouterMode());
let isOffline = $derived(!!serverError());
let conversationModel = $derived(
chatStore.getConversationModel(activeMessages() as DatabaseMessage[])
@@ -55,7 +56,10 @@
$effect(() => {
if (conversationModel && conversationModel !== previousConversationModel) {
previousConversationModel = conversationModel;
modelsStore.selectModelByName(conversationModel);
if (!isRouter || modelsStore.isModelLoaded(conversationModel)) {
modelsStore.selectModelByName(conversationModel);
}
}
});
@@ -168,9 +172,9 @@
<div class="ml-auto flex items-center gap-1.5">
<ModelsSelector
{disabled}
bind:this={selectorModelRef}
currentModel={conversationModel}
disabled={disabled || isOffline}
forceForegroundText={true}
useGlobalSelection={true}
/>
@@ -5,7 +5,7 @@
import { chatStore, pendingEditMessageId } from '$lib/stores/chat.svelte';
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { DatabaseService } from '$lib/services';
import { SYSTEM_MESSAGE_PLACEHOLDER } from '$lib/constants/ui';
import { SYSTEM_MESSAGE_PLACEHOLDER } from '$lib/constants';
import { MessageRole } from '$lib/enums';
import {
ChatMessageAssistant,
@@ -16,14 +16,13 @@
import { Check, X } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
import { Checkbox } from '$lib/components/ui/checkbox';
import { INPUT_CLASSES } from '$lib/constants/css-classes';
import { INPUT_CLASSES, REASONING_TAGS } from '$lib/constants';
import { MessageRole, KeyboardKey } from '$lib/enums';
import Label from '$lib/components/ui/label/label.svelte';
import { config } from '$lib/stores/settings.svelte';
import { isRouterMode } from '$lib/stores/server.svelte';
import { modelsStore } from '$lib/stores/models.svelte';
import { ServerModelStatus } from '$lib/enums';
import { REASONING_TAGS } from '$lib/constants/agentic';
interface Props {
class?: string;
@@ -4,7 +4,7 @@
import * as Tooltip from '$lib/components/ui/tooltip';
import { ChatMessageStatsView } from '$lib/enums';
import { formatPerformanceTime } from '$lib/utils';
import { MS_PER_SECOND, DEFAULT_PERFORMANCE_TIME } from '$lib/constants/formatters';
import { MS_PER_SECOND, DEFAULT_PERFORMANCE_TIME } from '$lib/constants';
interface Props {
predictedTokens?: number;
@@ -4,7 +4,7 @@
import { Button } from '$lib/components/ui/button';
import { MarkdownContent } from '$lib/components/app';
import { getMessageEditContext } from '$lib/contexts';
import { INPUT_CLASSES } from '$lib/constants/css-classes';
import { INPUT_CLASSES } from '$lib/constants';
import { config } from '$lib/stores/settings.svelte';
import { isIMEComposing } from '$lib/utils';
import ChatMessageActions from './ChatMessageActions.svelte';
@@ -12,7 +12,7 @@
} from '$lib/components/app';
import * as Alert from '$lib/components/ui/alert';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import { INITIAL_SCROLL_DELAY } from '$lib/constants/auto-scroll';
import { INITIAL_SCROLL_DELAY } from '$lib/constants';
import { KeyboardKey } from '$lib/enums';
import { createAutoScrollController } from '$lib/hooks/use-auto-scroll.svelte';
import {
@@ -1,6 +1,6 @@
<script lang="ts">
import { untrack } from 'svelte';
import { PROCESSING_INFO_TIMEOUT } from '$lib/constants/processing-info';
import { PROCESSING_INFO_TIMEOUT } from '$lib/constants';
import { useProcessingState } from '$lib/hooks/use-processing-state.svelte';
import { chatStore, isLoading, isChatStreaming } from '$lib/stores/chat.svelte';
import { activeMessages, activeConversation } from '$lib/stores/conversations.svelte';
@@ -18,15 +18,16 @@
import { config, settingsStore } from '$lib/stores/settings.svelte';
import {
SETTINGS_SECTION_TITLES,
type SettingsSectionTitle
} from '$lib/constants/settings-sections';
type SettingsSectionTitle,
NUMERIC_FIELDS,
POSITIVE_INTEGER_FIELDS,
SETTINGS_COLOR_MODES_CONFIG,
SETTINGS_KEYS
} from '$lib/constants';
import { setMode } from 'mode-watcher';
import { ColorMode } from '$lib/enums/ui';
import { SettingsFieldType } from '$lib/enums/settings';
import type { Component } from 'svelte';
import { NUMERIC_FIELDS, POSITIVE_INTEGER_FIELDS } from '$lib/constants/settings-fields';
import { SETTINGS_COLOR_MODES_CONFIG } from '$lib/constants/settings-config';
import { SETTINGS_KEYS } from '$lib/constants/settings-keys';
interface Props {
onSave?: () => void;
@@ -133,6 +134,11 @@
key: SETTINGS_KEYS.AUTO_SHOW_SIDEBAR_ON_NEW_CHAT,
label: 'Auto-show sidebar on new chat',
type: SettingsFieldType.CHECKBOX
},
{
key: SETTINGS_KEYS.SHOW_RAW_MODEL_NAMES,
label: 'Show raw model names',
type: SettingsFieldType.CHECKBOX
}
]
},
@@ -5,8 +5,7 @@
import Label from '$lib/components/ui/label/label.svelte';
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 { SETTINGS_KEYS } from '$lib/constants/settings-keys';
import { SETTING_CONFIG_DEFAULT, SETTING_CONFIG_INFO, SETTINGS_KEYS } from '$lib/constants';
import { SettingsFieldType } from '$lib/enums/settings';
import { settingsStore } from '$lib/stores/settings.svelte';
import { ChatSettingsParameterSourceIndicator } from '$lib/components/app';
@@ -22,8 +22,9 @@
IMAGE_NOT_ERROR_BOUND_SELECTOR,
DATA_ERROR_BOUND_ATTR,
DATA_ERROR_HANDLED_ATTR,
BOOL_TRUE_STRING
} from '$lib/constants/markdown';
BOOL_TRUE_STRING,
SETTINGS_KEYS
} from '$lib/constants';
import { UrlPrefix } from '$lib/enums';
import { FileTypeText } from '$lib/enums/files';
import {
@@ -39,7 +40,6 @@
import { createAutoScrollController } from '$lib/hooks/use-auto-scroll.svelte';
import type { DatabaseMessageExtra } from '$lib/types/database';
import { config } from '$lib/stores/settings.svelte';
import { SETTINGS_KEYS } from '$lib/constants/settings-keys';
interface Props {
attachments?: DatabaseMessageExtra[];
@@ -1,7 +1,7 @@
<script lang="ts">
import * as Dialog from '$lib/components/ui/dialog';
import { ChatSettings } from '$lib/components/app';
import type { SettingsSectionTitle } from '$lib/constants/settings-sections';
import type { SettingsSectionTitle } from '$lib/constants';
interface Props {
onOpenChange?: (open: boolean) => void;
@@ -1,6 +1,7 @@
<script lang="ts">
import { Package } from '@lucide/svelte';
import { BadgeInfo, ActionIconCopyToClipboard } from '$lib/components/app';
import ModelId from './ModelId.svelte';
import { modelsStore } from '$lib/stores/models.svelte';
import { serverStore } from '$lib/stores/server.svelte';
import * as Tooltip from '$lib/components/ui/tooltip';
@@ -23,6 +24,7 @@
let model = $derived(modelProp || modelsStore.singleModelName);
let isModelMode = $derived(serverStore.isModelMode);
let shouldShow = $derived(model && (modelProp !== undefined || isModelMode));
</script>
{#snippet badgeContent()}
@@ -31,7 +33,9 @@
<Package class="h-3 w-3" />
{/snippet}
{model}
{#if model}
<ModelId modelId={model} />
{/if}
{#if showCopyIcon}
<ActionIconCopyToClipboard text={model || ''} ariaLabel="Copy model name" />
@@ -39,7 +43,7 @@
</BadgeInfo>
{/snippet}
{#if model && isModelMode}
{#if shouldShow}
{#if showTooltip}
<Tooltip.Root>
<Tooltip.Trigger>
@@ -0,0 +1,64 @@
<script lang="ts">
import { ModelsService } from '$lib/services/models.service';
import { config } from '$lib/stores/settings.svelte';
interface Props {
modelId: string;
showOrgName?: boolean;
showRaw?: boolean;
aliases?: string[];
tags?: string[];
class?: string;
}
let {
modelId,
showOrgName = false,
showRaw = undefined,
aliases,
tags,
class: className = ''
}: Props = $props();
const badgeClass =
'inline-flex w-fit shrink-0 items-center justify-center whitespace-nowrap rounded-md border border-border/50 px-1 py-0 text-[10px] font-mono bg-foreground/15 dark:bg-foreground/10 text-foreground [a&]:hover:bg-foreground/25';
const tagBadgeClass =
'inline-flex w-fit shrink-0 items-center justify-center whitespace-nowrap rounded-md border border-border/50 px-1 py-0 text-[10px] font-mono text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground';
let parsed = $derived(ModelsService.parseModelId(modelId));
let resolvedShowRaw = $derived(showRaw ?? (config().showRawModelNames as boolean) ?? false);
</script>
{#if resolvedShowRaw}
<span class="min-w-0 truncate font-medium {className}">{modelId}</span>
{:else}
<span class="flex min-w-0 flex-wrap items-center gap-1 {className}">
<span class="min-w-0 truncate font-medium">
{#if showOrgName}{parsed.orgName}/{/if}{parsed.modelName ?? modelId}
</span>
{#if parsed.params}
<span class={badgeClass}>
{parsed.params}{parsed.activatedParams ? `-${parsed.activatedParams}` : ''}
</span>
{/if}
{#if parsed.quantization}
<span class={badgeClass}>
{parsed.quantization}
</span>
{/if}
{#if aliases && aliases.length > 0}
{#each aliases as alias (alias)}
<span class={badgeClass}>{alias}</span>
{/each}
{/if}
{#if tags && tags.length > 0}
{#each tags as tag (tag)}
<span class={tagBadgeClass}>{tag}</span>
{/each}
{/if}
</span>
{/if}
@@ -1,6 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import { ChevronDown, Loader2, Package, Power } from '@lucide/svelte';
import { SvelteMap } from 'svelte/reactivity';
import { ChevronDown, Loader2, Package } from '@lucide/svelte';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import * as Tooltip from '$lib/components/ui/tooltip';
import { cn } from '$lib/components/ui/utils';
@@ -10,35 +11,33 @@
modelsLoading,
modelsUpdating,
selectedModelId,
routerModels,
singleModelName
} from '$lib/stores/models.svelte';
import { KeyboardKey, ServerModelStatus } from '$lib/enums';
import { KeyboardKey } from '$lib/enums';
import { isRouterMode } from '$lib/stores/server.svelte';
import {
DialogModelInformation,
DropdownMenuSearchable,
TruncatedText
ModelId,
ModelsSelectorOption
} from '$lib/components/app';
import type { ModelOption } from '$lib/types/models';
interface Props {
class?: string;
currentModel?: string | null;
/** 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) */
onModelChange?: (modelId: string, modelName: string) => Promise<boolean> | boolean | void;
useGlobalSelection?: boolean;
}
let {
class: className = '',
currentModel = null,
onModelChange,
disabled = false,
forceForegroundText = false,
onModelChange,
useGlobalSelection = false
}: Props = $props();
@@ -55,46 +54,109 @@
let isRouter = $derived(isRouterMode());
let serverModel = $derived(singleModelName());
// Reactive router models state - needed for proper reactivity of status checks
let currentRouterModels = $derived(routerModels());
let isHighlightedCurrentModelActive = $derived.by(() => {
if (!isRouter || !currentModel) return false;
function getModelStatus(modelId: string): ServerModelStatus | null {
const model = currentRouterModels.find((m) => m.id === modelId);
return (model?.status?.value as ServerModelStatus) ?? null;
}
const currentOption = options.find((option) => option.model === currentModel);
let isHighlightedCurrentModelActive = $derived(
!isRouter || !currentModel
? false
: (() => {
const currentOption = options.find((option) => option.model === currentModel);
return currentOption ? currentOption.id === activeId : false;
});
return currentOption ? currentOption.id === activeId : false;
})()
);
let isCurrentModelInCache = $derived(() => {
let isCurrentModelInCache = $derived.by(() => {
if (!isRouter || !currentModel) return true;
return options.some((option) => option.model === currentModel);
});
let isLoadingModel = $state(false);
let searchTerm = $state('');
let highlightedIndex = $state<number>(-1);
let filteredOptions: ModelOption[] = $derived(
(() => {
const term = searchTerm.trim().toLowerCase();
if (!term) return options;
let filteredOptions: ModelOption[] = $derived.by(() => {
const term = searchTerm.trim().toLowerCase();
if (!term) return options;
return options.filter(
(option) =>
option.model.toLowerCase().includes(term) || option.name?.toLowerCase().includes(term)
);
})()
);
return options.filter(
(option) =>
option.model.toLowerCase().includes(term) ||
option.name?.toLowerCase().includes(term) ||
option.aliases?.some((alias: string) => alias.toLowerCase().includes(term)) ||
option.tags?.some((tag: string) => tag.toLowerCase().includes(term))
);
});
let groupedFilteredOptions = $derived.by(() => {
const favIds = modelsStore.favouriteModelIds;
const result: {
orgName: string | null;
isFavouritesGroup: boolean;
isLoadedGroup: boolean;
items: { option: ModelOption; flatIndex: number }[];
}[] = [];
// Loaded models group (top)
const loadedItems: { option: ModelOption; flatIndex: number }[] = [];
for (let i = 0; i < filteredOptions.length; i++) {
if (modelsStore.isModelLoaded(filteredOptions[i].model)) {
loadedItems.push({ option: filteredOptions[i], flatIndex: i });
}
}
if (loadedItems.length > 0) {
result.push({
orgName: null,
isFavouritesGroup: false,
isLoadedGroup: true,
items: loadedItems
});
}
// Favourites group
const loadedModelIds = new Set(loadedItems.map((item) => item.option.model));
const favItems: { option: ModelOption; flatIndex: number }[] = [];
for (let i = 0; i < filteredOptions.length; i++) {
if (favIds.has(filteredOptions[i].model) && !loadedModelIds.has(filteredOptions[i].model)) {
favItems.push({ option: filteredOptions[i], flatIndex: i });
}
}
if (favItems.length > 0) {
result.push({
orgName: null,
isFavouritesGroup: true,
isLoadedGroup: false,
items: favItems
});
}
// Org groups (excluding loaded and favourites)
const orgGroups = new SvelteMap<string, { option: ModelOption; flatIndex: number }[]>();
for (let i = 0; i < filteredOptions.length; i++) {
const option = filteredOptions[i];
if (loadedModelIds.has(option.model) || favIds.has(option.model)) continue;
const orgName = option.parsedId?.orgName ?? null;
const key = orgName ?? '';
if (!orgGroups.has(key)) orgGroups.set(key, []);
orgGroups.get(key)!.push({ option, flatIndex: i });
}
for (const [orgName, items] of orgGroups) {
result.push({
orgName: orgName || null,
isFavouritesGroup: false,
isLoadedGroup: false,
items
});
}
return result;
});
// Reset highlighted index when search term changes
$effect(() => {
void searchTerm;
highlightedIndex = -1;
@@ -109,8 +171,6 @@
});
});
// Handle changes to the model selector dropdown or the model dialog, depending on if the server is in
// router mode or not.
function handleOpenChange(open: boolean) {
if (loading || updating) return;
@@ -142,6 +202,7 @@
if (event.key === KeyboardKey.ARROW_DOWN) {
event.preventDefault();
if (filteredOptions.length === 0) return;
if (highlightedIndex === -1 || highlightedIndex === filteredOptions.length - 1) {
@@ -151,6 +212,7 @@
}
} else if (event.key === KeyboardKey.ARROW_UP) {
event.preventDefault();
if (filteredOptions.length === 0) return;
if (highlightedIndex === -1 || highlightedIndex === 0) {
@@ -160,11 +222,12 @@
}
} else if (event.key === KeyboardKey.ENTER) {
event.preventDefault();
if (highlightedIndex >= 0 && highlightedIndex < filteredOptions.length) {
const option = filteredOptions[highlightedIndex];
handleSelect(option.id);
} else if (filteredOptions.length > 0) {
// No selection - highlight first option
highlightedIndex = 0;
}
}
@@ -177,31 +240,18 @@
let shouldCloseMenu = true;
if (onModelChange) {
// If callback provided, use it (for regenerate functionality)
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) {
handleOpenChange(false);
// Focus the chat textarea after model selection
requestAnimationFrame(() => {
const textarea = document.querySelector<HTMLTextAreaElement>(
'[data-slot="chat-form"] textarea'
@@ -209,32 +259,39 @@
textarea?.focus();
});
}
if (!onModelChange && isRouter && !modelsStore.isModelLoaded(option.model)) {
isLoadingModel = true;
modelsStore
.loadModel(option.model)
.catch((error) => console.error('Failed to load model:', error))
.finally(() => (isLoadingModel = false));
}
}
function getDisplayOption(): ModelOption | undefined {
if (!isRouter) {
if (serverModel) {
const displayModel = serverModel || currentModel;
if (displayModel) {
return {
id: 'current',
model: serverModel,
name: serverModel.split('/').pop() || serverModel,
capabilities: [] // Empty array for single model mode
id: serverModel ? 'current' : 'offline-current',
model: displayModel,
name: displayModel.split('/').pop() || displayModel,
capabilities: []
};
}
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()) {
if (!isCurrentModelInCache) {
return {
id: 'not-in-cache',
model: currentModel,
@@ -246,12 +303,10 @@
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);
}
// No selection - return undefined to show "Select model"
return undefined;
}
</script>
@@ -260,10 +315,25 @@
{#if loading && options.length === 0 && isRouter}
<div class="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 class="h-3.5 w-3.5 animate-spin" />
Loading models…
</div>
{:else if options.length === 0 && isRouter}
<p class="text-xs text-muted-foreground">No models available.</p>
{#if currentModel}
<span
class={cn(
'inline-flex items-center gap-1.5 rounded-sm bg-muted-foreground/10 px-1.5 py-1 text-xs text-muted-foreground',
className
)}
style="max-width: min(calc(100cqw - 9rem), 20rem)"
>
<Package class="h-3.5 w-3.5" />
<ModelId modelId={currentModel} class="min-w-0" showOrgName />
</span>
{:else}
<p class="text-xs text-muted-foreground">No models available.</p>
{/if}
{:else}
{@const selectedOption = getDisplayOption()}
@@ -280,7 +350,7 @@
type="button"
class={cn(
`inline-grid cursor-pointer grid-cols-[1fr_auto_1fr] items-center gap-1.5 rounded-sm bg-muted-foreground/10 px-1.5 py-1 text-xs transition hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60`,
!isCurrentModelInCache()
!isCurrentModelInCache
? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400'
: forceForegroundText
? 'text-foreground'
@@ -294,12 +364,21 @@
>
<Package class="h-3.5 w-3.5" />
<TruncatedText
text={selectedOption?.model || 'Select model'}
class="min-w-0 font-medium"
/>
{#if selectedOption}
<Tooltip.Root>
<Tooltip.Trigger class="min-w-0 overflow-hidden">
<ModelId modelId={selectedOption.model} class="min-w-0" showOrgName />
</Tooltip.Trigger>
{#if updating}
<Tooltip.Content>
<p class="font-mono">{selectedOption.model}</p>
</Tooltip.Content>
</Tooltip.Root>
{:else}
<span class="min-w-0 font-medium">Select model</span>
{/if}
{#if updating || isLoadingModel}
<Loader2 class="h-3 w-3.5 animate-spin" />
{:else}
<ChevronDown class="h-3 w-3.5" />
@@ -316,10 +395,10 @@
placeholder="Search models..."
onSearchKeyDown={handleSearchKeyDown}
emptyMessage="No models found."
isEmpty={filteredOptions.length === 0 && isCurrentModelInCache()}
isEmpty={filteredOptions.length === 0 && isCurrentModelInCache}
>
<div class="models-list">
{#if !isCurrentModelInCache() && currentModel}
{#if !isCurrentModelInCache && currentModel}
<!-- Show unavailable model as first option (disabled) -->
<button
type="button"
@@ -329,90 +408,54 @@
aria-disabled="true"
disabled
>
<span
class="min-w-0 flex-1 truncate text-left sm:overflow-visible sm:text-clip sm:whitespace-nowrap"
>
{selectedOption?.name || currentModel}
</span>
<ModelId modelId={currentModel} class="flex-1" showOrgName />
<span class="ml-2 text-xs whitespace-nowrap opacity-70">(not available)</span>
</button>
<div class="my-1 h-px bg-border"></div>
{/if}
{#if filteredOptions.length === 0}
<p class="px-4 py-3 text-sm text-muted-foreground">No models found.</p>
{/if}
{#each filteredOptions as option, index (option.id)}
{@const status = getModelStatus(option.model)}
{@const isLoaded = status === ServerModelStatus.LOADED}
{@const isLoading = status === ServerModelStatus.LOADING}
{@const isSelected = currentModel === option.model || activeId === option.id}
{@const isHighlighted = index === highlightedIndex}
<div
class={cn(
'group flex w-full items-center gap-2 rounded-sm p-2 text-left text-sm transition focus:outline-none',
'cursor-pointer hover:bg-muted focus:bg-muted',
isSelected || isHighlighted
? 'bg-accent text-accent-foreground'
: 'hover:bg-accent hover:text-accent-foreground',
isLoaded ? 'text-popover-foreground' : 'text-muted-foreground'
)}
role="option"
aria-selected={isSelected || isHighlighted}
tabindex="0"
onclick={() => handleSelect(option.id)}
onmouseenter={() => (highlightedIndex = index)}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleSelect(option.id);
}
}}
>
<span
class="min-w-0 flex-1 truncate text-left sm:overflow-visible sm:pr-2 sm:text-clip sm:whitespace-nowrap"
{#each groupedFilteredOptions as group (group.isLoadedGroup ? '__loaded__' : group.isFavouritesGroup ? '__favourites__' : group.orgName)}
{#if group.isLoadedGroup}
<p class="px-2 py-2 text-xs font-semibold text-muted-foreground/60 select-none">
Loaded models
</p>
{:else if group.isFavouritesGroup}
<p class="px-2 py-2 text-xs font-semibold text-muted-foreground/60 select-none">
Favourite models
</p>
{:else if group.orgName}
<p
class="px-2 py-2 text-xs font-semibold text-muted-foreground/60 select-none [&:not(:first-child)]:mt-2"
>
{option.model}
</span>
{group.orgName}
</p>
{/if}
<div class="flex w-6 shrink-0 justify-center">
{#if isLoading}
<Tooltip.Root>
<Tooltip.Trigger>
<Loader2 class="h-4 w-4 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 flex h-4 w-4 items-center justify-center"
onclick={(e) => {
e.stopPropagation();
modelsStore.unloadModel(option.model);
}}
>
<span
class="h-2 w-2 rounded-full bg-green-500 transition-opacity group-hover:opacity-0"
></span>
<Power
class="absolute 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="h-2 w-2 rounded-full bg-muted-foreground/50"></span>
{/if}
</div>
</div>
{#each group.items as { option, flatIndex } (group.isLoadedGroup ? `loaded-${option.id}` : group.isFavouritesGroup ? `fav-${option.id}` : option.id)}
{@const isSelected = currentModel === option.model || activeId === option.id}
{@const isHighlighted = flatIndex === highlightedIndex}
{@const isFav = modelsStore.favouriteModelIds.has(option.model)}
<ModelsSelectorOption
{option}
{isSelected}
{isHighlighted}
{isFav}
showOrgName={group.isFavouritesGroup || group.isLoadedGroup}
onSelect={handleSelect}
onMouseEnter={() => (highlightedIndex = flatIndex)}
onKeyDown={(e) => {
if (e.key === KeyboardKey.ENTER || e.key === KeyboardKey.SPACE) {
e.preventDefault();
handleSelect(option.id);
}
}}
/>
{/each}
{/each}
</div>
</DropdownMenuSearchable>
@@ -422,7 +465,7 @@
<button
class={cn(
`inline-flex cursor-pointer items-center gap-1.5 rounded-sm bg-muted-foreground/10 px-1.5 py-1 text-xs transition hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60`,
!isCurrentModelInCache()
!isCurrentModelInCache
? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400'
: forceForegroundText
? 'text-foreground'
@@ -437,7 +480,17 @@
>
<Package class="h-3.5 w-3.5" />
<TruncatedText text={selectedOption?.model || ''} class="min-w-0 font-medium" />
{#if selectedOption}
<Tooltip.Root>
<Tooltip.Trigger class="min-w-0 overflow-hidden">
<ModelId modelId={selectedOption.model} class="min-w-0" showOrgName />
</Tooltip.Trigger>
<Tooltip.Content>
<p class="font-mono">{selectedOption.model}</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
{#if updating}
<Loader2 class="h-3 w-3.5 animate-spin" />
@@ -0,0 +1,143 @@
<script lang="ts">
import { CircleAlert, Heart, HeartOff, Loader2, Power, PowerOff, RotateCw } from '@lucide/svelte';
import { cn } from '$lib/components/ui/utils';
import { ActionIcon, ModelId } from '$lib/components/app';
import type { ModelOption } from '$lib/types/models';
import { ServerModelStatus } from '$lib/enums';
import { modelsStore, routerModels } from '$lib/stores/models.svelte';
interface Props {
option: ModelOption;
isSelected: boolean;
isHighlighted: boolean;
isFav: boolean;
showOrgName?: boolean;
onSelect: (modelId: string) => void;
onMouseEnter: () => void;
onKeyDown: (e: KeyboardEvent) => void;
}
let {
option,
isSelected,
isHighlighted,
isFav,
showOrgName = false,
onSelect,
onMouseEnter,
onKeyDown
}: Props = $props();
let currentRouterModels = $derived(routerModels());
let serverStatus = $derived.by(() => {
const model = currentRouterModels.find((m) => m.id === option.model);
return (model?.status?.value as ServerModelStatus) ?? null;
});
let isOperationInProgress = $derived(modelsStore.isModelOperationInProgress(option.model));
let isFailed = $derived(serverStatus === ServerModelStatus.FAILED);
let isLoaded = $derived(serverStatus === ServerModelStatus.LOADED && !isOperationInProgress);
let isLoading = $derived(serverStatus === ServerModelStatus.LOADING || isOperationInProgress);
</script>
<div
class={cn(
'group flex w-full items-center gap-2 rounded-sm p-2 text-left text-sm transition focus:outline-none',
'cursor-pointer hover:bg-muted focus:bg-muted',
isSelected || isHighlighted
? 'bg-accent text-accent-foreground'
: 'hover:bg-accent hover:text-accent-foreground',
isLoaded ? 'text-popover-foreground' : 'text-muted-foreground'
)}
role="option"
aria-selected={isSelected || isHighlighted}
tabindex="0"
onclick={() => onSelect(option.id)}
onmouseenter={onMouseEnter}
onkeydown={onKeyDown}
>
<ModelId
modelId={option.model}
{showOrgName}
aliases={option.aliases}
tags={option.tags}
class="flex-1"
/>
<div class="flex shrink-0 items-center gap-2.5">
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="pointer-events-none flex w-4 items-center justify-center pl-2 opacity-0 group-hover:pointer-events-auto group-hover:opacity-100"
onclick={(e) => e.stopPropagation()}
>
{#if isFav}
<ActionIcon
iconSize="h-2.5 w-2.5"
icon={HeartOff}
tooltip="Remove from favourites"
class="h-3 w-3 hover:text-foreground"
onclick={() => modelsStore.toggleFavourite(option.model)}
/>
{:else}
<ActionIcon
iconSize="h-2.5 w-2.5"
icon={Heart}
tooltip="Add to favourites"
class="h-3 w-3 hover:text-foreground"
onclick={() => modelsStore.toggleFavourite(option.model)}
/>
{/if}
</div>
{#if isLoading}
<Loader2 class="h-4 w-4 animate-spin text-muted-foreground" />
{:else if isFailed}
<div class="flex w-4 items-center justify-center">
<CircleAlert class="h-3.5 w-3.5 text-red-500 group-hover:hidden" />
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="hidden group-hover:flex" onclick={(e) => e.stopPropagation()}>
<ActionIcon
iconSize="h-2.5 w-2.5"
icon={RotateCw}
tooltip="Retry loading model"
class="h-3 w-3 text-red-500 hover:text-foreground"
onclick={() => modelsStore.loadModel(option.model)}
/>
</div>
</div>
{:else if isLoaded}
<div class="flex w-4 items-center justify-center">
<span class="h-2 w-2 rounded-full bg-green-500 group-hover:hidden"></span>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="hidden group-hover:flex" onclick={(e) => e.stopPropagation()}>
<ActionIcon
iconSize="h-2.5 w-2.5"
icon={PowerOff}
tooltip="Unload model"
class="h-3 w-3 text-red-500 hover:text-red-600"
onclick={() => modelsStore.unloadModel(option.model)}
/>
</div>
</div>
{:else}
<div class="flex w-4 items-center justify-center">
<span class="h-2 w-2 rounded-full bg-muted-foreground/50 group-hover:hidden"></span>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="hidden group-hover:flex" onclick={(e) => e.stopPropagation()}>
<ActionIcon
iconSize="h-2.5 w-2.5"
icon={Power}
tooltip="Load model"
class="h-3 w-3"
onclick={() => modelsStore.loadModel(option.model)}
/>
</div>
</div>
{/if}
</div>
</div>
@@ -71,3 +71,5 @@ export { default as ModelsSelector } from './ModelsSelector.svelte';
* ```
*/
export { default as ModelBadge } from './ModelBadge.svelte';
export { default as ModelId } from './ModelId.svelte';
export { default as ModelsSelectorOption } from './ModelsSelectorOption.svelte';
@@ -8,6 +8,8 @@
default: 'bg-primary text-primary-foreground [a&]:hover:bg-primary/90 border-transparent',
secondary:
'bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90 border-transparent',
tertiary:
'bg-foreground/15 dark:bg-foreground/10 text-foreground [a&]:hover:bg-foreground/25 border-transparent',
destructive:
'bg-destructive [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/70 border-transparent text-white',
outline: 'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground'
@@ -1,7 +1,7 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/components/ui/utils';
import { BOX_BORDER } from '$lib/constants/css-classes';
import { BOX_BORDER } from '$lib/constants';
let {
ref = $bindable(null),
@@ -0,0 +1,5 @@
export const API_MODELS = {
LIST: '/v1/models',
LOAD: '/models/load',
UNLOAD: '/models/unload'
};
@@ -0,0 +1,33 @@
// Central constants export file
// All constants should be imported from '$lib/constants'
export * from './agentic';
export * from './api-endpoints';
export * from './attachment-labels';
export * from './auto-scroll';
export * from './binary-detection';
export * from './cache';
export * from './chat-form';
export * from './code-blocks';
export * from './code';
export * from './css-classes';
export * from './floating-ui-constraints';
export * from './formatters';
export * from './icons';
export * from './latex-protection';
export * from './literal-html';
export * from './localstorage-keys';
export * from './markdown';
export * from './max-bundle-size';
export * from './model-id';
export * from './precision';
export * from './processing-info';
export * from './settings-config';
export * from './settings-fields';
export * from './settings-keys';
export * from './settings-sections';
export * from './supported-file-types';
export * from './table-html-restorer';
export * from './tooltip-config';
export * from './ui';
export * from './viewport';
@@ -1,2 +1,3 @@
export const CONFIG_LOCALSTORAGE_KEY = 'LlamaCppWebui.config';
export const USER_OVERRIDES_LOCALSTORAGE_KEY = 'LlamaCppWebui.userOverrides';
export const FAVOURITE_MODELS_LOCALSTORAGE_KEY = 'LlamaCppWebui.favouriteModels';
@@ -0,0 +1,28 @@
/** Sentinel value returned by `indexOf` when a substring is not found. */
export const MODEL_ID_NOT_FOUND = -1;
/** Separates `<org>` from `<model>` in a model ID, e.g. `org/ModelName`. */
export const MODEL_ID_ORG_SEPARATOR = '/';
/** Separates named segments within the model path, e.g. `ModelName-7B-GGUF`. */
export const MODEL_ID_SEGMENT_SEPARATOR = '-';
/** Separates the model path from the quantization tag, e.g. `model:Q4_K_M`. */
export const MODEL_ID_QUANTIZATION_SEPARATOR = ':';
/**
* Matches a trailing ALL-CAPS format segment, e.g. `GGUF`, `BF16`, `Q4_K_M`.
* Must be at least 2 uppercase letters, optionally followed by uppercase letters or digits.
*/
export const MODEL_FORMAT_SEGMENT_RE = /^[A-Z]{2,}[A-Z0-9]*$/;
/**
* Matches a parameter-count segment, e.g. `7B`, `1.5b`, `120M`.
*/
export const MODEL_PARAMS_RE = /^\d+(\.\d+)?[BbMmKkTt]$/;
/**
* Matches an activated-parameter-count segment, e.g. `A10B`, `A2.4b`.
* The leading `A` distinguishes it from a regular params segment.
*/
export const MODEL_ACTIVATED_PARAMS_RE = /^A\d+(\.\d+)?[BbMmKkTt]$/;
@@ -23,6 +23,7 @@ export const SETTING_CONFIG_DEFAULT: Record<string, string | number | boolean> =
autoShowSidebarOnNewChat: true,
autoMicOnEmpty: false,
fullHeightCodeBlocks: false,
showRawModelNames: false,
// make sure these default values are in sync with `common.h`
samplers: 'top_k;typ_p;top_p;min_p;temperature',
backend_sampling: false,
@@ -116,6 +117,8 @@ export const SETTING_CONFIG_INFO: Record<string, string> = {
'Automatically show microphone button instead of send button when textarea is empty for models with audio modality support.',
fullHeightCodeBlocks:
'Always display code blocks at their full natural height, overriding any height limits.',
showRawModelNames:
'Display full raw model identifiers (e.g. "unsloth/Qwen3.5-27B-GGUF:BF16") instead of parsed names with badges.',
pyInterpreterEnabled:
'Enable Python interpreter using Pyodide. Allows running Python code in markdown code blocks.',
enableContinueGeneration:
@@ -24,6 +24,7 @@ export const SETTINGS_KEYS = {
ALWAYS_SHOW_SIDEBAR_ON_DESKTOP: 'alwaysShowSidebarOnDesktop',
AUTO_SHOW_SIDEBAR_ON_NEW_CHAT: 'autoShowSidebarOnNewChat',
FULL_HEIGHT_CODE_BLOCKS: 'fullHeightCodeBlocks',
SHOW_RAW_MODEL_NAMES: 'showRawModelNames',
// Sampling
TEMPERATURE: 'temperature',
DYNATEMP_RANGE: 'dynatemp_range',
+2 -1
View File
@@ -11,5 +11,6 @@ export enum KeyboardKey {
D_UPPER = 'D',
E_UPPER = 'E',
K_LOWER = 'k',
O_UPPER = 'O'
O_UPPER = 'O',
SPACE = ' '
}
@@ -1,4 +1,4 @@
import { DEFAULT_MOBILE_BREAKPOINT } from '$lib/constants/viewport';
import { DEFAULT_MOBILE_BREAKPOINT } from '$lib/constants';
import { MediaQuery } from 'svelte/reactivity';
export class IsMobile extends MediaQuery {
@@ -1,4 +1,4 @@
import { AUTO_SCROLL_AT_BOTTOM_THRESHOLD, AUTO_SCROLL_INTERVAL } from '$lib/constants/auto-scroll';
import { AUTO_SCROLL_AT_BOTTOM_THRESHOLD, AUTO_SCROLL_INTERVAL } from '$lib/constants';
export interface AutoScrollOptions {
/** Whether auto-scroll is disabled globally (e.g., from settings) */
@@ -1,6 +1,6 @@
import { activeProcessingState } from '$lib/stores/chat.svelte';
import { config } from '$lib/stores/settings.svelte';
import { STATS_UNITS } from '$lib/constants/processing-info';
import { STATS_UNITS } from '$lib/constants';
import type { ApiProcessingState, LiveProcessingStats, LiveGenerationStats } from '$lib/types';
export interface UseProcessingStateReturn {
@@ -22,7 +22,7 @@ import {
COPY_CODE_BTN_CLASS,
PREVIEW_CODE_BTN_CLASS,
RELATIVE_CLASS
} from '$lib/constants/code-blocks';
} from '$lib/constants';
declare global {
interface Window {
@@ -1,7 +1,7 @@
import type { Plugin } from 'unified';
import { visit } from 'unist-util-visit';
import type { Break, Content, Paragraph, PhrasingContent, Root, Text } from 'mdast';
import { LINE_BREAK, NBSP, PHRASE_PARENTS, TAB_AS_SPACES } from '$lib/constants/literal-html';
import { LINE_BREAK, NBSP, PHRASE_PARENTS, TAB_AS_SPACES } from '$lib/constants';
/**
* remark plugin that rewrites raw HTML nodes into plain-text equivalents.
@@ -68,7 +68,7 @@ import type { Plugin } from 'unified';
import type { Element, ElementContent, Root, Text } from 'hast';
import { visit } from 'unist-util-visit';
import { visitParents } from 'unist-util-visit-parents';
import { BR_PATTERN, LIST_PATTERN, LI_PATTERN } from '$lib/constants/table-html-restorer';
import { BR_PATTERN, LIST_PATTERN, LI_PATTERN } from '$lib/constants';
/**
* Expands text containing `<br>` tags into an array of text nodes and br elements.
@@ -1,5 +1,5 @@
import { getJsonHeaders, formatAttachmentText, isAbortError } from '$lib/utils';
import { ATTACHMENT_LABEL_PDF_FILE } from '$lib/constants/attachment-labels';
import { AGENTIC_REGEX, ATTACHMENT_LABEL_PDF_FILE } from '$lib/constants';
import {
AttachmentType,
ContentPartType,
@@ -9,7 +9,6 @@ import {
} from '$lib/enums';
import type { ApiChatMessageContentPart, ApiChatCompletionToolCall } from '$lib/types/api';
import { modelsStore } from '$lib/stores/models.svelte';
import { AGENTIC_REGEX } from '$lib/constants/agentic';
export class ChatService {
private static stripReasoningContent(
@@ -1,5 +1,16 @@
import { ServerModelStatus } from '$lib/enums';
import { apiFetch, apiPost } from '$lib/utils';
import type { ParsedModelId } from '$lib/types/models';
import {
MODEL_FORMAT_SEGMENT_RE,
MODEL_PARAMS_RE,
MODEL_ACTIVATED_PARAMS_RE,
MODEL_ID_NOT_FOUND,
MODEL_ID_ORG_SEPARATOR,
MODEL_ID_SEGMENT_SEPARATOR,
MODEL_ID_QUANTIZATION_SEPARATOR,
API_MODELS
} from '$lib/constants';
export class ModelsService {
/**
@@ -17,7 +28,7 @@ export class ModelsService {
* @returns List of available models with basic metadata
*/
static async list(): Promise<ApiModelListResponse> {
return apiFetch<ApiModelListResponse>('/v1/models');
return apiFetch<ApiModelListResponse>(API_MODELS.LIST);
}
/**
@@ -28,7 +39,7 @@ export class ModelsService {
* @returns List of models with detailed status and configuration info
*/
static async listRouter(): Promise<ApiRouterModelsListResponse> {
return apiFetch<ApiRouterModelsListResponse>('/v1/models');
return apiFetch<ApiRouterModelsListResponse>(API_MODELS.LIST);
}
/**
@@ -54,7 +65,7 @@ export class ModelsService {
payload.extra_args = extraArgs;
}
return apiPost<ApiRouterModelsLoadResponse>('/models/load', payload);
return apiPost<ApiRouterModelsLoadResponse>(API_MODELS.LOAD, payload);
}
/**
@@ -66,7 +77,7 @@ export class ModelsService {
* @returns Unload response from the server
*/
static async unload(modelId: string): Promise<ApiRouterModelsUnloadResponse> {
return apiPost<ApiRouterModelsUnloadResponse>('/models/unload', { model: modelId });
return apiPost<ApiRouterModelsUnloadResponse>(API_MODELS.UNLOAD, { model: modelId });
}
/**
@@ -96,4 +107,89 @@ export class ModelsService {
static isModelLoading(model: ApiModelDataEntry): boolean {
return model.status.value === ServerModelStatus.LOADING;
}
/**
*
*
* Parsing
*
*
*/
/**
* Parse a model ID string into its structured components.
*
* Handles the convention:
* `<org>/<ModelName>-<Parameters>(-<ActivatedParameters>)-<Format>:<QuantizationType>`
*
* @param modelId - Raw model identifier string
* @returns Structured {@link ParsedModelId} with all detected fields
*/
static parseModelId(modelId: string): ParsedModelId {
const result: ParsedModelId = {
raw: modelId,
orgName: null,
modelName: null,
params: null,
activatedParams: null,
format: null,
quantization: null,
tags: []
};
const colonIdx = modelId.indexOf(MODEL_ID_QUANTIZATION_SEPARATOR);
let modelPath: string;
if (colonIdx !== MODEL_ID_NOT_FOUND) {
result.quantization = modelId.slice(colonIdx + 1) || null;
modelPath = modelId.slice(0, colonIdx);
} else {
modelPath = modelId;
}
const slashIdx = modelPath.indexOf(MODEL_ID_ORG_SEPARATOR);
let modelStr: string;
if (slashIdx !== MODEL_ID_NOT_FOUND) {
result.orgName = modelPath.slice(0, slashIdx);
modelStr = modelPath.slice(slashIdx + 1);
} else {
modelStr = modelPath;
}
const segments = modelStr.split(MODEL_ID_SEGMENT_SEPARATOR);
if (segments.length > 0 && MODEL_FORMAT_SEGMENT_RE.test(segments[segments.length - 1])) {
result.format = segments.pop()!;
}
const paramsRe = MODEL_PARAMS_RE;
const activatedParamsRe = MODEL_ACTIVATED_PARAMS_RE;
let paramsIdx = MODEL_ID_NOT_FOUND;
let activatedParamsIdx = MODEL_ID_NOT_FOUND;
for (let i = 0; i < segments.length; i++) {
const seg = segments[i];
if (paramsIdx === -1 && paramsRe.test(seg)) {
paramsIdx = i;
result.params = seg.toUpperCase();
} else if (activatedParamsRe.test(seg)) {
activatedParamsIdx = i;
result.activatedParams = seg.toUpperCase();
}
}
const pivotIdx = paramsIdx !== MODEL_ID_NOT_FOUND ? paramsIdx : segments.length;
result.modelName = segments.slice(0, pivotIdx).join(MODEL_ID_SEGMENT_SEPARATOR) || null;
if (paramsIdx !== MODEL_ID_NOT_FOUND) {
result.tags = segments
.slice(paramsIdx + 1)
.filter((_, relIdx) => paramsIdx + 1 + relIdx !== activatedParamsIdx);
}
return result;
}
}
@@ -28,12 +28,12 @@ import {
findLeafNode,
isAbortError
} from '$lib/utils';
import { SYSTEM_MESSAGE_PLACEHOLDER } from '$lib/constants/ui';
import { REASONING_TAGS } from '$lib/constants/agentic';
import {
MAX_INACTIVE_CONVERSATION_STATES,
INACTIVE_CONVERSATION_STATE_MAX_AGE_MS
} from '$lib/constants/cache';
INACTIVE_CONVERSATION_STATE_MAX_AGE_MS,
REASONING_TAGS,
SYSTEM_MESSAGE_PLACEHOLDER
} from '$lib/constants';
import type {
ChatMessageTimings,
ChatMessagePromptProgress,
@@ -1,9 +1,14 @@
import { SvelteSet } from 'svelte/reactivity';
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
import { toast } from 'svelte-sonner';
import { ServerModelStatus, ModelModality } from '$lib/enums';
import { ModelsService, PropsService } from '$lib/services';
import { serverStore } from '$lib/stores/server.svelte';
import { TTLCache } from '$lib/utils';
import { MODEL_PROPS_CACHE_TTL_MS, MODEL_PROPS_CACHE_MAX_ENTRIES } from '$lib/constants/cache';
import {
MODEL_PROPS_CACHE_TTL_MS,
MODEL_PROPS_CACHE_MAX_ENTRIES,
FAVOURITE_MODELS_LOCALSTORAGE_KEY
} from '$lib/constants';
/**
* modelsStore - Reactive store for model management in both MODEL and ROUTER modes
@@ -50,7 +55,9 @@ class ModelsStore {
selectedModelName = $state<string | null>(null);
private modelUsage = $state<Map<string, SvelteSet<string>>>(new Map());
private modelLoadingStates = $state<Map<string, boolean>>(new Map());
private modelLoadingStates = new SvelteMap<string, boolean>();
favouriteModelIds = $state<Set<string>>(this.loadFavouritesFromStorage());
/**
* Model-specific props cache with TTL
@@ -261,15 +268,19 @@ class ModelsStore {
const displayNameSource =
details?.name && details.name.trim().length > 0 ? details.name : item.id;
const displayName = this.toDisplayName(displayNameSource);
const modelId = details?.model || item.id;
return {
id: item.id,
name: displayName,
model: details?.model || item.id,
model: modelId,
description: details?.description,
capabilities: rawCapabilities.filter((value: unknown): value is string => Boolean(value)),
details: details?.details,
meta: item.meta ?? null
meta: item.meta ?? null,
parsedId: ModelsService.parseModelId(modelId),
aliases: item.aliases ?? [],
tags: item.tags ?? []
} satisfies ModelOption;
});
@@ -497,22 +508,21 @@ class ModelsStore {
/** 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.
* Keeps polling indefinitely until the model reaches the expected status or fails.
*
* @param modelId - Model identifier to check
* @param expectedStatus - Expected status to wait for
* @returns Promise that resolves when expected status is reached
* @throws Error if model reaches FAILED status
*/
private async pollForModelStatus(
modelId: string,
expectedStatus: ServerModelStatus
): Promise<void> {
for (let attempt = 0; attempt < ModelsStore.STATUS_POLL_MAX_ATTEMPTS; attempt++) {
let attempt = 0;
while (true) {
await this.fetchRouterModels();
const currentStatus = this.getModelStatus(modelId);
@@ -520,12 +530,23 @@ class ModelsStore {
return;
}
if (currentStatus === ServerModelStatus.FAILED) {
throw new Error(
`Model failed to ${expectedStatus === ServerModelStatus.LOADED ? 'load' : 'unload'}`
);
}
if (
expectedStatus === ServerModelStatus.LOADED &&
currentStatus === ServerModelStatus.UNLOADED &&
attempt > 2
) {
throw new Error('Model was unloaded unexpectedly during loading');
}
attempt++;
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`
);
}
/**
@@ -547,8 +568,10 @@ class ModelsStore {
await this.pollForModelStatus(modelId, ServerModelStatus.LOADED);
await this.updateModelModalities(modelId);
toast.success(`Model loaded: ${this.toDisplayName(modelId)}`);
} catch (error) {
this.error = error instanceof Error ? error.message : 'Failed to load model';
toast.error(`Failed to load model: ${this.toDisplayName(modelId)}`);
throw error;
} finally {
this.modelLoadingStates.set(modelId, false);
@@ -573,8 +596,10 @@ class ModelsStore {
await ModelsService.unload(modelId);
await this.pollForModelStatus(modelId, ServerModelStatus.UNLOADED);
toast.info(`Model unloaded: ${this.toDisplayName(modelId)}`);
} catch (error) {
this.error = error instanceof Error ? error.message : 'Failed to unload model';
toast.error(`Failed to unload model: ${this.toDisplayName(modelId)}`);
throw error;
} finally {
this.modelLoadingStates.set(modelId, false);
@@ -593,6 +618,48 @@ class ModelsStore {
await this.loadModel(modelId);
}
/**
*
*
* Favourites
*
*
*/
isFavourite(modelId: string): boolean {
return this.favouriteModelIds.has(modelId);
}
toggleFavourite(modelId: string): void {
const next = new SvelteSet(this.favouriteModelIds);
if (next.has(modelId)) {
next.delete(modelId);
} else {
next.add(modelId);
}
this.favouriteModelIds = next;
try {
localStorage.setItem(FAVOURITE_MODELS_LOCALSTORAGE_KEY, JSON.stringify([...next]));
} catch {
toast.error('Failed to save favourite models to local storage');
}
}
private loadFavouritesFromStorage(): Set<string> {
try {
const raw = localStorage.getItem(FAVOURITE_MODELS_LOCALSTORAGE_KEY);
return raw ? new Set(JSON.parse(raw) as string[]) : new Set();
} catch {
toast.error('Failed to load favourite models from local storage');
return new Set();
}
}
/**
*
*
@@ -646,3 +713,4 @@ export const loadingModelIds = () => modelsStore.loadingModelIds;
export const propsCacheVersion = () => modelsStore.propsCacheVersion;
export const singleModelName = () => modelsStore.singleModelName;
export const selectedModelContextSize = () => modelsStore.selectedModelContextSize;
export const favouriteModelIds = () => modelsStore.favouriteModelIds;
@@ -32,7 +32,11 @@
*/
import { browser } from '$app/environment';
import { SETTING_CONFIG_DEFAULT } from '$lib/constants/settings-config';
import {
CONFIG_LOCALSTORAGE_KEY,
SETTING_CONFIG_DEFAULT,
USER_OVERRIDES_LOCALSTORAGE_KEY
} from '$lib/constants';
import { ParameterSyncService } from '$lib/services/parameter-sync.service';
import { serverStore } from '$lib/stores/server.svelte';
import {
@@ -41,10 +45,6 @@ import {
getConfigValue,
setConfigValue
} from '$lib/utils';
import {
CONFIG_LOCALSTORAGE_KEY,
USER_OVERRIDES_LOCALSTORAGE_KEY
} from '$lib/constants/localstorage-keys';
class SettingsStore {
/**
+4
View File
@@ -81,6 +81,10 @@ export interface ApiModelDataEntry {
path: string;
/** Current status of the model */
status: ApiModelStatus;
/** Alternative names that resolve to this model */
aliases?: string[];
/** Informational tags for this model */
tags?: string[];
/** Legacy meta field (may be present in older responses) */
meta?: Record<string, unknown> | null;
}
+14
View File
@@ -14,6 +14,20 @@ export interface ModelOption {
modalities?: ModelModalities;
details?: ApiModelDetails['details'];
meta?: ApiModelDataEntry['meta'];
parsedId?: ParsedModelId;
aliases?: string[];
tags?: string[];
}
export interface ParsedModelId {
raw: string;
orgName: string | null;
modelName: string | null;
params: string | null;
activatedParams: string | null;
format: string | null;
quantization: string | null;
tags: string[];
}
/**
+1 -1
View File
@@ -1,4 +1,4 @@
import type { SETTING_CONFIG_DEFAULT } from '$lib/constants/settings-config';
import type { SETTING_CONFIG_DEFAULT } from '$lib/constants';
import type { ChatMessagePromptProgress, ChatMessageTimings } from './chat';
import type { DatabaseMessageExtra } from './database';
import type { ParameterSource, SyncableParameterType, SettingsFieldType } from '$lib/enums';
@@ -1,4 +1,4 @@
import { DEFAULT_CACHE_TTL_MS, DEFAULT_CACHE_MAX_ENTRIES } from '$lib/constants/cache';
import { DEFAULT_CACHE_TTL_MS, DEFAULT_CACHE_MAX_ENTRIES } from '$lib/constants';
/**
* TTL Cache - Time-To-Live cache implementation for memory optimization
+1 -1
View File
@@ -7,7 +7,7 @@ import {
LT_REGEX,
GT_REGEX,
FENCE_PATTERN
} from '$lib/constants/code';
} from '$lib/constants';
export interface IncompleteCodeBlock {
language: string;
@@ -3,7 +3,7 @@ import {
IMAGE_FILE_TYPES,
PDF_FILE_TYPES,
TEXT_FILE_TYPES
} from '$lib/constants/supported-file-types';
} from '$lib/constants';
import {
FileExtensionAudio,
FileExtensionImage,
@@ -4,7 +4,7 @@ import {
SECONDS_PER_HOUR,
SHORT_DURATION_THRESHOLD,
MEDIUM_DURATION_THRESHOLD
} from '$lib/constants/formatters';
} from '$lib/constants';
/**
* Formats file size in bytes to human readable format
@@ -3,7 +3,7 @@ import {
LATEX_MATH_AND_CODE_PATTERN,
LATEX_LINEBREAK_REGEXP,
MHCHEM_PATTERN_MAP
} from '$lib/constants/latex-protection';
} from '$lib/constants';
/**
* Replaces inline LaTeX expressions enclosed in `$...$` with placeholders, avoiding dollar signs
@@ -5,7 +5,7 @@
* and display, addressing JavaScript's floating-point precision issues.
*/
import { PRECISION_MULTIPLIER } from '$lib/constants/precision';
import { PRECISION_MULTIPLIER } from '$lib/constants';
/**
* Normalize floating-point numbers for consistent comparison
@@ -3,7 +3,7 @@
* Handles text file detection, reading, and validation
*/
import { DEFAULT_BINARY_DETECTION_OPTIONS } from '$lib/constants/binary-detection';
import { DEFAULT_BINARY_DETECTION_OPTIONS } from '$lib/constants';
import type { BinaryDetectionOptions } from '$lib/types';
import { FileExtensionText } from '$lib/enums';
+1 -1
View File
@@ -14,7 +14,7 @@
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';
import { TOOLTIP_DELAY_DURATION } from '$lib/constants';
import { KeyboardKey } from '$lib/enums';
import { IsMobile } from '$lib/hooks/is-mobile.svelte';
@@ -3,6 +3,7 @@
import { chatStore } from '$lib/stores/chat.svelte';
import { conversationsStore, isConversationsInitialized } from '$lib/stores/conversations.svelte';
import { modelsStore, modelOptions } from '$lib/stores/models.svelte';
import { isRouterMode } from '$lib/stores/server.svelte';
import { onMount } from 'svelte';
import { page } from '$app/state';
import { replaceState } from '$app/navigation';
@@ -71,6 +72,14 @@
conversationsStore.clearActiveConversation();
chatStore.clearUIState();
if (
isRouterMode() &&
modelsStore.selectedModelName &&
!modelsStore.isModelLoaded(modelsStore.selectedModelName)
) {
modelsStore.clearSelection();
}
// Handle URL params only if we have ?q= or ?model= or ?new_chat=true
if (qParam !== null || modelParam !== null || newChatParam === 'true') {
await handleUrlParams();
@@ -99,10 +99,12 @@
(option) => option.model === lastMessageWithModel.model
);
if (matchingModel) {
if (matchingModel && modelsStore.isModelLoaded(matchingModel.model)) {
try {
await modelsStore.selectModelById(matchingModel.id);
console.log(`Automatically loaded model: ${lastMessageWithModel.model} from last message`);
console.log(
`Automatically selected model: ${lastMessageWithModel.model} from last message`
);
} catch (error) {
console.warn('Failed to automatically select model from last message:', error);
}