ui: Stores consolidation refactor (#27238)
* ui: Remove dead code from stores - persisted() helper was exported but never used - messageUpdateCallback / registerMessageUpdateCallback were never wired up - conversationsStore.initialize() alias, single caller moved to init() * ui: Merge device, theme and viewport into a single deviceStore All three are reactive browser-environment signals, now exposed as one class store: deviceStore.isMobile, deviceStore.isIOSDevice / isIOSSafari / isWKWebView / isStandalone and deviceStore.systemTheme.isDark. The systemTheme name disambiguates the OS preference from the user theme preference in settingsStore. Drops the unused viewport export (only isMobile was consumed). * ui: Merge build info into version store One VersionStore class with build (llama.cpp build number from build.json) and frontend (PWA version from _app/version.json), matching the class pattern of the other stores. * ui: Colocate context gauge popup state with its components The gauge popup state is local UI state shared only by the ChatFormContextGauge subtree, so it lives next to its consumers instead of the app-scope stores barrel.
This commit is contained in:
+2
-2
@@ -2,10 +2,10 @@
|
|||||||
import ChatFormActionAddButton from './ChatFormActionAddButton.svelte';
|
import ChatFormActionAddButton from './ChatFormActionAddButton.svelte';
|
||||||
import ChatFormActionAddDropdown from './ChatFormActionAddDropdown.svelte';
|
import ChatFormActionAddDropdown from './ChatFormActionAddDropdown.svelte';
|
||||||
import ChatFormActionAddSheet from './ChatFormActionAddSheet.svelte';
|
import ChatFormActionAddSheet from './ChatFormActionAddSheet.svelte';
|
||||||
import { isMobile } from '$lib/stores';
|
import { deviceStore } from '$lib/stores';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if isMobile.current}
|
{#if deviceStore.isMobile}
|
||||||
<ChatFormActionAddSheet>
|
<ChatFormActionAddSheet>
|
||||||
{#snippet trigger({ disabled, onclick })}
|
{#snippet trigger({ disabled, onclick })}
|
||||||
<ChatFormActionAddButton {disabled} {onclick} />
|
<ChatFormActionAddButton {disabled} {onclick} />
|
||||||
|
|||||||
+8
-2
@@ -1,6 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { ModelsSelectorDropdown, ModelsSelectorSheet } from '$lib/components/app';
|
import { ModelsSelectorDropdown, ModelsSelectorSheet } from '$lib/components/app';
|
||||||
import { chatStore, conversationsStore, isMobile, modelsStore, serverStore } from '$lib/stores';
|
import {
|
||||||
|
chatStore,
|
||||||
|
conversationsStore,
|
||||||
|
deviceStore,
|
||||||
|
modelsStore,
|
||||||
|
serverStore
|
||||||
|
} from '$lib/stores';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
@@ -170,7 +176,7 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if isMobile.current}
|
{#if deviceStore.isMobile}
|
||||||
<ModelsSelectorSheet
|
<ModelsSelectorSheet
|
||||||
disabled={disabled || isOffline}
|
disabled={disabled || isOffline}
|
||||||
bind:this={selectorModelRef}
|
bind:this={selectorModelRef}
|
||||||
|
|||||||
+3
-4
@@ -1,15 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import ContextGaugeDial from './ContextGaugeDial.svelte';
|
import ContextGaugeDial from './ContextGaugeDial.svelte';
|
||||||
import { useContextGauge } from '$lib/hooks/use-context-gauge.svelte';
|
|
||||||
import {
|
import {
|
||||||
chatStore,
|
|
||||||
conversationsStore,
|
|
||||||
gaugeTriggerClick,
|
gaugeTriggerClick,
|
||||||
gaugeTriggerEnter,
|
gaugeTriggerEnter,
|
||||||
gaugeTriggerKeydown,
|
gaugeTriggerKeydown,
|
||||||
gaugeTriggerLeave,
|
gaugeTriggerLeave,
|
||||||
gaugeTriggerPointerDown
|
gaugeTriggerPointerDown
|
||||||
} from '$lib/stores';
|
} from './gauge-popup.svelte';
|
||||||
|
import { useContextGauge } from '$lib/hooks/use-context-gauge.svelte';
|
||||||
|
import { chatStore, conversationsStore } from '$lib/stores';
|
||||||
import { untrack } from 'svelte';
|
import { untrack } from 'svelte';
|
||||||
|
|
||||||
const gauge = useContextGauge();
|
const gauge = useContextGauge();
|
||||||
|
|||||||
+1
-1
@@ -1,9 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import ContextGaugeDetailRow from './ContextGaugeDetailRow.svelte';
|
import ContextGaugeDetailRow from './ContextGaugeDetailRow.svelte';
|
||||||
|
import { gaugePopup } from './gauge-popup.svelte';
|
||||||
import { ChevronDown } from '@lucide/svelte';
|
import { ChevronDown } from '@lucide/svelte';
|
||||||
import * as Collapsible from '$lib/components/ui/collapsible';
|
import * as Collapsible from '$lib/components/ui/collapsible';
|
||||||
import { STATS_UNITS } from '$lib/constants';
|
import { STATS_UNITS } from '$lib/constants';
|
||||||
import { gaugePopup } from '$lib/stores/context-gauge-popup.svelte';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
currentRead: number;
|
currentRead: number;
|
||||||
|
|||||||
+6
-1
@@ -2,8 +2,13 @@
|
|||||||
import { colorLevelBgClass, colorLevelTextClass } from './context-gauge';
|
import { colorLevelBgClass, colorLevelTextClass } from './context-gauge';
|
||||||
import ContextGaugeDetails from './ContextGaugeDetails.svelte';
|
import ContextGaugeDetails from './ContextGaugeDetails.svelte';
|
||||||
import ContextGaugeLoadModel from './ContextGaugeLoadModel.svelte';
|
import ContextGaugeLoadModel from './ContextGaugeLoadModel.svelte';
|
||||||
|
import {
|
||||||
|
gaugeCardEnter,
|
||||||
|
gaugeCardLeave,
|
||||||
|
gaugePopup,
|
||||||
|
gaugePopupClose
|
||||||
|
} from './gauge-popup.svelte';
|
||||||
import { useContextGauge } from '$lib/hooks/use-context-gauge.svelte';
|
import { useContextGauge } from '$lib/hooks/use-context-gauge.svelte';
|
||||||
import { gaugeCardEnter, gaugeCardLeave, gaugePopup, gaugePopupClose } from '$lib/stores';
|
|
||||||
import { formatParameters } from '$lib/utils/formatters';
|
import { formatParameters } from '$lib/utils/formatters';
|
||||||
|
|
||||||
const gauge = useContextGauge();
|
const gauge = useContextGauge();
|
||||||
|
|||||||
+2
-2
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { isMobile } from '$lib/stores';
|
import { deviceStore } from '$lib/stores';
|
||||||
import { autoResizeTextarea } from '$lib/utils';
|
import { autoResizeTextarea } from '$lib/utils';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function focus() {
|
export function focus() {
|
||||||
if (isMobile.current) return;
|
if (deviceStore.isMobile) return;
|
||||||
|
|
||||||
textareaElement?.focus({ preventScroll: true });
|
textareaElement?.focus({ preventScroll: true });
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-3
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { CODE_BLOCK, CODE_TOKEN_ATTR, UI_DATA_ATTRS } from '$lib/constants';
|
import { CODE_BLOCK, CODE_TOKEN_ATTR, UI_DATA_ATTRS } from '$lib/constants';
|
||||||
import { BooleanString, ChatFormInputRichTokenKind, ColorMode } from '$lib/enums';
|
import { BooleanString, ChatFormInputRichTokenKind, ColorMode } from '$lib/enums';
|
||||||
import { isMobile } from '$lib/stores';
|
import { deviceStore } from '$lib/stores';
|
||||||
import type { ChatFormInputRichToken } from '$lib/types';
|
import type { ChatFormInputRichToken } from '$lib/types';
|
||||||
import type { SourceHistoryEntry } from '$lib/utils';
|
import type { SourceHistoryEntry } from '$lib/utils';
|
||||||
import {
|
import {
|
||||||
@@ -750,7 +750,7 @@
|
|||||||
syncEmptyState();
|
syncEmptyState();
|
||||||
document.addEventListener('selectionchange', handleSelectionChange);
|
document.addEventListener('selectionchange', handleSelectionChange);
|
||||||
|
|
||||||
if (!isMobile.current) {
|
if (!deviceStore.isMobile) {
|
||||||
rootElement?.focus({ preventScroll: true });
|
rootElement?.focus({ preventScroll: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -792,7 +792,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function focus() {
|
export function focus() {
|
||||||
if (isMobile.current) return;
|
if (deviceStore.isMobile) return;
|
||||||
|
|
||||||
rootElement?.focus({ preventScroll: true });
|
rootElement?.focus({ preventScroll: true });
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -8,7 +8,7 @@
|
|||||||
import { BuiltInTool, FileMentionEntryType, GlobSearchType, KeyboardKey } from '$lib/enums';
|
import { BuiltInTool, FileMentionEntryType, GlobSearchType, KeyboardKey } from '$lib/enums';
|
||||||
import { useDebouncedSearch } from '$lib/hooks/use-debounced-search.svelte';
|
import { useDebouncedSearch } from '$lib/hooks/use-debounced-search.svelte';
|
||||||
import { usePickerNavigation } from '$lib/hooks/use-picker-navigation.svelte';
|
import { usePickerNavigation } from '$lib/hooks/use-picker-navigation.svelte';
|
||||||
import { isMobile, settingsStore, toolsStore } from '$lib/stores';
|
import { deviceStore, settingsStore, toolsStore } from '$lib/stores';
|
||||||
import type { FileMentionEntry, GlobEntryResult } from '$lib/types';
|
import type { FileMentionEntry, GlobEntryResult } from '$lib/types';
|
||||||
import { abbreviateHome, runGlobSearchWithChildren } from '$lib/utils';
|
import { abbreviateHome, runGlobSearchWithChildren } from '$lib/utils';
|
||||||
|
|
||||||
@@ -130,7 +130,7 @@
|
|||||||
return searchError ? `Search failed - ${searchError}` : 'No matching files or folders';
|
return searchError ? `Search failed - ${searchError}` : 'No matching files or folders';
|
||||||
});
|
});
|
||||||
|
|
||||||
const showTooltip = $derived(!isMobile.current);
|
const showTooltip = $derived(!deviceStore.isMobile);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
import { setChatMessageActionsContext, setChatMessageEditContext } from '$lib/contexts';
|
import { setChatMessageActionsContext, setChatMessageEditContext } from '$lib/contexts';
|
||||||
import { AgenticSectionType, AttachmentType, MessageRole } from '$lib/enums';
|
import { AgenticSectionType, AttachmentType, MessageRole } from '$lib/enums';
|
||||||
import { DatabaseService } from '$lib/services/database.service';
|
import { DatabaseService } from '$lib/services/database.service';
|
||||||
import { chatStore, conversationsStore, isMobile } from '$lib/stores';
|
import { chatStore, conversationsStore, deviceStore } from '$lib/stores';
|
||||||
import type {
|
import type {
|
||||||
ChatMessageActions,
|
ChatMessageActions,
|
||||||
ChatMessageDeletionInfo,
|
ChatMessageDeletionInfo,
|
||||||
@@ -304,7 +304,7 @@
|
|||||||
|
|
||||||
// After the system message flow ends, hand focus to the main chat form
|
// After the system message flow ends, hand focus to the main chat form
|
||||||
function focusMainChatForm() {
|
function focusMainChatForm() {
|
||||||
if (isMobile.current) return;
|
if (deviceStore.isMobile) return;
|
||||||
|
|
||||||
document.querySelector<HTMLTextAreaElement>('.chat-screen-form-wrapper textarea')?.focus();
|
document.querySelector<HTMLTextAreaElement>('.chat-screen-form-wrapper textarea')?.focus();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,8 +21,7 @@
|
|||||||
import {
|
import {
|
||||||
chatStore,
|
chatStore,
|
||||||
conversationsStore,
|
conversationsStore,
|
||||||
device,
|
deviceStore,
|
||||||
isMobile,
|
|
||||||
serverStore,
|
serverStore,
|
||||||
settingsStore
|
settingsStore
|
||||||
} from '$lib/stores';
|
} from '$lib/stores';
|
||||||
@@ -32,7 +31,7 @@
|
|||||||
let { showCenteredEmpty = false } = $props();
|
let { showCenteredEmpty = false } = $props();
|
||||||
|
|
||||||
let disableAutoScroll = $derived(
|
let disableAutoScroll = $derived(
|
||||||
Boolean(settingsStore.config.disableAutoScroll) || isMobile.current
|
Boolean(settingsStore.config.disableAutoScroll) || deviceStore.isMobile
|
||||||
);
|
);
|
||||||
let isMobileUserScrolledUp = $state(false);
|
let isMobileUserScrolledUp = $state(false);
|
||||||
let mobileScrollDownHint = $state(false);
|
let mobileScrollDownHint = $state(false);
|
||||||
@@ -52,11 +51,11 @@
|
|||||||
let hasPropsError = $derived(!!serverStore.error);
|
let hasPropsError = $derived(!!serverStore.error);
|
||||||
let isCurrentConversationLoading = $derived(chatStore.isLoading || chatStore.isStreaming());
|
let isCurrentConversationLoading = $derived(chatStore.isLoading || chatStore.isStreaming());
|
||||||
let chatFormBottomPosition = $derived.by(() => {
|
let chatFormBottomPosition = $derived.by(() => {
|
||||||
if (!isMobile.current) return '1rem';
|
if (!deviceStore.isMobile) return '1rem';
|
||||||
|
|
||||||
if (device.isStandalone) return '1.5rem';
|
if (deviceStore.isStandalone) return '1.5rem';
|
||||||
|
|
||||||
if (device.isIOSSafari) return '0.25rem';
|
if (deviceStore.isIOSSafari) return '0.25rem';
|
||||||
|
|
||||||
return '0.5rem';
|
return '0.5rem';
|
||||||
});
|
});
|
||||||
@@ -84,7 +83,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
function handleMobileScroll() {
|
function handleMobileScroll() {
|
||||||
if (!isMobile.current) return;
|
if (!deviceStore.isMobile) return;
|
||||||
|
|
||||||
const container = scroll.chatScrollContainer;
|
const container = scroll.chatScrollContainer;
|
||||||
|
|
||||||
@@ -184,7 +183,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleSendLikeScroll() {
|
function handleSendLikeScroll() {
|
||||||
if (!isMobile.current) {
|
if (!deviceStore.isMobile) {
|
||||||
autoScroll.enable();
|
autoScroll.enable();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,7 +196,7 @@
|
|||||||
'.chat-message:nth-last-child(2) .chat-message-user .chat-message-user-bubble'
|
'.chat-message:nth-last-child(2) .chat-message-user .chat-message-user-bubble'
|
||||||
) as HTMLElement | null;
|
) as HTMLElement | null;
|
||||||
|
|
||||||
if (isMobile.current) {
|
if (deviceStore.isMobile) {
|
||||||
// Keep the last user message bubble just above the input on mobile
|
// Keep the last user message bubble just above the input on mobile
|
||||||
const bubbleHeight = lastUserBubble?.scrollHeight ?? 0;
|
const bubbleHeight = lastUserBubble?.scrollHeight ?? 0;
|
||||||
const baseHeight = container.scrollHeight - innerHeight;
|
const baseHeight = container.scrollHeight - innerHeight;
|
||||||
@@ -220,7 +219,7 @@
|
|||||||
}
|
}
|
||||||
}, 100);
|
}, 100);
|
||||||
|
|
||||||
if (isMobile.current) {
|
if (deviceStore.isMobile) {
|
||||||
autoScroll.setDisabled(disableAutoScroll);
|
autoScroll.setDisabled(disableAutoScroll);
|
||||||
mobileScrollDownHint = true;
|
mobileScrollDownHint = true;
|
||||||
mobileScrollDownHintLockedUntil = Date.now() + 500;
|
mobileScrollDownHintLockedUntil = Date.now() + 500;
|
||||||
@@ -243,7 +242,8 @@
|
|||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const shouldDisableAutoScroll =
|
const shouldDisableAutoScroll =
|
||||||
settingsStore.config.disableAutoScroll || (isMobile.current && isCurrentConversationLoading);
|
settingsStore.config.disableAutoScroll ||
|
||||||
|
(deviceStore.isMobile && isCurrentConversationLoading);
|
||||||
|
|
||||||
autoScroll.setDisabled(shouldDisableAutoScroll);
|
autoScroll.setDisabled(shouldDisableAutoScroll);
|
||||||
|
|
||||||
@@ -266,7 +266,7 @@
|
|||||||
autoScroll.enable();
|
autoScroll.enable();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isMobile.current && isCurrentConversationLoading) {
|
if (deviceStore.isMobile && isCurrentConversationLoading) {
|
||||||
mobileScrollDownHint = true;
|
mobileScrollDownHint = true;
|
||||||
mobileScrollDownHintLockedUntil = Date.now() + 500;
|
mobileScrollDownHintLockedUntil = Date.now() + 500;
|
||||||
}
|
}
|
||||||
@@ -318,9 +318,9 @@
|
|||||||
<div
|
<div
|
||||||
class={[
|
class={[
|
||||||
'pointer-events-none md:sticky fixed mt-auto transition-all duration-200',
|
'pointer-events-none md:sticky fixed mt-auto transition-all duration-200',
|
||||||
device.isStandalone
|
deviceStore.isStandalone
|
||||||
? 'bottom-6 right-4 left-4'
|
? 'bottom-6 right-4 left-4'
|
||||||
: device.isIOSSafari
|
: deviceStore.isIOSSafari
|
||||||
? 'bottom-1 left-2 right-2'
|
? 'bottom-1 left-2 right-2'
|
||||||
: 'bottom-2 right-2 left-2',
|
: 'bottom-2 right-2 left-2',
|
||||||
isEmpty ? 'md:bottom-[calc(50dvh-7rem)] 2xl:bottom-[calc(50dvh-4rem)]' : 'md:bottom-4'
|
isEmpty ? 'md:bottom-[calc(50dvh-7rem)] 2xl:bottom-[calc(50dvh-4rem)]' : 'md:bottom-4'
|
||||||
@@ -336,7 +336,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="pointer-events-none flex flex-col gap-6 items-center w-full">
|
<div class="pointer-events-none flex flex-col gap-6 items-center w-full">
|
||||||
{#if (isMobile.current ? mobileScrollDownHint || isMobileUserScrolledUp : autoScroll.userScrolledUp) && page.url.hash.includes(ROUTES.CHAT) && page.params.id}
|
{#if (deviceStore.isMobile ? mobileScrollDownHint || isMobileUserScrolledUp : autoScroll.userScrolledUp) && page.url.hash.includes(ROUTES.CHAT) && page.params.id}
|
||||||
<ChatScreenActionScrollDown
|
<ChatScreenActionScrollDown
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
mobileScrollDownHint = false;
|
mobileScrollDownHint = false;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { ChatForm } from '$lib/components/app';
|
import { ChatForm } from '$lib/components/app';
|
||||||
import { useDraftMessages } from '$lib/hooks/use-draft-messages.svelte';
|
import { useDraftMessages } from '$lib/hooks/use-draft-messages.svelte';
|
||||||
import { isMobile } from '$lib/stores';
|
import { deviceStore } from '$lib/stores';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -120,13 +120,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
if (!isMobile.current) {
|
if (!deviceStore.isMobile) {
|
||||||
setTimeout(focusFormUnlessCaptured, 100);
|
setTimeout(focusFormUnlessCaptured, 100);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
afterNavigate((navigation) => {
|
afterNavigate((navigation) => {
|
||||||
if (navigation?.from != null && !isMobile.current) {
|
if (navigation?.from != null && !deviceStore.isMobile) {
|
||||||
setTimeout(focusFormUnlessCaptured, 100);
|
setTimeout(focusFormUnlessCaptured, 100);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
+11
-11
@@ -14,7 +14,7 @@
|
|||||||
import { useKeyboardShortcuts } from '$lib/hooks/use-keyboard-shortcuts.svelte';
|
import { useKeyboardShortcuts } from '$lib/hooks/use-keyboard-shortcuts.svelte';
|
||||||
import { useMarqueeSelection } from '$lib/hooks/use-marquee-selection.svelte';
|
import { useMarqueeSelection } from '$lib/hooks/use-marquee-selection.svelte';
|
||||||
import { RouterService } from '$lib/services/router.service';
|
import { RouterService } from '$lib/services/router.service';
|
||||||
import { chatStore, conversationsStore, device, isMobile, settingsStore } from '$lib/stores';
|
import { chatStore, conversationsStore, deviceStore, settingsStore } from '$lib/stores';
|
||||||
import { buildConversationTree } from '$lib/utils';
|
import { buildConversationTree } from '$lib/utils';
|
||||||
import { circIn } from 'svelte/easing';
|
import { circIn } from 'svelte/easing';
|
||||||
import { SvelteSet } from 'svelte/reactivity';
|
import { SvelteSet } from 'svelte/reactivity';
|
||||||
@@ -36,7 +36,7 @@
|
|||||||
let logoHovered = $state(false);
|
let logoHovered = $state(false);
|
||||||
|
|
||||||
const isStripExpanded = $derived(isExpandedMode || hoveredTooltip !== null);
|
const isStripExpanded = $derived(isExpandedMode || hoveredTooltip !== null);
|
||||||
const isOnMobile = $derived(isMobile.current);
|
const isOnMobile = $derived(deviceStore.isMobile);
|
||||||
const alwaysShowOnDesktop = $derived(settingsStore.config.alwaysShowSidebarOnDesktop as boolean);
|
const alwaysShowOnDesktop = $derived(settingsStore.config.alwaysShowSidebarOnDesktop as boolean);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -65,7 +65,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (isMobile.current && page.url.hash.includes(ROUTES.SEARCH)) {
|
if (deviceStore.isMobile && page.url.hash.includes(ROUTES.SEARCH)) {
|
||||||
isExpandedMode = false;
|
isExpandedMode = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -227,7 +227,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function selectConversation(id: string) {
|
async function selectConversation(id: string) {
|
||||||
if (isMobile.current) {
|
if (deviceStore.isMobile) {
|
||||||
scheduleMobileCollapse();
|
scheduleMobileCollapse();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -315,9 +315,9 @@
|
|||||||
'fixed md:sticky top-2 left-2 md:left-0 md:ml-2 md:mt-2 pt-2 z-10 w-[calc(100dvw-1rem)]',
|
'fixed md:sticky top-2 left-2 md:left-0 md:ml-2 md:mt-2 pt-2 z-10 w-[calc(100dvw-1rem)]',
|
||||||
'md:h-[calc(100dvh-1.125rem)]',
|
'md:h-[calc(100dvh-1.125rem)]',
|
||||||
isExpandedMode &&
|
isExpandedMode &&
|
||||||
(device.isStandalone
|
(deviceStore.isStandalone
|
||||||
? 'h-[calc(100dvh-2rem)]'
|
? 'h-[calc(100dvh-2rem)]'
|
||||||
: device.isIOSDevice
|
: deviceStore.isIOSDevice
|
||||||
? 'h-[calc(100dvh-0.5rem)]'
|
? 'h-[calc(100dvh-0.5rem)]'
|
||||||
: 'h-[calc(100dvh-1rem)]'),
|
: 'h-[calc(100dvh-1rem)]'),
|
||||||
'rounded-3xl md:rounded-2xl',
|
'rounded-3xl md:rounded-2xl',
|
||||||
@@ -353,7 +353,7 @@
|
|||||||
|
|
||||||
{#if isOnMobile || (isExpandedMode && !alwaysShowOnDesktop)}
|
{#if isOnMobile || (isExpandedMode && !alwaysShowOnDesktop)}
|
||||||
<div
|
<div
|
||||||
class="flex items-center transition-all duration-150 ease-out {isMobile.current &&
|
class="flex items-center transition-all duration-150 ease-out {deviceStore.isMobile &&
|
||||||
!isExpandedMode
|
!isExpandedMode
|
||||||
? 'opacity-0 h-0!'
|
? 'opacity-0 h-0!'
|
||||||
: ''}"
|
: ''}"
|
||||||
@@ -361,7 +361,7 @@
|
|||||||
out:fade={{ duration: 100 }}
|
out:fade={{ duration: 100 }}
|
||||||
>
|
>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
icon={isMobile.current ? X : PanelLeftClose}
|
icon={deviceStore.isMobile ? X : PanelLeftClose}
|
||||||
size="lg"
|
size="lg"
|
||||||
iconSize="h-4.5 w-4.5 md:h-4 md:w-4"
|
iconSize="h-4.5 w-4.5 md:h-4 md:w-4"
|
||||||
class="backdrop-blur-none md:h-9 md:w-9 h-10 w-10 rounded-full mr-1 hover:bg-accent!"
|
class="backdrop-blur-none md:h-9 md:w-9 h-10 w-10 rounded-full mr-1 hover:bg-accent!"
|
||||||
@@ -375,9 +375,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="mt-2 flex min-h-0 flex-1 flex-col gap-4 md:gap-1 {isMobile.current
|
class="mt-2 flex min-h-0 flex-1 flex-col gap-4 md:gap-1 {deviceStore.isMobile
|
||||||
? 'transition-[opacity,height] duration-200 ease-out'
|
? 'transition-[opacity,height] duration-200 ease-out'
|
||||||
: ''} {isMobile.current && !isExpandedMode ? 'opacity-0 !h-0' : ''}"
|
: ''} {deviceStore.isMobile && !isExpandedMode ? 'opacity-0 !h-0' : ''}"
|
||||||
in:fade={{ duration: 200 }}
|
in:fade={{ duration: 200 }}
|
||||||
out:fade={{ duration: 200 }}
|
out:fade={{ duration: 200 }}
|
||||||
>
|
>
|
||||||
@@ -395,7 +395,7 @@
|
|||||||
isSearchModeActive = true;
|
isSearchModeActive = true;
|
||||||
}}
|
}}
|
||||||
onNewChat={() => {
|
onNewChat={() => {
|
||||||
if (isMobile.current) {
|
if (deviceStore.isMobile) {
|
||||||
scheduleMobileCollapse();
|
scheduleMobileCollapse();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|||||||
+4
-4
@@ -12,7 +12,7 @@
|
|||||||
SIDEBAR_ACTIONS_ITEMS
|
SIDEBAR_ACTIONS_ITEMS
|
||||||
} from '$lib/constants';
|
} from '$lib/constants';
|
||||||
import { TooltipSide } from '$lib/enums';
|
import { TooltipSide } from '$lib/enums';
|
||||||
import { isMobile } from '$lib/stores';
|
import { deviceStore } from '$lib/stores';
|
||||||
import type { Component } from 'svelte';
|
import type { Component } from 'svelte';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { circIn } from 'svelte/easing';
|
import { circIn } from 'svelte/easing';
|
||||||
@@ -42,7 +42,7 @@
|
|||||||
let showIcons = $state(false);
|
let showIcons = $state(false);
|
||||||
let searchInputRef = $state<HTMLInputElement | null>(null);
|
let searchInputRef = $state<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
const isOnMobile = $derived(isMobile.current);
|
const isOnMobile = $derived(deviceStore.isMobile);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (isSearchModeActive && searchInputRef) {
|
if (isSearchModeActive && searchInputRef) {
|
||||||
@@ -107,7 +107,7 @@
|
|||||||
>
|
>
|
||||||
{#each SIDEBAR_ACTIONS_ITEMS as item, i (item.tooltip)}
|
{#each SIDEBAR_ACTIONS_ITEMS as item, i (item.tooltip)}
|
||||||
{@const isActive = isItemActive(item)}
|
{@const isActive = isItemActive(item)}
|
||||||
{@const isSearchOnMobile = item.icon === Search && isMobile.current}
|
{@const isSearchOnMobile = item.icon === Search && deviceStore.isMobile}
|
||||||
{@const itemHref = isSearchOnMobile ? ROUTES.SEARCH : item.route}
|
{@const itemHref = isSearchOnMobile ? ROUTES.SEARCH : item.route}
|
||||||
{@const itemOnClick = item.route
|
{@const itemOnClick = item.route
|
||||||
? () => {
|
? () => {
|
||||||
@@ -156,7 +156,7 @@
|
|||||||
<div class="{className} flex-col gap-1 hidden md:flex">
|
<div class="{className} flex-col gap-1 hidden md:flex">
|
||||||
{#each SIDEBAR_ACTIONS_ITEMS as item, i (item.tooltip)}
|
{#each SIDEBAR_ACTIONS_ITEMS as item, i (item.tooltip)}
|
||||||
{@const isActive = isItemActive(item)}
|
{@const isActive = isItemActive(item)}
|
||||||
{@const isSearchOnMobile = item.icon === Search && isMobile.current}
|
{@const isSearchOnMobile = item.icon === Search && deviceStore.isMobile}
|
||||||
{@const itemOnClick = item.route
|
{@const itemOnClick = item.route
|
||||||
? () => {
|
? () => {
|
||||||
onNewChat?.();
|
onNewChat?.();
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export function usePwa() {
|
|||||||
// PWA pages update via the service worker path; the storage check is the non-PWA fallback only
|
// PWA pages update via the service worker path; the storage check is the non-PWA fallback only
|
||||||
if (navigator.serviceWorker?.controller) return;
|
if (navigator.serviceWorker?.controller) return;
|
||||||
|
|
||||||
const currentVersion = versionStore.value;
|
const currentVersion = versionStore.frontend;
|
||||||
|
|
||||||
if (!currentVersion) return;
|
if (!currentVersion) return;
|
||||||
|
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
/**
|
|
||||||
* buildInfoStore - llama.cpp build information
|
|
||||||
*
|
|
||||||
* Reads the build version from `build.json` — embedded at llama.cpp build time
|
|
||||||
* with the llama.cpp build number (LLAMA_BUILD_NUMBER). Shown in the UI when
|
|
||||||
* `showBuildVersion` is enabled.
|
|
||||||
*
|
|
||||||
* In dev mode (via `npm run dev`), falls back to `import.meta.env.DEV`'s truthy
|
|
||||||
* value since the artifact is not produced.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { browser } from '$app/environment';
|
|
||||||
import { base } from '$app/paths';
|
|
||||||
|
|
||||||
let build = $state<string>('');
|
|
||||||
|
|
||||||
async function loadBuild() {
|
|
||||||
if (!browser) return;
|
|
||||||
|
|
||||||
if (import.meta.env.DEV) {
|
|
||||||
build = 'dev';
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${base}/build.json`, { cache: 'no-store' });
|
|
||||||
|
|
||||||
if (res.ok) {
|
|
||||||
const data = await res.json();
|
|
||||||
|
|
||||||
build = data.version ?? '';
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// build.json missing or unreachable - leave as empty string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
loadBuild();
|
|
||||||
|
|
||||||
export const buildInfoStore = {
|
|
||||||
get value(): string {
|
|
||||||
return build;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -109,9 +109,6 @@ class ChatStore {
|
|||||||
private isEditModeActive = $state(false);
|
private isEditModeActive = $state(false);
|
||||||
private addFilesHandler: ((files: File[]) => void) | null = $state(null);
|
private addFilesHandler: ((files: File[]) => void) | null = $state(null);
|
||||||
pendingEditMessageId = $state<string | null>(null);
|
pendingEditMessageId = $state<string | null>(null);
|
||||||
private messageUpdateCallback:
|
|
||||||
| ((messageId: string, updates: Partial<DatabaseMessage>) => void)
|
|
||||||
| null = null;
|
|
||||||
private _pendingDraftMessage = $state<string>('');
|
private _pendingDraftMessage = $state<string>('');
|
||||||
private _pendingDraftFiles = $state<ChatUploadedFile[]>([]);
|
private _pendingDraftFiles = $state<ChatUploadedFile[]>([]);
|
||||||
|
|
||||||
|
|||||||
@@ -100,14 +100,6 @@ class ConversationsStore {
|
|||||||
localStorage.setItem(REASONING_EFFORT_DEFAULT_LOCALSTORAGE_KEY, this.pendingReasoningEffort);
|
localStorage.setItem(REASONING_EFFORT_DEFAULT_LOCALSTORAGE_KEY, this.pendingReasoningEffort);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Callback for updating message content in chatStore.
|
|
||||||
* Registered by chatStore to enable cross-store updates without circular dependency.
|
|
||||||
*/
|
|
||||||
private messageUpdateCallback:
|
|
||||||
| ((messageId: string, updates: Partial<DatabaseMessage>) => void)
|
|
||||||
| null = null;
|
|
||||||
|
|
||||||
/** In-flight init run; shared by concurrent callers, reset on failure to allow retry */
|
/** In-flight init run; shared by concurrent callers, reset on failure to allow retry */
|
||||||
private initPromise: Promise<void> | null = null;
|
private initPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
@@ -143,23 +135,6 @@ class ConversationsStore {
|
|||||||
return this.initPromise;
|
return this.initPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Alias for init() for backward compatibility.
|
|
||||||
*/
|
|
||||||
async initialize(): Promise<void> {
|
|
||||||
return this.init();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Register a callback for message updates from other stores.
|
|
||||||
* Called by chatStore during initialization.
|
|
||||||
*/
|
|
||||||
registerMessageUpdateCallback(
|
|
||||||
callback: (messageId: string, updates: Partial<DatabaseMessage>) => void
|
|
||||||
): void {
|
|
||||||
this.messageUpdateCallback = callback;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1,5 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* deviceStore - Browser environment signals
|
||||||
|
*
|
||||||
|
* Device capabilities, OS theme and viewport in one class store:
|
||||||
|
* deviceStore.isMobile, deviceStore.isIOSDevice / isIOSSafari / isWKWebView /
|
||||||
|
* isStandalone, deviceStore.systemTheme.isDark.
|
||||||
|
*
|
||||||
|
* UA-derived flags are static for the session; isStandalone and systemTheme
|
||||||
|
* track live media query changes.
|
||||||
|
*/
|
||||||
|
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import { MEDIA_QUERIES } from '$lib/constants';
|
import { DEFAULT_MOBILE_BREAKPOINT, MEDIA_QUERIES } from '$lib/constants';
|
||||||
|
import { MediaQuery } from 'svelte/reactivity';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* iOS UA token detection.
|
* iOS UA token detection.
|
||||||
@@ -17,53 +29,60 @@ const UA_PATTERNS = {
|
|||||||
WEBVIEW_IOS: /CriOS|FxiOS|EdgiOS|GSA/
|
WEBVIEW_IOS: /CriOS|FxiOS|EdgiOS|GSA/
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
interface DeviceContext {
|
class DeviceStore {
|
||||||
/** Any iOS/iPadOS device, regardless of which app or browser embeds the page. */
|
/** Any iOS/iPadOS device, regardless of which app or browser embeds the page. */
|
||||||
isIOSDevice: boolean;
|
readonly isIOSDevice: boolean = false;
|
||||||
/** The Safari browser app on iOS, excluding other iOS browsers and WKWebViews. */
|
/** The Safari browser app on iOS, excluding other iOS browsers and WKWebViews. */
|
||||||
isIOSSafari: boolean;
|
readonly isIOSSafari: boolean = false;
|
||||||
/** Any WKWebView context on iOS: in-app browsers, embedded web views, and the
|
/** Any WKWebView context on iOS: in-app browsers, embedded web views, and the
|
||||||
* third-party iOS browsers (all of which share the WKWebView engine). */
|
* third-party iOS browsers (all of which share the WKWebView engine). */
|
||||||
isWKWebView: boolean;
|
readonly isWKWebView: boolean = false;
|
||||||
/** PWA standalone mode: the page was launched from the home screen icon. */
|
/** PWA standalone mode: the page was launched from the home screen icon. */
|
||||||
isStandalone: boolean;
|
isStandalone = $state(false);
|
||||||
|
/** OS color scheme preference; the user override lives in settingsStore. */
|
||||||
|
readonly systemTheme = $state({ isDark: false });
|
||||||
|
|
||||||
|
private mobile = new MediaQuery(`max-width: ${DEFAULT_MOBILE_BREAKPOINT - 1}px`);
|
||||||
|
|
||||||
|
get isMobile(): boolean {
|
||||||
|
return this.mobile.current;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SERVER_DEFAULT: DeviceContext = {
|
constructor() {
|
||||||
isIOSDevice: false,
|
if (!browser) return;
|
||||||
isIOSSafari: false,
|
|
||||||
isStandalone: false,
|
|
||||||
isWKWebView: false
|
|
||||||
};
|
|
||||||
|
|
||||||
function detect(): DeviceContext {
|
|
||||||
if (!browser) return SERVER_DEFAULT;
|
|
||||||
|
|
||||||
const ua = navigator.userAgent;
|
const ua = navigator.userAgent;
|
||||||
const isTouch = navigator.maxTouchPoints > 0;
|
const isTouch = navigator.maxTouchPoints > 0;
|
||||||
const isIOSDevice = UA_PATTERNS.IOS_PHONE.test(ua) || (UA_PATTERNS.MACINTOSH.test(ua) && isTouch);
|
|
||||||
|
this.isIOSDevice =
|
||||||
|
UA_PATTERNS.IOS_PHONE.test(ua) || (UA_PATTERNS.MACINTOSH.test(ua) && isTouch);
|
||||||
// Safari keeps 'Safari/' in the UA; non-Safari iOS browsers emit their own
|
// Safari keeps 'Safari/' in the UA; non-Safari iOS browsers emit their own
|
||||||
// token instead. WKWebView typically omits 'Safari/' entirely.
|
// token instead. WKWebView typically omits 'Safari/' entirely.
|
||||||
const hasSafariToken = UA_PATTERNS.SAFARI.test(ua) && !UA_PATTERNS.WEBVIEW_IOS.test(ua);
|
const hasSafariToken = UA_PATTERNS.SAFARI.test(ua) && !UA_PATTERNS.WEBVIEW_IOS.test(ua);
|
||||||
const isIOSSafari = isIOSDevice && hasSafariToken;
|
|
||||||
const isWKWebView = isIOSDevice && !hasSafariToken;
|
this.isIOSSafari = this.isIOSDevice && hasSafariToken;
|
||||||
|
this.isWKWebView = this.isIOSDevice && !hasSafariToken;
|
||||||
// navigator.standalone is the legacy iOS-only flag (deprecated but still
|
// navigator.standalone is the legacy iOS-only flag (deprecated but still
|
||||||
// present); display-mode: standalone is the modern standard (Safari 16.4+).
|
// present); display-mode: standalone is the modern standard (Safari 16.4+).
|
||||||
const isStandalone =
|
this.isStandalone =
|
||||||
window.matchMedia(MEDIA_QUERIES.DISPLAY_MODE_STANDALONE).matches ||
|
window.matchMedia(MEDIA_QUERIES.DISPLAY_MODE_STANDALONE).matches ||
|
||||||
(navigator as Navigator & { standalone?: boolean }).standalone === true;
|
(navigator as Navigator & { standalone?: boolean }).standalone === true;
|
||||||
|
this.systemTheme.isDark = window.matchMedia(MEDIA_QUERIES.PREFERS_DARK).matches;
|
||||||
|
|
||||||
return { isIOSDevice, isIOSSafari, isStandalone, isWKWebView };
|
// isStandalone and systemTheme can change at runtime (e.g. user installs the
|
||||||
}
|
// PWA while the tab is open); the UA-derived flags are static for the session
|
||||||
|
const standaloneMql = window.matchMedia(MEDIA_QUERIES.DISPLAY_MODE_STANDALONE);
|
||||||
|
|
||||||
export const device = $state<DeviceContext>(detect());
|
standaloneMql.addEventListener('change', (e) => {
|
||||||
|
this.isStandalone = e.matches;
|
||||||
|
});
|
||||||
|
|
||||||
if (browser) {
|
const darkMql = window.matchMedia(MEDIA_QUERIES.PREFERS_DARK);
|
||||||
// isStandalone can change at runtime (e.g. user installs the PWA while the
|
|
||||||
// tab is open); the UA-derived flags are static for the session.
|
|
||||||
const mql = window.matchMedia(MEDIA_QUERIES.DISPLAY_MODE_STANDALONE);
|
|
||||||
|
|
||||||
mql.addEventListener('change', (e) => {
|
darkMql.addEventListener('change', (e) => {
|
||||||
device.isStandalone = e.matches;
|
this.systemTheme.isDark = e.matches;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const deviceStore = new DeviceStore();
|
||||||
|
|||||||
@@ -53,26 +53,6 @@ export { permissionsStore } from './permissions.svelte';
|
|||||||
export { toolsStore } from './tools.svelte';
|
export { toolsStore } from './tools.svelte';
|
||||||
|
|
||||||
// ENVIRONMENT / META
|
// ENVIRONMENT / META
|
||||||
export { buildInfoStore } from './build-info.svelte';
|
|
||||||
|
|
||||||
export { versionStore } from './version.svelte';
|
export { versionStore } from './version.svelte';
|
||||||
|
|
||||||
export { device } from './device.svelte';
|
export { deviceStore } from './device.svelte';
|
||||||
|
|
||||||
export { viewport, isMobile } from './viewport.svelte';
|
|
||||||
|
|
||||||
export { theme } from './theme.svelte';
|
|
||||||
|
|
||||||
export {
|
|
||||||
gaugePopup,
|
|
||||||
gaugePopupClose,
|
|
||||||
gaugeTriggerPointerDown,
|
|
||||||
gaugeTriggerClick,
|
|
||||||
gaugeTriggerKeydown,
|
|
||||||
gaugeTriggerEnter,
|
|
||||||
gaugeTriggerLeave,
|
|
||||||
gaugeCardEnter,
|
|
||||||
gaugeCardLeave
|
|
||||||
} from './context-gauge-popup.svelte';
|
|
||||||
|
|
||||||
export { persisted } from './persisted.svelte';
|
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
import { browser } from '$app/environment';
|
|
||||||
|
|
||||||
type PersistedValue<T> = {
|
|
||||||
get value(): T;
|
|
||||||
set value(newValue: T);
|
|
||||||
};
|
|
||||||
|
|
||||||
export function persisted<T>(key: string, initialValue: T): PersistedValue<T> {
|
|
||||||
let value = initialValue;
|
|
||||||
|
|
||||||
if (browser) {
|
|
||||||
try {
|
|
||||||
const stored = localStorage.getItem(key);
|
|
||||||
|
|
||||||
if (stored !== null) {
|
|
||||||
value = JSON.parse(stored) as T;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.warn(`Failed to load ${key}:`, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const persist = (next: T) => {
|
|
||||||
if (!browser) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (next === null || next === undefined) {
|
|
||||||
localStorage.removeItem(key);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
localStorage.setItem(key, JSON.stringify(next));
|
|
||||||
} catch (error) {
|
|
||||||
console.warn(`Failed to persist ${key}:`, error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
get value() {
|
|
||||||
return value;
|
|
||||||
},
|
|
||||||
|
|
||||||
set value(newValue: T) {
|
|
||||||
value = newValue;
|
|
||||||
persist(newValue);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -40,9 +40,9 @@ import {
|
|||||||
} from '$lib/constants';
|
} from '$lib/constants';
|
||||||
import { ColorMode } from '$lib/enums';
|
import { ColorMode } from '$lib/enums';
|
||||||
import { ParameterSyncService } from '$lib/services/parameter-sync.service';
|
import { ParameterSyncService } from '$lib/services/parameter-sync.service';
|
||||||
|
import { deviceStore } from '$lib/stores/device.svelte';
|
||||||
// direct imports between stores, not via the barrel, to avoid circular deps
|
// direct imports between stores, not via the barrel, to avoid circular deps
|
||||||
import { serverStore } from '$lib/stores/server.svelte';
|
import { serverStore } from '$lib/stores/server.svelte';
|
||||||
import { isMobile } from '$lib/stores/viewport.svelte';
|
|
||||||
import type { SettingsExportType } from '$lib/types';
|
import type { SettingsExportType } from '$lib/types';
|
||||||
import {
|
import {
|
||||||
configToParameterRecord,
|
configToParameterRecord,
|
||||||
@@ -138,7 +138,7 @@ class SettingsStore {
|
|||||||
|
|
||||||
// Default sendOnEnter to false on mobile when the user has no saved preference
|
// Default sendOnEnter to false on mobile when the user has no saved preference
|
||||||
if (!(SETTINGS_KEYS.SEND_ON_ENTER in savedVal)) {
|
if (!(SETTINGS_KEYS.SEND_ON_ENTER in savedVal)) {
|
||||||
if (isMobile.current) {
|
if (deviceStore.isMobile) {
|
||||||
this.config[SETTINGS_KEYS.SEND_ON_ENTER] = false;
|
this.config[SETTINGS_KEYS.SEND_ON_ENTER] = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
import { browser } from '$app/environment';
|
|
||||||
import { MEDIA_QUERIES } from '$lib/constants';
|
|
||||||
|
|
||||||
export const theme = $state({
|
|
||||||
isSystemDark: browser && window.matchMedia(MEDIA_QUERIES.PREFERS_DARK).matches
|
|
||||||
});
|
|
||||||
|
|
||||||
if (browser) {
|
|
||||||
const mql = window.matchMedia(MEDIA_QUERIES.PREFERS_DARK);
|
|
||||||
|
|
||||||
mql.addEventListener('change', (e) => {
|
|
||||||
theme.isSystemDark = e.matches;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,44 +1,61 @@
|
|||||||
/**
|
/**
|
||||||
* versionStore - Frontend build version
|
* versionStore - Build version information
|
||||||
*
|
*
|
||||||
* Reads from SvelteKit's `_app/version.json` — generated by the @vite-pwa/sveltekit
|
* - `build`: llama.cpp build number from `build.json`, embedded at llama.cpp
|
||||||
* plugin. The version string changes on every build, so comparing it against
|
* build time (LLAMA_BUILD_NUMBER). Shown in the UI when `showBuildVersion`
|
||||||
* localStorage reliably detects server upgrades.
|
* is enabled.
|
||||||
|
* - `frontend`: frontend build version from SvelteKit's `_app/version.json`,
|
||||||
|
* generated by the @vite-pwa/sveltekit plugin. Changes on every build, so
|
||||||
|
* comparing it against localStorage reliably detects server upgrades.
|
||||||
*
|
*
|
||||||
* In dev mode, falls back to `'dev'`.
|
* In dev mode both fall back to `'dev'`.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import { base } from '$app/paths';
|
import { base } from '$app/paths';
|
||||||
|
|
||||||
let version = $state<string>('');
|
class VersionStore {
|
||||||
|
build = $state<string>('');
|
||||||
|
frontend = $state<string>('');
|
||||||
|
|
||||||
async function loadVersion() {
|
constructor() {
|
||||||
if (!browser) return;
|
if (!browser) return;
|
||||||
|
|
||||||
if (import.meta.env.DEV) {
|
if (import.meta.env.DEV) {
|
||||||
version = 'dev';
|
this.build = 'dev';
|
||||||
|
this.frontend = 'dev';
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async load(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${base}/build.json`, { cache: 'no-store' });
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
this.build = data.version ?? '';
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// build.json missing or unreachable - leave as empty string
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${base}/_app/version.json`, { cache: 'no-store' });
|
const res = await fetch(`${base}/_app/version.json`, { cache: 'no-store' });
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
version = data.version ?? '';
|
this.frontend = data.version ?? '';
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// _app/version.json missing or unreachable - leave as empty string
|
// version.json missing or unreachable - leave as empty string
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
loadVersion();
|
export const versionStore = new VersionStore();
|
||||||
|
|
||||||
export const versionStore = {
|
|
||||||
get value(): string {
|
|
||||||
return version;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
import { browser } from '$app/environment';
|
|
||||||
import { DEFAULT_MOBILE_BREAKPOINT } from '$lib/constants';
|
|
||||||
import { MediaQuery } from 'svelte/reactivity';
|
|
||||||
|
|
||||||
export const viewport = $state({
|
|
||||||
width: browser ? window.innerWidth : 0
|
|
||||||
});
|
|
||||||
|
|
||||||
export const isMobile = new MediaQuery(`max-width: ${DEFAULT_MOBILE_BREAKPOINT - 1}px`);
|
|
||||||
@@ -77,7 +77,7 @@
|
|||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
if (!conversationsStore.isInitialized) {
|
if (!conversationsStore.isInitialized) {
|
||||||
await conversationsStore.initialize();
|
await conversationsStore.init();
|
||||||
}
|
}
|
||||||
|
|
||||||
conversationsStore.clearActiveConversation();
|
conversationsStore.clearActiveConversation();
|
||||||
|
|||||||
@@ -19,15 +19,14 @@
|
|||||||
import { usePwa } from '$lib/hooks/use-pwa.svelte';
|
import { usePwa } from '$lib/hooks/use-pwa.svelte';
|
||||||
import { RouterService } from '$lib/services/router.service';
|
import { RouterService } from '$lib/services/router.service';
|
||||||
import {
|
import {
|
||||||
buildInfoStore,
|
|
||||||
chatStore,
|
chatStore,
|
||||||
conversationsStore,
|
conversationsStore,
|
||||||
isMobile,
|
deviceStore,
|
||||||
mcpStore,
|
mcpStore,
|
||||||
modelsStore,
|
modelsStore,
|
||||||
serverStore,
|
serverStore,
|
||||||
settingsStore,
|
settingsStore,
|
||||||
theme
|
versionStore
|
||||||
} from '$lib/stores';
|
} from '$lib/stores';
|
||||||
import { ModeWatcher } from 'mode-watcher';
|
import { ModeWatcher } from 'mode-watcher';
|
||||||
import { untrack } from 'svelte';
|
import { untrack } from 'svelte';
|
||||||
@@ -55,7 +54,7 @@
|
|||||||
const { needRefresh, updateServiceWorker } = pwa;
|
const { needRefresh, updateServiceWorker } = pwa;
|
||||||
|
|
||||||
function updateFavicon() {
|
function updateFavicon() {
|
||||||
const dark = theme.isSystemDark;
|
const dark = deviceStore.systemTheme.isDark;
|
||||||
|
|
||||||
let icoLink = document.querySelector(FAVICON_SELECTORS.ICO_48X48) as HTMLLinkElement | null;
|
let icoLink = document.querySelector(FAVICON_SELECTORS.ICO_48X48) as HTMLLinkElement | null;
|
||||||
|
|
||||||
@@ -153,7 +152,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
void theme.isSystemDark;
|
void deviceStore.systemTheme.isDark;
|
||||||
|
|
||||||
updateFavicon();
|
updateFavicon();
|
||||||
});
|
});
|
||||||
@@ -274,7 +273,7 @@
|
|||||||
<div class="flex flex-col md:flex-row">
|
<div class="flex flex-col md:flex-row">
|
||||||
<SidebarNavigation
|
<SidebarNavigation
|
||||||
onSearchClick={() => {
|
onSearchClick={() => {
|
||||||
if (isMobile.current) {
|
if (deviceStore.isMobile) {
|
||||||
goto(ROUTES.SEARCH);
|
goto(ROUTES.SEARCH);
|
||||||
} else if (chatSidebar?.activateSearchMode) {
|
} else if (chatSidebar?.activateSearchMode) {
|
||||||
chatSidebar.activateSearchMode();
|
chatSidebar.activateSearchMode();
|
||||||
@@ -294,8 +293,8 @@
|
|||||||
|
|
||||||
<!-- PWA update prompt + version -->
|
<!-- PWA update prompt + version -->
|
||||||
<div class="fixed right-4 bottom-4 z-9999 flex flex-col items-end gap-1">
|
<div class="fixed right-4 bottom-4 z-9999 flex flex-col items-end gap-1">
|
||||||
{#if showBuildVersion && buildInfoStore.value}
|
{#if showBuildVersion && versionStore.build}
|
||||||
<span class="text-[10px] tabular-nums text-muted-foreground">{buildInfoStore.value}</span>
|
<span class="text-[10px] tabular-nums text-muted-foreground">{versionStore.build}</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<PwaRefreshAlert
|
<PwaRefreshAlert
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
import { SearchInput, SidebarNavigationSearchResults } from '$lib/components/app';
|
import { SearchInput, SidebarNavigationSearchResults } from '$lib/components/app';
|
||||||
import { ROUTES } from '$lib/constants';
|
import { ROUTES } from '$lib/constants';
|
||||||
import { RouterService } from '$lib/services/router.service';
|
import { RouterService } from '$lib/services/router.service';
|
||||||
import { chatStore, conversationsStore, isMobile } from '$lib/stores';
|
import { chatStore, conversationsStore, deviceStore } from '$lib/stores';
|
||||||
|
|
||||||
let searchQuery = $state('');
|
let searchQuery = $state('');
|
||||||
let searchInputRef = $state<HTMLInputElement | null>(null);
|
let searchInputRef = $state<HTMLInputElement | null>(null);
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
// Search page is intended for mobile; on desktop the sidebar already exposes
|
// Search page is intended for mobile; on desktop the sidebar already exposes
|
||||||
// in-place search, so bounce back to a chat.
|
// in-place search, so bounce back to a chat.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (browser && !isMobile.current) {
|
if (browser && !deviceStore.isMobile) {
|
||||||
goto(ROUTES.NEW_CHAT, { replaceState: true });
|
goto(ROUTES.NEW_CHAT, { replaceState: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user