Merge branch 'upstream' into concedo_experimental

# Conflicts:
#	.devops/openvino.Dockerfile
#	.github/actions/windows-setup-cuda/action.yml
#	.github/workflows/build-cache.yml
#	.github/workflows/build-cpu.yml
#	.github/workflows/build-cuda-windows.yml
#	.github/workflows/build-openvino.yml
#	.github/workflows/build-self-hosted.yml
#	.github/workflows/build-vulkan.yml
#	.github/workflows/docker.yml
#	.github/workflows/make-release.yml
#	.github/workflows/release.yml
#	AUTHORS
#	CMakeLists.txt
#	README.md
#	build-xcframework.sh
#	ci/run.sh
#	common/CMakeLists.txt
#	docs/backend/OPENVINO.md
#	examples/gguf-hash/CMakeLists.txt
#	examples/gguf-hash/gguf-hash.cpp
#	ggml/CMakeLists.txt
#	ggml/src/ggml-cann/ggml-cann.cpp
#	ggml/src/ggml-et/ggml-et.cpp
#	ggml/src/ggml-hexagon/ggml-hexagon.cpp
#	ggml/src/ggml-hexagon/htp/flash-attn-ops.c
#	ggml/src/ggml-hexagon/htp/flash-attn-ops.h
#	ggml/src/ggml-opencl/CMakeLists.txt
#	ggml/src/ggml-opencl/ggml-opencl.cpp
#	ggml/src/ggml-opencl/kernels/flash_attn_f16.cl
#	ggml/src/ggml-opencl/kernels/flash_attn_f32.cl
#	ggml/src/ggml-opencl/kernels/moe_sort_by_expert.cl
#	ggml/src/ggml-openvino/ggml-openvino.cpp
#	ggml/src/ggml-sycl/ggml-sycl.cpp
#	ggml/src/ggml-webgpu/ggml-webgpu-shader-lib.hpp
#	ggml/src/ggml-webgpu/ggml-webgpu.cpp
#	ggml/src/ggml-webgpu/wgsl-shaders/common_decls.tmpl
#	ggml/src/ggml-webgpu/wgsl-shaders/mul_mat_decls.tmpl
#	ggml/src/ggml-webgpu/wgsl-shaders/mul_mat_reg_tile.wgsl
#	ggml/src/ggml-webgpu/wgsl-shaders/mul_mat_subgroup_matrix.wgsl
#	ggml/src/ggml-webgpu/wgsl-shaders/mul_mat_vec.wgsl
#	ggml/src/ggml-webgpu/wgsl-shaders/mul_mat_vec_acc.tmpl
#	scripts/sync-ggml.last
#	tests/CMakeLists.txt
#	tests/test-backend-ops.cpp
#	tests/test-llama-archs.cpp
#	tools/mtmd/CMakeLists.txt
#	tools/mtmd/mtmd-helper.cpp
#	tools/perplexity/perplexity.cpp
#	tools/server/README.md
#	tools/ui/src/lib/hooks/use-tools-panel.svelte.ts
#	vendor/hash/CMakeLists.txt
This commit is contained in:
Concedo
2026-08-21 23:27:27 +08:00
183 changed files with 3138 additions and 1849 deletions
@@ -48,6 +48,7 @@
containsFileMentionLink,
findCommandToken,
findMentionToken,
getConversationModel,
isIMEComposing,
isOffsetInCodeBlock,
parseClipboardContent,
@@ -190,31 +191,9 @@
let isRouter = $derived(serverStore.isRouterMode);
let conversationModel = $derived(
chatStore.getConversationModel(conversationsStore.activeMessages as DatabaseMessage[])
getConversationModel(conversationsStore.activeMessages as DatabaseMessage[])
);
let activeModelId = $derived.by(() => {
const options = modelsStore.models;
if (!isRouter) {
return options.length > 0 ? options[0].model : null;
}
const selectedId = modelsStore.selectedModelId;
if (selectedId) {
const model = options.find((m) => m.id === selectedId);
if (model) return model.model;
}
if (conversationModel) {
const model = options.find((m) => m.model === conversationModel);
if (model) return model.model;
}
return null;
});
let activeModelId = $derived(modelsStore.activeModelId);
let hasModelSelected = $derived(
!isRouter || !!conversationModel || !!modelsStore.selectedModelId
@@ -35,7 +35,7 @@
<span>
Run llama-server with <code>{CLI_FLAGS.TOOLS}</code> flag to enable
<strong>Built-in Tools</strong>.
<strong>Server Tools</strong>.
</span>
</span>
@@ -2,10 +2,10 @@
import ChatFormActionAddButton from './ChatFormActionAddButton.svelte';
import ChatFormActionAddDropdown from './ChatFormActionAddDropdown.svelte';
import ChatFormActionAddSheet from './ChatFormActionAddSheet.svelte';
import { isMobile } from '$lib/stores';
import { deviceStore } from '$lib/stores';
</script>
{#if isMobile.current}
{#if deviceStore.isMobile}
<ChatFormActionAddSheet>
{#snippet trigger({ disabled, onclick })}
<ChatFormActionAddButton {disabled} {onclick} />
@@ -1,6 +1,7 @@
<script lang="ts">
import { ModelsSelectorDropdown, ModelsSelectorSheet } from '$lib/components/app';
import { chatStore, conversationsStore, isMobile, modelsStore, serverStore } from '$lib/stores';
import { conversationsStore, deviceStore, modelsStore, serverStore } from '$lib/stores';
import { getConversationModel } from '$lib/utils';
interface Props {
disabled?: boolean;
@@ -30,7 +31,7 @@
let isOffline = $derived(!!serverStore.error);
let conversationModel = $derived(
chatStore.getConversationModel(conversationsStore.activeMessages as DatabaseMessage[])
getConversationModel(conversationsStore.activeMessages as DatabaseMessage[])
);
let lastSyncedConversationModel: string | null = null;
@@ -74,29 +75,7 @@
}
});
let activeModelId = $derived.by(() => {
const options = modelsStore.models;
if (!isRouter) {
return options.length > 0 ? options[0].model : null;
}
const selectedId = modelsStore.selectedModelId;
if (selectedId) {
const model = options.find((m) => m.id === selectedId);
if (model) return model.model;
}
if (conversationModel) {
const model = options.find((m) => m.model === conversationModel);
if (model) return model.model;
}
return null;
});
let activeModelId = $derived(modelsStore.activeModelId);
let modelPropsVersion = $state(0); // Used to trigger reactivity after fetch
@@ -156,7 +135,7 @@
}
</script>
{#if isMobile.current}
{#if deviceStore.isMobile}
<ModelsSelectorSheet
disabled={disabled || isOffline}
bind:this={selectorModelRef}
@@ -1,15 +1,14 @@
<script lang="ts">
import ContextGaugeDial from './ContextGaugeDial.svelte';
import { useContextGauge } from '$lib/hooks/use-context-gauge.svelte';
import {
chatStore,
conversationsStore,
gaugeTriggerClick,
gaugeTriggerEnter,
gaugeTriggerKeydown,
gaugeTriggerLeave,
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';
const gauge = useContextGauge();
@@ -1,9 +1,9 @@
<script lang="ts">
import ContextGaugeDetailRow from './ContextGaugeDetailRow.svelte';
import { gaugePopup } from './gauge-popup.svelte';
import { ChevronDown } from '@lucide/svelte';
import * as Collapsible from '$lib/components/ui/collapsible';
import { STATS_UNITS } from '$lib/constants';
import { gaugePopup } from '$lib/stores/context-gauge-popup.svelte';
interface Props {
currentRead: number;
@@ -2,8 +2,13 @@
import { colorLevelBgClass, colorLevelTextClass } from './context-gauge';
import ContextGaugeDetails from './ContextGaugeDetails.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 { gaugeCardEnter, gaugeCardLeave, gaugePopup, gaugePopupClose } from '$lib/stores';
import { formatParameters } from '$lib/utils/formatters';
const gauge = useContextGauge();
@@ -62,7 +62,7 @@
// it, the picker still opens for manual entry but explains why search is
// unavailable instead of firing searches that would only fail. Browse is
// hidden too: it resolves the picked folder name through the same tool.
const fileSearchKey = $derived(toolsStore.getPermissionKey(BuiltInTool.FILE_GLOB_SEARCH));
const fileSearchKey = $derived(toolsStore.getPermissionKey(BuiltInTool.SERVER_FILE_GLOB_SEARCH));
const fileSearchEnabled = $derived(
fileSearchKey !== null && toolsStore.isToolEnabled(fileSearchKey)
);
@@ -212,7 +212,7 @@
// so the caller fails visibly instead of committing a bare leaf name.
async function resolveNativeName(name: string): Promise<string | null> {
try {
const res = await ToolsService.executeToolRaw(BuiltInTool.FILE_GLOB_SEARCH, {
const res = await ToolsService.executeToolRaw(BuiltInTool.SERVER_FILE_GLOB_SEARCH, {
include: buildCaseInsensitiveGlob(name),
limit: SEARCH.NATIVE_LIMIT,
max_depth: SEARCH.NATIVE_MAX_DEPTH,
@@ -1,5 +1,5 @@
<script lang="ts">
import { isMobile } from '$lib/stores';
import { deviceStore } from '$lib/stores';
import { autoResizeTextarea } from '$lib/utils';
import { onMount } from 'svelte';
@@ -37,7 +37,7 @@
}
export function focus() {
if (isMobile.current) return;
if (deviceStore.isMobile) return;
textareaElement?.focus({ preventScroll: true });
}
@@ -1,7 +1,7 @@
<script lang="ts">
import { CODE_BLOCK, CODE_TOKEN_ATTR, UI_DATA_ATTRS } from '$lib/constants';
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 { SourceHistoryEntry } from '$lib/utils';
import {
@@ -750,7 +750,7 @@
syncEmptyState();
document.addEventListener('selectionchange', handleSelectionChange);
if (!isMobile.current) {
if (!deviceStore.isMobile) {
rootElement?.focus({ preventScroll: true });
}
});
@@ -792,7 +792,7 @@
}
export function focus() {
if (isMobile.current) return;
if (deviceStore.isMobile) return;
rootElement?.focus({ preventScroll: true });
}
@@ -8,7 +8,7 @@
import { BuiltInTool, FileMentionEntryType, GlobSearchType, KeyboardKey } from '$lib/enums';
import { useDebouncedSearch } from '$lib/hooks/use-debounced-search.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 { abbreviateHome, runGlobSearchWithChildren } from '$lib/utils';
@@ -51,7 +51,7 @@
// When the server does not expose file_glob_search (started without
// --tools) or the user disabled it, the picker still opens but explains
// why instead of firing searches that would only fail.
const fileSearchKey = $derived(toolsStore.getPermissionKey(BuiltInTool.FILE_GLOB_SEARCH));
const fileSearchKey = $derived(toolsStore.getPermissionKey(BuiltInTool.SERVER_FILE_GLOB_SEARCH));
const fileSearchEnabled = $derived(
fileSearchKey !== null && toolsStore.isToolEnabled(fileSearchKey)
);
@@ -130,7 +130,7 @@
return searchError ? `Search failed - ${searchError}` : 'No matching files or folders';
});
const showTooltip = $derived(!isMobile.current);
const showTooltip = $derived(!deviceStore.isMobile);
$effect(() => {
if (typeof window === 'undefined') return;
@@ -11,7 +11,7 @@
import { setChatMessageActionsContext, setChatMessageEditContext } from '$lib/contexts';
import { AgenticSectionType, AttachmentType, MessageRole } from '$lib/enums';
import { DatabaseService } from '$lib/services/database.service';
import { chatStore, conversationsStore, isMobile } from '$lib/stores';
import { chatStore, conversationsStore, deviceStore } from '$lib/stores';
import type {
ChatMessageActions,
ChatMessageDeletionInfo,
@@ -304,7 +304,7 @@
// After the system message flow ends, hand focus to the main chat form
function focusMainChatForm() {
if (isMobile.current) return;
if (deviceStore.isMobile) return;
document.querySelector<HTMLTextAreaElement>('.chat-screen-form-wrapper textarea')?.focus();
}
@@ -35,19 +35,19 @@
{#if isSearchCall}
<ChatMessageToolCallBlockSearchResults {section} {open} {isStreaming} {onToggle} />
{:else if section.toolName === BuiltInTool.GET_DATETIME}
{:else if section.toolName === BuiltInTool.BROWSER_GET_DATETIME}
<ChatMessageToolCallBlockGetDatetime {section} {isStreaming} />
{:else if section.toolName === BuiltInTool.GET_INFO}
{:else if section.toolName === BuiltInTool.SERVER_GET_INFO}
<ChatMessageToolCallBlockGetInfo {section} {isStreaming} />
{:else if section.toolName === BuiltInTool.READ_FILE}
{:else if section.toolName === BuiltInTool.SERVER_READ_FILE}
<ChatMessageToolCallBlockReadFile {section} {open} {isStreaming} {onToggle} />
{:else if section.toolName === BuiltInTool.READ_MEDIA}
{:else if section.toolName === BuiltInTool.BROWSER_READ_MEDIA}
<ChatMessageToolCallBlockReadMedia {section} {open} {isStreaming} {onToggle} />
{:else if section.toolName === BuiltInTool.EDIT_FILE}
{:else if section.toolName === BuiltInTool.SERVER_EDIT_FILE}
<ChatMessageToolCallBlockEditFile {section} {open} {isStreaming} {onToggle} />
{:else if section.toolName === BuiltInTool.WRITE_FILE}
{:else if section.toolName === BuiltInTool.SERVER_WRITE_FILE}
<ChatMessageToolCallBlockWriteFile {section} {open} {isStreaming} {onToggle} />
{:else if section.toolName === BuiltInTool.EXEC_SHELL_COMMAND}
{:else if section.toolName === BuiltInTool.SERVER_EXEC_SHELL_COMMAND}
<ChatMessageToolCallBlockExecShellCommand
{section}
{open}
@@ -56,11 +56,11 @@
{attachments}
{onToggle}
/>
{:else if section.toolName === BuiltInTool.FILE_GLOB_SEARCH}
{:else if section.toolName === BuiltInTool.SERVER_FILE_GLOB_SEARCH}
<ChatMessageToolCallBlockFileGlobSearch {section} {open} {isStreaming} {onToggle} />
{:else if section.toolName === BuiltInTool.GREP_SEARCH}
{:else if section.toolName === BuiltInTool.SERVER_GREP_SEARCH}
<ChatMessageToolCallBlockGrepSearch {section} {open} {isStreaming} {onToggle} />
{:else if section.toolName === BuiltInTool.RUN_JAVASCRIPT}
{:else if section.toolName === BuiltInTool.BROWSER_RUN_JAVASCRIPT}
<ChatMessageToolCallBlockRunJavascript {section} {open} {isStreaming} {onToggle} />
{:else}
<ChatMessageToolCallBlockDefault {section} {open} {isStreaming} {attachments} {onToggle} />
@@ -12,7 +12,7 @@
import {
classifyToolResult,
formatJsonPretty,
getBuiltinToolUi,
getToolUi,
parseToolResultWithMedia
} from '$lib/utils';
import { createBase64DataUrl } from '$lib/utils/data-url';
@@ -27,7 +27,7 @@
let { attachments, isStreaming, onToggle, open, section }: Props = $props();
const title = $derived(getBuiltinToolUi(section.toolName)?.label ?? section.toolName ?? '');
const title = $derived(getToolUi(section.toolName)?.label ?? section.toolName ?? '');
const outputKind = $derived(classifyToolResult(section.toolResult));
const parsedLines: ToolResultLine[] = $derived(
section.toolResult ? parseToolResultWithMedia(section.toolResult, attachments) : []
@@ -6,7 +6,7 @@
import { MAX_HEIGHT_CODE_BLOCK } from '$lib/constants';
import { FileTypeText } from '$lib/enums';
import type { AgenticSection } from '$lib/types';
import { getBuiltinToolUi } from '$lib/utils';
import { getToolUi } from '$lib/utils';
interface Props {
section: AgenticSection;
@@ -18,7 +18,7 @@
let { isStreaming, onToggle, open, section }: Props = $props();
const runJsMeta = $derived(parseRunJavascriptMeta(section));
const title = $derived(getBuiltinToolUi(section.toolName)?.label ?? section.toolName ?? '');
const title = $derived(getToolUi(section.toolName)?.label ?? section.toolName ?? '');
</script>
<ToolCallBlock {section} {open} {isStreaming} meta={runJsMeta} {title} {onToggle}>
@@ -14,8 +14,8 @@
import { ICON_CLASS_DEFAULT, ICON_CLASS_SPIN } from '$lib/constants';
import { AgenticSectionType } from '$lib/enums';
import { mcpStore } from '$lib/stores';
import type { AgenticSection, BuiltinToolUiEntry } from '$lib/types';
import { getBuiltinToolUi } from '$lib/utils';
import type { AgenticSection, ToolUiEntry } from '$lib/types';
import { getToolUi } from '$lib/utils';
import type { Component, Snippet } from 'svelte';
type ToolCallBlockMetaWithError = TMeta & { errorMessage?: string };
@@ -82,7 +82,7 @@
const showSpinner = $derived(isPending || (isStreamingCall && isStreaming) || extraLiveStreaming);
const isCodeStreaming = $derived(isStreaming && (isPending || isStreamingCall));
const toolUi: BuiltinToolUiEntry | null = $derived(getBuiltinToolUi(section.toolName));
const toolUi: ToolUiEntry | null = $derived(getToolUi(section.toolName));
const toolIcon: Component = $derived(
spinIconWhenActive && showSpinner ? Loader2 : (toolUi?.icon ?? Wrench)
);
@@ -24,7 +24,7 @@ export type EditFileMeta = {
};
export function parseEditFileMeta(section: AgenticSection): EditFileMeta | null {
const args = parseToolArgs(BuiltInTool.EDIT_FILE, section, { partial: true });
const args = parseToolArgs(BuiltInTool.SERVER_EDIT_FILE, section, { partial: true });
if (!args) return null;
@@ -14,7 +14,7 @@ export type ExecShellCommandMeta = {
};
export function parseExecShellCommandMeta(section: AgenticSection): ExecShellCommandMeta | null {
const args = parseToolArgs(BuiltInTool.EXEC_SHELL_COMMAND, section);
const args = parseToolArgs(BuiltInTool.SERVER_EXEC_SHELL_COMMAND, section);
if (!args) return null;
@@ -19,7 +19,7 @@ export type FileGlobSearchMeta = {
};
export function parseFileGlobSearchMeta(section: AgenticSection): FileGlobSearchMeta | null {
const args = parseToolArgs(BuiltInTool.FILE_GLOB_SEARCH, section);
const args = parseToolArgs(BuiltInTool.SERVER_FILE_GLOB_SEARCH, section);
if (!args) return null;
@@ -28,7 +28,7 @@ export type GrepSearchMeta = {
};
export function parseGrepSearchMeta(section: AgenticSection): GrepSearchMeta | null {
const args = parseToolArgs(BuiltInTool.GREP_SEARCH, section);
const args = parseToolArgs(BuiltInTool.SERVER_GREP_SEARCH, section);
if (!args) return null;
@@ -16,7 +16,7 @@ export type ReadFileMeta = {
};
export function parseReadFileMeta(section: AgenticSection): ReadFileMeta | null {
const args = parseToolArgs(BuiltInTool.READ_FILE, section, { partial: true });
const args = parseToolArgs(BuiltInTool.SERVER_READ_FILE, section, { partial: true });
if (!args) return null;
@@ -16,7 +16,7 @@ export type RunJavascriptMeta = {
};
export function parseRunJavascriptMeta(section: AgenticSection): RunJavascriptMeta | null {
const args = parseToolArgs(BuiltInTool.RUN_JAVASCRIPT, section);
const args = parseToolArgs(BuiltInTool.BROWSER_RUN_JAVASCRIPT, section);
if (!args) return null;
@@ -20,7 +20,7 @@ export type WriteFileMeta = {
};
export function parseWriteFileMeta(section: AgenticSection): WriteFileMeta | null {
const args = parseToolArgs(BuiltInTool.WRITE_FILE, section, { partial: true });
const args = parseToolArgs(BuiltInTool.SERVER_WRITE_FILE, section, { partial: true });
if (!args) return null;
@@ -61,8 +61,8 @@
{:else}
{@const source = toolsStore.getToolSource(toolName)}
{@const providerName =
source === ToolSource.BUILTIN
? TOOL_SERVER_LABELS[ToolSource.BUILTIN]
source === ToolSource.SERVER
? TOOL_SERVER_LABELS[ToolSource.SERVER]
: source === ToolSource.CUSTOM
? TOOL_SERVER_LABELS[ToolSource.CUSTOM]
: 'MCP Tools'}
@@ -21,8 +21,7 @@
import {
chatStore,
conversationsStore,
device,
isMobile,
deviceStore,
serverStore,
settingsStore
} from '$lib/stores';
@@ -32,7 +31,7 @@
let { showCenteredEmpty = false } = $props();
let disableAutoScroll = $derived(
Boolean(settingsStore.config.disableAutoScroll) || isMobile.current
Boolean(settingsStore.config.disableAutoScroll) || deviceStore.isMobile
);
let isMobileUserScrolledUp = $state(false);
let mobileScrollDownHint = $state(false);
@@ -52,11 +51,11 @@
let hasPropsError = $derived(!!serverStore.error);
let isCurrentConversationLoading = $derived(chatStore.isLoading || chatStore.isStreaming());
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';
});
@@ -84,7 +83,7 @@
});
function handleMobileScroll() {
if (!isMobile.current) return;
if (!deviceStore.isMobile) return;
const container = scroll.chatScrollContainer;
@@ -184,7 +183,7 @@
}
function handleSendLikeScroll() {
if (!isMobile.current) {
if (!deviceStore.isMobile) {
autoScroll.enable();
}
@@ -197,7 +196,7 @@
'.chat-message:nth-last-child(2) .chat-message-user .chat-message-user-bubble'
) as HTMLElement | null;
if (isMobile.current) {
if (deviceStore.isMobile) {
// Keep the last user message bubble just above the input on mobile
const bubbleHeight = lastUserBubble?.scrollHeight ?? 0;
const baseHeight = container.scrollHeight - innerHeight;
@@ -220,7 +219,7 @@
}
}, 100);
if (isMobile.current) {
if (deviceStore.isMobile) {
autoScroll.setDisabled(disableAutoScroll);
mobileScrollDownHint = true;
mobileScrollDownHintLockedUntil = Date.now() + 500;
@@ -243,7 +242,8 @@
$effect(() => {
const shouldDisableAutoScroll =
settingsStore.config.disableAutoScroll || (isMobile.current && isCurrentConversationLoading);
settingsStore.config.disableAutoScroll ||
(deviceStore.isMobile && isCurrentConversationLoading);
autoScroll.setDisabled(shouldDisableAutoScroll);
@@ -266,7 +266,7 @@
autoScroll.enable();
}
if (isMobile.current && isCurrentConversationLoading) {
if (deviceStore.isMobile && isCurrentConversationLoading) {
mobileScrollDownHint = true;
mobileScrollDownHintLockedUntil = Date.now() + 500;
}
@@ -318,9 +318,9 @@
<div
class={[
'pointer-events-none md:sticky fixed mt-auto transition-all duration-200',
device.isStandalone
deviceStore.isStandalone
? 'bottom-6 right-4 left-4'
: device.isIOSSafari
: deviceStore.isIOSSafari
? 'bottom-1 left-2 right-2'
: 'bottom-2 right-2 left-2',
isEmpty ? 'md:bottom-[calc(50dvh-7rem)] 2xl:bottom-[calc(50dvh-4rem)]' : 'md:bottom-4'
@@ -336,7 +336,7 @@
{/if}
<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
onclick={() => {
mobileScrollDownHint = false;
@@ -3,7 +3,7 @@
import { page } from '$app/state';
import { ChatForm } from '$lib/components/app';
import { useDraftMessages } from '$lib/hooks/use-draft-messages.svelte';
import { isMobile } from '$lib/stores';
import { deviceStore } from '$lib/stores';
import { onMount } from 'svelte';
interface Props {
@@ -120,13 +120,13 @@
}
onMount(() => {
if (!isMobile.current) {
if (!deviceStore.isMobile) {
setTimeout(focusFormUnlessCaptured, 100);
}
});
afterNavigate((navigation) => {
if (navigation?.from != null && !isMobile.current) {
if (navigation?.from != null && !deviceStore.isMobile) {
setTimeout(focusFormUnlessCaptured, 100);
}
});
@@ -278,7 +278,7 @@ export { default as ChatFormInput } from './ChatForm/ChatFormInput/ChatFormInput
/**
* Working directory selector for agent mode. Renders a chip below the chat
* form; clicking it opens a popover with a directory picker backed by the
* server's `file_glob_search` built-in tool (POST /tools). The picked
* server's `file_glob_search` server tool (POST /tools). The picked
* directory is exposed via `bind:directory`; changing it records a
* synthetic "Set working directory to ..." user message into chat history
* and is enforced on tool calls via the `x-tool-cwd` request header.
@@ -380,7 +380,7 @@ export { default as ChatFormPickerListItemSkeleton } from './ChatForm/ChatFormPi
/**
* `@`-triggered file/folder mention picker. Resolves `@<query>` in the chat
* input to a filesystem match via the server's `file_glob_search` built-in
* input to a filesystem match via the server's `file_glob_search` server tool
* tool, scoped to the conversation cwd (or server home when unset).
* Selection splices a `[name](file:///<abs path>)` link into the input.
*/
@@ -14,7 +14,7 @@
import { useKeyboardShortcuts } from '$lib/hooks/use-keyboard-shortcuts.svelte';
import { useMarqueeSelection } from '$lib/hooks/use-marquee-selection.svelte';
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 { circIn } from 'svelte/easing';
import { SvelteSet } from 'svelte/reactivity';
@@ -36,7 +36,7 @@
let logoHovered = $state(false);
const isStripExpanded = $derived(isExpandedMode || hoveredTooltip !== null);
const isOnMobile = $derived(isMobile.current);
const isOnMobile = $derived(deviceStore.isMobile);
const alwaysShowOnDesktop = $derived(settingsStore.config.alwaysShowSidebarOnDesktop as boolean);
$effect(() => {
@@ -65,7 +65,7 @@
});
$effect(() => {
if (isMobile.current && page.url.hash.includes(ROUTES.SEARCH)) {
if (deviceStore.isMobile && page.url.hash.includes(ROUTES.SEARCH)) {
isExpandedMode = false;
}
});
@@ -227,7 +227,7 @@
}
async function selectConversation(id: string) {
if (isMobile.current) {
if (deviceStore.isMobile) {
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)]',
'md:h-[calc(100dvh-1.125rem)]',
isExpandedMode &&
(device.isStandalone
(deviceStore.isStandalone
? 'h-[calc(100dvh-2rem)]'
: device.isIOSDevice
: deviceStore.isIOSDevice
? 'h-[calc(100dvh-0.5rem)]'
: 'h-[calc(100dvh-1rem)]'),
'rounded-3xl md:rounded-2xl',
@@ -353,7 +353,7 @@
{#if isOnMobile || (isExpandedMode && !alwaysShowOnDesktop)}
<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
? 'opacity-0 h-0!'
: ''}"
@@ -361,7 +361,7 @@
out:fade={{ duration: 100 }}
>
<ActionIcon
icon={isMobile.current ? X : PanelLeftClose}
icon={deviceStore.isMobile ? X : PanelLeftClose}
size="lg"
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!"
@@ -375,9 +375,9 @@
</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'
: ''} {isMobile.current && !isExpandedMode ? 'opacity-0 !h-0' : ''}"
: ''} {deviceStore.isMobile && !isExpandedMode ? 'opacity-0 !h-0' : ''}"
in:fade={{ duration: 200 }}
out:fade={{ duration: 200 }}
>
@@ -395,7 +395,7 @@
isSearchModeActive = true;
}}
onNewChat={() => {
if (isMobile.current) {
if (deviceStore.isMobile) {
scheduleMobileCollapse();
}
}}
@@ -12,7 +12,7 @@
SIDEBAR_ACTIONS_ITEMS
} from '$lib/constants';
import { TooltipSide } from '$lib/enums';
import { isMobile } from '$lib/stores';
import { deviceStore } from '$lib/stores';
import type { Component } from 'svelte';
import { onMount } from 'svelte';
import { circIn } from 'svelte/easing';
@@ -42,7 +42,7 @@
let showIcons = $state(false);
let searchInputRef = $state<HTMLInputElement | null>(null);
const isOnMobile = $derived(isMobile.current);
const isOnMobile = $derived(deviceStore.isMobile);
$effect(() => {
if (isSearchModeActive && searchInputRef) {
@@ -107,7 +107,7 @@
>
{#each SIDEBAR_ACTIONS_ITEMS as item, i (item.tooltip)}
{@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 itemOnClick = item.route
? () => {
@@ -156,7 +156,7 @@
<div class="{className} flex-col gap-1 hidden md:flex">
{#each SIDEBAR_ACTIONS_ITEMS as item, i (item.tooltip)}
{@const isActive = isItemActive(item)}
{@const isSearchOnMobile = item.icon === Search && isMobile.current}
{@const isSearchOnMobile = item.icon === Search && deviceStore.isMobile}
{@const itemOnClick = item.route
? () => {
onNewChat?.();
@@ -8,6 +8,7 @@
} from '$lib/components/app';
import SettingsGroup from '$lib/components/app/settings/SettingsGroup.svelte';
import { ConversationSelectionMode, FileExtensionText, HtmlInputType } from '$lib/enums';
import { ConversationTransferService } from '$lib/services';
import { conversationsStore, settingsStore } from '$lib/stores';
import { createMessageCountMap } from '$lib/utils';
import { fade } from 'svelte/transition';
@@ -147,9 +148,9 @@
);
if (allData.length === 1) {
conversationsStore.downloadConversationFile(allData[0]);
ConversationTransferService.downloadConversationFile(allData[0]);
} else {
conversationsStore.downloadConversationsArchive(allData);
ConversationTransferService.downloadConversationsArchive(allData);
}
exportedConversations = selectedConversations;
@@ -177,7 +178,7 @@
if (!file) return;
try {
const importedData = await conversationsStore.parseImportFile(file);
const importedData = await ConversationTransferService.parseImportFile(file);
if (importedData.length === 0) {
throw new Error('No conversations found in file');
@@ -6,7 +6,7 @@
import { ICON_CLASS_DEFAULT } from '$lib/constants';
import { ToolSource } from '$lib/enums/tools.enums';
import { mcpStore, permissionsStore, toolsStore } from '$lib/stores';
import { getBuiltinToolUi } from '$lib/utils';
import { getToolUi } from '$lib/utils';
import { SvelteSet } from 'svelte/reactivity';
let expandedGroups = new SvelteSet<string>();
@@ -69,12 +69,12 @@
{#each group.tools as entry (entry.key)}
{@const toolName = entry.definition.function.name}
{@const builtinUi =
entry.source === ToolSource.BUILTIN || entry.source === ToolSource.FRONTEND
? getBuiltinToolUi(toolName)
{@const toolUi =
entry.source === ToolSource.SERVER || entry.source === ToolSource.BROWSER
? getToolUi(toolName)
: null}
{@const displayLabel = builtinUi?.label ?? toolName}
{@const IconComponent = builtinUi?.icon ?? null}
{@const displayLabel = toolUi?.label ?? toolName}
{@const IconComponent = toolUi?.icon ?? null}
{@const isEnabled = toolsStore.isToolEnabled(entry.key)}
{@const permissionKey = entry.key}
{@const isAlwaysAllowed = permissionsStore.hasTool(permissionKey)}
@@ -69,7 +69,7 @@ export { default as SettingsChatFields } from './SettingsChat/SettingsChatFields
/**
* **SettingsChatToolsTab** - Tools configuration tab for chat settings
*
* Displays available tools grouped by source (built-in, MCP, custom) with
* Displays available tools grouped by source (server, browser, MCP, custom) with
* toggles to enable/disable individual tools and tool groups. Shows MCP
* server favicons and permission management controls.
*/
@@ -31,5 +31,11 @@ export const API_STREAM = {
LOOKUP: './v1/streams/lookup'
};
// query params for the resumable stream routes
export const STREAM_QUERY_PARAMS = {
CONV_ID: 'conv_id',
FROM: 'from'
} as const;
/** CORS proxy endpoint path */
export const CORS_PROXY_ENDPOINT = '/cors-proxy';
+3 -1
View File
@@ -2,7 +2,9 @@ import { CLI_FLAGS } from './cli-flags.constants';
import { BuiltInTool, JsonSchemaType, ToolCallType } from '$lib/enums';
import type { OpenAIToolDefinition } from '$lib/types';
export const BROWSER_INFO_TOOL_NAME = BuiltInTool.GET_INFO;
// get_info is served by the server, but the browser falls back to this
// implementation when the server does not provide it - same wire name.
export const BROWSER_INFO_TOOL_NAME = BuiltInTool.SERVER_GET_INFO;
/** UA token to OS name, first match wins - Android and iOS UAs also carry the Linux / Mac OS X tokens */
export const BROWSER_INFO_OS_UA_PATTERNS: readonly [RegExp, string][] = [
@@ -1,52 +0,0 @@
// Registry of built-in and frontend (browser) tools whose renderer
// shows a recognizable icon and friendly label inline in the chat UI.
//
// To add a new built-in tool, add an entry to BUILTIN_TOOL_UI. To give a
// tool a custom title or body renderer, add a dedicated component under
// ChatMessageToolCall/ and route it in ChatMessageToolCallBlock.svelte
// (see ChatMessageToolCallBlockGetDatetime and
// ChatMessageToolCallBlockSearchResults for prior art).
import {
Braces,
Clock,
Eye,
FilePen,
FilePlus,
FileSearch,
FileText,
Info,
SearchCode,
Terminal
} from '@lucide/svelte';
import { BuiltInTool, ToolSource } from '$lib/enums';
import type { BuiltinToolUiEntry } from '$lib/types';
export const BUILTIN_TOOL_UI: Readonly<Record<BuiltInTool, BuiltinToolUiEntry>> = {
[BuiltInTool.EDIT_FILE]: { icon: FilePen, label: 'Edit file', source: ToolSource.BUILTIN },
[BuiltInTool.EXEC_SHELL_COMMAND]: {
icon: Terminal,
label: 'Run command',
source: ToolSource.BUILTIN
},
[BuiltInTool.FILE_GLOB_SEARCH]: {
icon: FileSearch,
label: 'Search files',
source: ToolSource.BUILTIN
},
[BuiltInTool.GET_DATETIME]: { icon: Clock, label: 'Current time', source: ToolSource.FRONTEND },
[BuiltInTool.GET_INFO]: { icon: Info, label: 'Runtime info', source: ToolSource.BUILTIN },
[BuiltInTool.GREP_SEARCH]: {
icon: SearchCode,
label: 'Search in files',
source: ToolSource.BUILTIN
},
[BuiltInTool.READ_FILE]: { icon: FileText, label: 'Read file', source: ToolSource.BUILTIN },
[BuiltInTool.READ_MEDIA]: { icon: Eye, label: 'Read media', source: ToolSource.FRONTEND },
[BuiltInTool.RUN_JAVASCRIPT]: {
icon: Braces,
label: 'Run JavaScript',
source: ToolSource.FRONTEND
},
[BuiltInTool.WRITE_FILE]: { icon: FilePlus, label: 'Write file', source: ToolSource.BUILTIN }
} as const;
+1 -1
View File
@@ -1,7 +1,7 @@
import { BuiltInTool, JsonSchemaType, ToolCallType } from '$lib/enums';
import type { OpenAIToolDefinition } from '$lib/types';
export const GET_DATETIME_TOOL_NAME = BuiltInTool.GET_DATETIME;
export const GET_DATETIME_TOOL_NAME = BuiltInTool.BROWSER_GET_DATETIME;
export function buildGetDatetimeToolDefinition(): OpenAIToolDefinition {
return {
+1 -1
View File
@@ -15,7 +15,7 @@ export * from './context-gauge-popup.constants';
export * from './conversation-import.constants';
export * from './binary-detection.constants';
export * from './content-detection.constants';
export * from './built-in-tools.constants';
export * from './tool-ui.constants';
export * from './cache.constants';
export * from './chat-form.constants';
export * from './cli-flags.constants';
+1 -1
View File
@@ -7,7 +7,7 @@ import {
} from '$lib/enums';
import type { OpenAIToolDefinition } from '$lib/types';
export const READ_MEDIA_TOOL_NAME = BuiltInTool.READ_MEDIA;
export const READ_MEDIA_TOOL_NAME = BuiltInTool.BROWSER_READ_MEDIA;
// header lines of the tool result, parsed back by the read_media renderer
export const PREFIX_FILE = 'File: ';
@@ -1,6 +1,6 @@
import { BuiltInTool } from '$lib/enums';
export const SANDBOX_TOOL_NAME = BuiltInTool.RUN_JAVASCRIPT;
export const SANDBOX_TOOL_NAME = BuiltInTool.BROWSER_RUN_JAVASCRIPT;
export const SANDBOX_TIMEOUT_MS_DEFAULT = 10000;
@@ -0,0 +1,60 @@
// Registry of server and browser tools whose renderer
// shows a recognizable icon and friendly label inline in the chat UI.
//
// To add a new tool, add an entry to TOOL_UI. To give a
// tool a custom title or body renderer, add a dedicated component under
// ChatMessageToolCall/ and route it in ChatMessageToolCallBlock.svelte
// (see ChatMessageToolCallBlockGetDatetime and
// ChatMessageToolCallBlockSearchResults for prior art).
import {
Braces,
Clock,
Eye,
FilePen,
FilePlus,
FileSearch,
FileText,
Info,
SearchCode,
Terminal
} from '@lucide/svelte';
import { BuiltInTool, ToolSource } from '$lib/enums';
import type { ToolUiEntry } from '$lib/types';
export const TOOL_UI: Readonly<Record<BuiltInTool, ToolUiEntry>> = {
[BuiltInTool.BROWSER_GET_DATETIME]: {
icon: Clock,
label: 'Current time',
source: ToolSource.BROWSER
},
[BuiltInTool.BROWSER_READ_MEDIA]: { icon: Eye, label: 'Read media', source: ToolSource.BROWSER },
[BuiltInTool.BROWSER_RUN_JAVASCRIPT]: {
icon: Braces,
label: 'Run JavaScript',
source: ToolSource.BROWSER
},
[BuiltInTool.SERVER_EDIT_FILE]: { icon: FilePen, label: 'Edit file', source: ToolSource.SERVER },
[BuiltInTool.SERVER_EXEC_SHELL_COMMAND]: {
icon: Terminal,
label: 'Run command',
source: ToolSource.SERVER
},
[BuiltInTool.SERVER_FILE_GLOB_SEARCH]: {
icon: FileSearch,
label: 'Search files',
source: ToolSource.SERVER
},
[BuiltInTool.SERVER_GET_INFO]: { icon: Info, label: 'Runtime info', source: ToolSource.SERVER },
[BuiltInTool.SERVER_GREP_SEARCH]: {
icon: SearchCode,
label: 'Search in files',
source: ToolSource.SERVER
},
[BuiltInTool.SERVER_READ_FILE]: { icon: FileText, label: 'Read file', source: ToolSource.SERVER },
[BuiltInTool.SERVER_WRITE_FILE]: {
icon: FilePlus,
label: 'Write file',
source: ToolSource.SERVER
}
} as const;
+4 -4
View File
@@ -18,15 +18,15 @@ export const UI_DATA_ATTRS = {
} as const;
export const TOOL_GROUP_LABELS = {
[ToolSource.BUILTIN]: 'Built-in',
[ToolSource.BROWSER]: 'Browser',
[ToolSource.CUSTOM]: 'JSON Schema',
[ToolSource.FRONTEND]: 'Browser'
[ToolSource.SERVER]: 'Server'
} as const;
export const TOOL_SERVER_LABELS = {
[ToolSource.BUILTIN]: 'Built-in Tools',
[ToolSource.BROWSER]: 'Browser Tools',
[ToolSource.CUSTOM]: 'Custom Tools',
[ToolSource.FRONTEND]: 'Browser Tools'
[ToolSource.SERVER]: 'Server Tools'
} as const;
export const TOOLTIP_DELAY_DURATION = 500;
+6 -6
View File
@@ -9,12 +9,12 @@ export enum ToolCallType {
* Types of sections in agentic content display.
*/
export enum AgenticSectionType {
REASONING = 'reasoning',
REASONING_PENDING = 'reasoning_pending',
TEXT = 'text',
TOOL_CALL = 'tool_call',
TOOL_CALL_PENDING = 'tool_call_pending',
TOOL_CALL_STREAMING = 'tool_call_streaming',
REASONING = 'reasoning',
REASONING_PENDING = 'reasoning_pending'
TOOL_CALL_STREAMING = 'tool_call_streaming'
}
/**
@@ -22,8 +22,8 @@ export enum AgenticSectionType {
*/
export enum ContinueIntentKind {
APPEND_TEXT = 'append_text',
RERUN_TURN = 'rerun_turn',
NEXT_TURN = 'next_turn'
NEXT_TURN = 'next_turn',
RERUN_TURN = 'rerun_turn'
}
/**
@@ -39,7 +39,7 @@ export enum ToolResultKind {
* Line classification for the unified-diff renderer of `edit_file` results.
*/
export enum DiffLineKind {
CONTEXT = 'context',
ADD = 'add',
CONTEXT = 'context',
REMOVE = 'remove'
}
+13 -13
View File
@@ -4,12 +4,12 @@
export enum AttachmentType {
AUDIO = 'AUDIO',
IMAGE = 'IMAGE',
VIDEO = 'VIDEO',
LEGACY_CONTEXT = 'context', // Legacy attachment type for backward compatibility
MCP_PROMPT = 'MCP_PROMPT',
MCP_RESOURCE = 'MCP_RESOURCE',
PDF = 'PDF',
TEXT = 'TEXT',
LEGACY_CONTEXT = 'context' // Legacy attachment type for backward compatibility
VIDEO = 'VIDEO'
}
/**
@@ -17,14 +17,14 @@ export enum AttachmentType {
* Used to select which file upload or attachment action is triggered.
*/
export enum AttachmentMenuItemId {
IMAGES = 'images',
AUDIO = 'audio',
VIDEO = 'video',
TEXT = 'text',
IMAGES = 'images',
MCP_PROMPT = 'mcp-prompt',
MCP_RESOURCES = 'mcp-resources',
PDF = 'pdf',
SYSTEM_MESSAGE = 'system-message',
MCP_PROMPT = 'mcp-prompt',
MCP_RESOURCES = 'mcp-resources'
TEXT = 'text',
VIDEO = 'video'
}
/**
@@ -32,9 +32,9 @@ export enum AttachmentMenuItemId {
*/
export enum AttachmentItemEnabledWhen {
ALWAYS = 'always',
HAS_VISION_MODALITY = 'hasVisionModality',
HAS_AUDIO_MODALITY = 'hasAudioModality',
HAS_VIDEO_MODALITY = 'hasVideoModality'
HAS_VIDEO_MODALITY = 'hasVideoModality',
HAS_VISION_MODALITY = 'hasVisionModality'
}
/**
@@ -42,9 +42,9 @@ export enum AttachmentItemEnabledWhen {
*/
export enum AttachmentAction {
FILE_UPLOAD = 'onFileUpload',
SYSTEM_PROMPT_CLICK = 'onSystemPromptClick',
MCP_PROMPT_CLICK = 'onMcpPromptClick',
MCP_RESOURCES_CLICK = 'onMcpResourcesClick'
MCP_RESOURCES_CLICK = 'onMcpResourcesClick',
SYSTEM_PROMPT_CLICK = 'onSystemPromptClick'
}
/**
@@ -52,9 +52,9 @@ export enum AttachmentAction {
*/
export enum AttachmentLabel {
FILE = 'File',
PDF_FILE = 'PDF File',
MCP_PROMPT = 'MCP Prompt',
MCP_RESOURCE = 'MCP Resource'
MCP_RESOURCE = 'MCP Resource',
PDF_FILE = 'PDF File'
}
/**
@@ -1,5 +1,5 @@
/** String representation of a boolean used in data attributes and persisted values. */
export enum BooleanString {
TRUE = 'true',
FALSE = 'false'
FALSE = 'false',
TRUE = 'true'
}
+24 -24
View File
@@ -1,41 +1,41 @@
export enum ChatMessageStatsView {
GENERATION = 'generation',
READING = 'reading',
TOOLS = 'tools',
SUMMARY = 'summary'
SUMMARY = 'summary',
TOOLS = 'tools'
}
export enum ChatMessageStatisticsMode {
SWITCHABLE = 'switchable',
GENERATION = 'generation',
READING = 'reading',
GENERATION = 'generation'
SWITCHABLE = 'switchable'
}
/**
* Connection state of a streamed completion, drives the resume status indicator.
*/
export enum StreamConnectionState {
STREAMING = 'streaming',
LOST = 'lost',
RESUMING = 'resuming',
LOST = 'lost'
STREAMING = 'streaming'
}
/**
* Reasoning format options for API requests.
*/
export enum ReasoningFormat {
NONE = 'none',
AUTO = 'auto'
AUTO = 'auto',
NONE = 'none'
}
/**
* Message roles for chat messages.
*/
export enum MessageRole {
USER = 'user',
ASSISTANT = 'assistant',
SYSTEM = 'system',
TOOL = 'tool'
TOOL = 'tool',
USER = 'user'
}
/**
@@ -43,27 +43,27 @@ export enum MessageRole {
*/
export enum MessageType {
ROOT = 'root',
SYSTEM = 'system',
TEXT = 'text',
THINK = 'think',
SYSTEM = 'system'
THINK = 'think'
}
/**
* Content part types for API chat message content.
*/
export enum ContentPartType {
TEXT = 'text',
IMAGE_URL = 'image_url',
INPUT_AUDIO = 'input_audio',
INPUT_VIDEO = 'input_video'
INPUT_VIDEO = 'input_video',
TEXT = 'text'
}
/**
* Error dialog types for displaying server/timeout errors.
*/
export enum ErrorDialogType {
TIMEOUT = 'timeout',
SERVER = 'server'
SERVER = 'server',
TIMEOUT = 'timeout'
}
export enum ConversationSelectionMode {
@@ -75,27 +75,27 @@ export enum ConversationSelectionMode {
* PDF view mode options for previewing PDF attachments.
*/
export enum PdfViewMode {
TEXT = 'text',
PAGES = 'pages'
PAGES = 'pages',
TEXT = 'text'
}
export enum ChatFormCommandAction {
PROMPT = 'prompt',
CWD = 'cwd',
MODEL = 'model'
MODEL = 'model',
PROMPT = 'prompt'
}
export enum FileMentionEntryType {
FILE = 'file',
DIRECTORY = 'directory'
DIRECTORY = 'directory',
FILE = 'file'
}
/**
* Kinds of tokens the chat-form-input-rich produces.
*/
export enum ChatFormInputRichTokenKind {
TEXT = 'text',
BADGE = 'badge',
CODE_BLOCK = 'code_block',
CODE_INLINE = 'code_inline',
CODE_BLOCK = 'code_block'
TEXT = 'text'
}
@@ -4,6 +4,6 @@
* message record belongs to it.
*/
export enum SessionRecordType {
SESSION = 'session',
MESSAGE = 'message'
MESSAGE = 'message',
SESSION = 'session'
}
+132 -132
View File
@@ -5,11 +5,11 @@
// File type category enum
export enum FileTypeCategory {
IMAGE = 'image',
AUDIO = 'audio',
VIDEO = 'video',
IMAGE = 'image',
PDF = 'pdf',
TEXT = 'text'
TEXT = 'text',
VIDEO = 'video'
}
/**
@@ -21,13 +21,13 @@ export enum SpecialFileType {
// Specific file type enums for each category
export enum FileTypeImage {
GIF = 'gif',
HEIC = 'heic',
HEIF = 'heif',
JPEG = 'jpeg',
PNG = 'png',
GIF = 'gif',
WEBP = 'webp',
SVG = 'svg',
HEIC = 'heic',
HEIF = 'heif'
WEBP = 'webp'
}
export enum FileTypeAudio {
@@ -46,55 +46,55 @@ export enum FileTypePdf {
}
export enum FileTypeText {
PLAIN_TEXT = 'plainText',
MARKDOWN = 'md',
ASCIIDOC = 'asciidoc',
JAVASCRIPT = 'js',
TYPESCRIPT = 'ts',
JSX = 'jsx',
TSX = 'tsx',
CSS = 'css',
HTML = 'html',
JSON = 'json',
XML = 'xml',
YAML = 'yaml',
CSV = 'csv',
LOG = 'log',
PYTHON = 'python',
JAVA = 'java',
BIBTEX = 'bibtex',
CPP = 'cpp',
PHP = 'php',
RUBY = 'ruby',
CSHARP = 'csharp',
CSS = 'css',
CSV = 'csv',
CUDA = 'cuda',
DART = 'dart',
GO = 'go',
HASKELL = 'haskell',
HTML = 'html',
JAVA = 'java',
JAVASCRIPT = 'js',
JSON = 'json',
JSX = 'jsx',
KOTLIN = 'kotlin',
LATEX = 'latex',
LOG = 'log',
MARKDOWN = 'md',
PHP = 'php',
PLAIN_TEXT = 'plainText',
PROPERTIES = 'properties',
PYTHON = 'python',
R = 'r',
RUBY = 'ruby',
RUST = 'rust',
SCALA = 'scala',
SHELL = 'shell',
SQL = 'sql',
R = 'r',
SCALA = 'scala',
KOTLIN = 'kotlin',
SWIFT = 'swift',
DART = 'dart',
VUE = 'vue',
SVELTE = 'svelte',
LATEX = 'latex',
BIBTEX = 'bibtex',
CUDA = 'cuda',
SWIFT = 'swift',
TSX = 'tsx',
TYPESCRIPT = 'ts',
VUE = 'vue',
VULKAN = 'vulkan',
HASKELL = 'haskell',
CSHARP = 'csharp',
PROPERTIES = 'properties'
XML = 'xml',
YAML = 'yaml'
}
// File extension enums
export enum FileExtensionImage {
JPG = '.jpg',
JPEG = '.jpeg',
PNG = '.png',
GIF = '.gif',
WEBP = '.webp',
SVG = '.svg',
HEIC = '.heic',
HEIF = '.heif'
HEIF = '.heif',
JPEG = '.jpeg',
JPG = '.jpg',
PNG = '.png',
SVG = '.svg',
WEBP = '.webp'
}
export enum FileExtensionAudio {
@@ -112,64 +112,64 @@ export enum FileExtensionPdf {
}
export enum FileExtensionText {
TXT = '.txt',
MD = '.md',
ADOC = '.adoc',
JS = '.js',
TS = '.ts',
JSX = '.jsx',
TSX = '.tsx',
BAT = '.bat',
BIB = '.bib',
C = '.c',
COMP = '.comp',
CPP = '.cpp',
CS = '.cs',
CSS = '.css',
HTML = '.html',
CSV = '.csv',
CU = '.cu',
CUH = '.cuh',
DART = '.dart',
GO = '.go',
H = '.h',
HPP = '.hpp',
HS = '.hs',
HTM = '.htm',
HTML = '.html',
JAVA = '.java',
JS = '.js',
JSON = '.json',
JSONL = '.jsonl',
ZIP = '.zip',
JSX = '.jsx',
KT = '.kt',
LOG = '.log',
MD = '.md',
PHP = '.php',
PROPERTIES = '.properties',
PY = '.py',
R = '.r',
RB = '.rb',
RS = '.rs',
SCALA = '.scala',
SH = '.sh',
SQL = '.sql',
SVELTE = '.svelte',
SWIFT = '.swift',
TEX = '.tex',
TS = '.ts',
TSX = '.tsx',
TXT = '.txt',
VUE = '.vue',
XML = '.xml',
YAML = '.yaml',
YML = '.yml',
CSV = '.csv',
LOG = '.log',
PY = '.py',
JAVA = '.java',
CPP = '.cpp',
C = '.c',
H = '.h',
PHP = '.php',
RB = '.rb',
GO = '.go',
RS = '.rs',
SH = '.sh',
BAT = '.bat',
SQL = '.sql',
R = '.r',
SCALA = '.scala',
KT = '.kt',
SWIFT = '.swift',
DART = '.dart',
VUE = '.vue',
SVELTE = '.svelte',
TEX = '.tex',
BIB = '.bib',
CU = '.cu',
CUH = '.cuh',
COMP = '.comp',
HPP = '.hpp',
HS = '.hs',
PROPERTIES = '.properties',
CS = '.cs'
ZIP = '.zip'
}
// MIME type prefixes and includes for content detection
export enum MimeTypePrefix {
IMAGE = 'image/',
AUDIO = 'audio/',
IMAGE = 'image/',
TEXT = 'text'
}
export enum MimeTypeIncludes {
JSON = 'json',
JAVASCRIPT = 'javascript',
JSON = 'json',
TYPESCRIPT = 'typescript'
}
@@ -182,23 +182,23 @@ export enum UriPattern {
// MIME type enums
export enum MimeTypeApplication {
JSON = 'application/json',
PDF = 'application/pdf',
OCTET_STREAM = 'application/octet-stream',
PDF = 'application/pdf',
ZIP = 'application/zip'
}
export enum MimeTypeAudio {
MP3_MPEG = 'audio/mpeg',
MP3 = 'audio/mp3',
MP3_MPEG = 'audio/mpeg',
MP4 = 'audio/mp4',
VND_WAVE = 'audio/vnd.wave',
WAV = 'audio/wav',
WAVE = 'audio/wave',
X_WAV = 'audio/x-wav',
X_WAVE = 'audio/x-wave',
VND_WAVE = 'audio/vnd.wave',
X_PN_WAV = 'audio/x-pn-wav',
WEBM = 'audio/webm',
WEBM_OPUS = 'audio/webm;codecs=opus'
WEBM_OPUS = 'audio/webm;codecs=opus',
X_PN_WAV = 'audio/x-pn-wav',
X_WAV = 'audio/x-wav',
X_WAVE = 'audio/x-wave'
}
export enum MimeTypeVideo {
@@ -207,62 +207,62 @@ export enum MimeTypeVideo {
}
export enum MimeTypeImage {
GIF = 'image/gif',
HEIC = 'image/heic',
HEIF = 'image/heif',
ICO = 'image/x-icon',
ICO_MICROSOFT = 'image/vnd.microsoft.icon',
JPEG = 'image/jpeg',
JPG = 'image/jpg',
PNG = 'image/png',
GIF = 'image/gif',
WEBP = 'image/webp',
SVG = 'image/svg+xml',
ICO = 'image/x-icon',
ICO_MICROSOFT = 'image/vnd.microsoft.icon',
HEIC = 'image/heic',
HEIF = 'image/heif'
WEBP = 'image/webp'
}
export enum MimeTypeText {
PLAIN = 'text/plain',
MARKDOWN = 'text/markdown',
ASCIIDOC = 'text/asciidoc',
JAVASCRIPT = 'text/javascript',
JAVASCRIPT_APP = 'application/javascript',
TYPESCRIPT = 'text/typescript',
JSX = 'text/jsx',
TSX = 'text/tsx',
CSS = 'text/css',
HTML = 'text/html',
JSON = 'application/json',
JSONL = 'application/jsonl',
XML_TEXT = 'text/xml',
XML_APP = 'application/xml',
YAML_TEXT = 'text/yaml',
YAML_APP = 'application/yaml',
CSV = 'text/csv',
PYTHON = 'text/x-python',
JAVA = 'text/x-java-source',
BAT = 'application/x-bat',
BIBTEX = 'text/x-bibtex',
C_HDR = 'text/x-chdr',
C_SRC = 'text/x-csrc',
CPP_HDR = 'text/x-c++hdr',
CPP_SRC = 'text/x-c++src',
CSHARP = 'text/x-csharp',
HASKELL = 'text/x-haskell',
C_SRC = 'text/x-csrc',
C_HDR = 'text/x-chdr',
PHP = 'text/x-php',
RUBY = 'text/x-ruby',
GO = 'text/x-go',
RUST = 'text/x-rust',
SHELL = 'text/x-shellscript',
BAT = 'application/x-bat',
SQL = 'text/x-sql',
R = 'text/x-r',
SCALA = 'text/x-scala',
KOTLIN = 'text/x-kotlin',
SWIFT = 'text/x-swift',
CSS = 'text/css',
CSV = 'text/csv',
CUDA = 'text/x-cuda',
DART = 'text/x-dart',
VUE = 'text/x-vue',
GO = 'text/x-go',
HASKELL = 'text/x-haskell',
HTML = 'text/html',
JAVA = 'text/x-java-source',
JAVASCRIPT = 'text/javascript',
JAVASCRIPT_APP = 'application/javascript',
JSON = 'application/json',
JSONL = 'application/jsonl',
JSX = 'text/jsx',
KOTLIN = 'text/x-kotlin',
LATEX = 'application/x-latex',
MARKDOWN = 'text/markdown',
PHP = 'text/x-php',
PLAIN = 'text/plain',
PROPERTIES = 'text/properties',
PYTHON = 'text/x-python',
R = 'text/x-r',
RUBY = 'text/x-ruby',
RUST = 'text/x-rust',
SCALA = 'text/x-scala',
SHELL = 'text/x-shellscript',
SQL = 'text/x-sql',
SVELTE = 'text/x-svelte',
SWIFT = 'text/x-swift',
TEX = 'text/x-tex',
TEX_APP = 'application/x-tex',
LATEX = 'application/x-latex',
BIBTEX = 'text/x-bibtex',
CUDA = 'text/x-cuda',
PROPERTIES = 'text/properties'
TSX = 'text/tsx',
TYPESCRIPT = 'text/typescript',
VUE = 'text/x-vue',
XML_APP = 'application/xml',
XML_TEXT = 'text/xml',
YAML_APP = 'application/yaml',
YAML_TEXT = 'text/yaml'
}
+5 -5
View File
@@ -2,19 +2,19 @@
* Keyboard key names for event handling
*/
export enum KeyboardKey {
ENTER = 'Enter',
ESCAPE = 'Escape',
ARROW_UP = 'ArrowUp',
ARROW_DOWN = 'ArrowDown',
ARROW_LEFT = 'ArrowLeft',
ARROW_RIGHT = 'ArrowRight',
TAB = 'Tab',
ARROW_UP = 'ArrowUp',
B_LOWER = 'b',
D_LOWER = 'd',
D_UPPER = 'D',
E_UPPER = 'E',
ENTER = 'Enter',
ESCAPE = 'Escape',
K_LOWER = 'k',
O_LOWER = 'o',
O_UPPER = 'O',
SPACE = ' '
SPACE = ' ',
TAB = 'Tab'
}
+17 -17
View File
@@ -2,61 +2,61 @@
* Connection lifecycle phases for MCP protocol
*/
export enum MCPConnectionPhase {
IDLE = 'idle',
TRANSPORT_CREATING = 'transport_creating',
TRANSPORT_READY = 'transport_ready',
INITIALIZING = 'initializing',
CAPABILITIES_EXCHANGED = 'capabilities_exchanged',
LISTING_TOOLS = 'listing_tools',
CONNECTED = 'connected',
DISCONNECTED = 'disconnected',
ERROR = 'error',
DISCONNECTED = 'disconnected'
IDLE = 'idle',
INITIALIZING = 'initializing',
LISTING_TOOLS = 'listing_tools',
TRANSPORT_CREATING = 'transport_creating',
TRANSPORT_READY = 'transport_ready'
}
/**
* Log level for connection events
*/
export enum MCPLogLevel {
ERROR = 'error',
INFO = 'info',
WARN = 'warn',
ERROR = 'error'
WARN = 'warn'
}
/**
* Transport types for MCP connections
*/
export enum MCPTransportType {
WEBSOCKET = 'websocket',
SSE = 'sse',
STREAMABLE_HTTP = 'streamable_http',
SSE = 'sse'
WEBSOCKET = 'websocket'
}
/**
* Health check status for MCP servers
*/
export enum HealthCheckStatus {
IDLE = 'idle',
CONNECTING = 'connecting',
SUCCESS = 'success',
ERROR = 'error'
ERROR = 'error',
IDLE = 'idle',
SUCCESS = 'success'
}
/**
* Content types for MCP tool results
*/
export enum MCPContentType {
TEXT = 'text',
IMAGE = 'image',
RESOURCE = 'resource'
RESOURCE = 'resource',
TEXT = 'text'
}
/**
* JSON Schema types used in MCP tool definitions
*/
export enum JsonSchemaType {
NUMBER = 'number',
OBJECT = 'object',
STRING = 'string',
NUMBER = 'number'
STRING = 'string'
}
/**
+3 -3
View File
@@ -1,6 +1,6 @@
export enum ModelModality {
TEXT = 'TEXT',
AUDIO = 'AUDIO',
VISION = 'VISION',
VIDEO = 'VIDEO'
TEXT = 'TEXT',
VIDEO = 'VIDEO',
VISION = 'VISION'
}
@@ -4,9 +4,9 @@
*/
export enum ReasoningEffort {
DEFAULT = 'default',
OFF = 'off',
LOW = 'low',
MEDIUM = 'medium',
HIGH = 'high',
MAX = 'max'
LOW = 'low',
MAX = 'max',
MEDIUM = 'medium',
OFF = 'off'
}
+8 -8
View File
@@ -13,11 +13,11 @@ export enum ServerRole {
* Used as the `value` field in the status object from /models endpoint
*/
export enum ServerModelStatus {
UNLOADED = 'unloaded',
LOADING = 'loading',
FAILED = 'failed',
LOADED = 'loaded',
LOADING = 'loading',
SLEEPING = 'sleeping',
FAILED = 'failed'
UNLOADED = 'unloaded'
}
/**
@@ -26,10 +26,10 @@ export enum ServerModelStatus {
* tools/server/server-models.cpp from the C++ server.
*/
export enum ServerModelsSseEventType {
STATUS_CHANGE = 'status_change',
MODEL_STATUS = 'model_status',
STATUS_UPDATE = 'status_update',
MODELS_RELOAD = 'models_reload',
DOWNLOAD_PROGRESS = 'download_progress',
MODEL_REMOVE = 'model_remove',
DOWNLOAD_PROGRESS = 'download_progress'
MODEL_STATUS = 'model_status',
MODELS_RELOAD = 'models_reload',
STATUS_CHANGE = 'status_change',
STATUS_UPDATE = 'status_update'
}
+7 -7
View File
@@ -2,26 +2,26 @@
* Parameter source - indicates whether a parameter uses default or custom value
*/
export enum ParameterSource {
DEFAULT = 'default',
CUSTOM = 'custom'
CUSTOM = 'custom',
DEFAULT = 'default'
}
/**
* Syncable parameter type - data types for parameters that can be synced with server
*/
export enum SyncableParameterType {
BOOLEAN = 'boolean',
NUMBER = 'number',
STRING = 'string',
BOOLEAN = 'boolean'
STRING = 'string'
}
/**
* Settings field type - defines the input type for settings fields
*/
export enum SettingsFieldType {
INPUT = 'input',
TEXTAREA = 'textarea',
CHECKBOX = 'checkbox',
INPUT = 'input',
RADIO = 'radio',
SELECT = 'select',
RADIO = 'radio'
TEXTAREA = 'textarea'
}
+2 -2
View File
@@ -2,6 +2,6 @@
* Splash screen orientation for iOS apple-touch-startup-image
*/
export enum SplashOrientation {
PORTRAIT = 'portrait',
LANDSCAPE = 'landscape'
LANDSCAPE = 'landscape',
PORTRAIT = 'portrait'
}
+30 -24
View File
@@ -1,20 +1,20 @@
export enum ToolSource {
BUILTIN = 'builtin',
MCP = 'mcp',
BROWSER = 'browser',
CUSTOM = 'custom',
FRONTEND = 'frontend'
MCP = 'mcp',
SERVER = 'server'
}
export enum ToolPermissionDecision {
ALWAYS = 'always',
ALWAYS_SERVER = 'always_server',
ONCE = 'once',
DENY = 'deny'
DENY = 'deny',
ONCE = 'once'
}
export enum ToolResponseField {
PLAIN_TEXT = 'plain_text_response',
ERROR = 'error'
ERROR = 'error',
PLAIN_TEXT = 'plain_text_response'
}
/**
@@ -22,28 +22,34 @@ export enum ToolResponseField {
* Mirrors the server-side validation in server-tools.cpp.
*/
export enum GlobSearchType {
FILE = 'file',
ALL = 'all',
DIR = 'dir',
ALL = 'all'
FILE = 'file'
}
/**
* Wire-format identifiers for built-in and frontend tools. The string
* Wire-format identifiers for server and browser tools. The string
* value matches what the model emits in tool call names, so comparing
* against `BuiltInTool.READ_FILE` is equivalent to comparing against the
* raw `'read_file'` literal - the enum just keeps the two in lock-step
* and gives TypeScript a single source of truth for autocomplete / rename
* support.
* against `BuiltInTool.SERVER_READ_FILE` is equivalent to comparing
* against the raw `'read_file'` literal - the enum just keeps the two in
* lock-step and gives TypeScript a single source of truth for autocomplete
* / rename support.
*
* The `SERVER_` / `BROWSER_` prefixes mirror the tool's primary source
* (llama-server vs llama-ui). `get_info` is the exception: it is served by
* the server, but llama-ui falls back to a browser implementation when the
* server does not provide it, so it can surface under both categories in
* the UI while keeping a single wire name.
*/
export enum BuiltInTool {
READ_FILE = 'read_file',
READ_MEDIA = 'read_media',
EDIT_FILE = 'edit_file',
WRITE_FILE = 'write_file',
GET_DATETIME = 'get_datetime',
GET_INFO = 'get_info',
FILE_GLOB_SEARCH = 'file_glob_search',
GREP_SEARCH = 'grep_search',
EXEC_SHELL_COMMAND = 'exec_shell_command',
RUN_JAVASCRIPT = 'run_javascript'
BROWSER_GET_DATETIME = 'get_datetime',
BROWSER_READ_MEDIA = 'read_media',
BROWSER_RUN_JAVASCRIPT = 'run_javascript',
SERVER_EDIT_FILE = 'edit_file',
SERVER_EXEC_SHELL_COMMAND = 'exec_shell_command',
SERVER_FILE_GLOB_SEARCH = 'file_glob_search',
SERVER_GET_INFO = 'get_info',
SERVER_GREP_SEARCH = 'grep_search',
SERVER_READ_FILE = 'read_file',
SERVER_WRITE_FILE = 'write_file'
}
+9 -9
View File
@@ -1,22 +1,22 @@
export enum ColorMode {
LIGHT = 'light',
DARK = 'dark',
LIGHT = 'light',
SYSTEM = 'system'
}
export enum TooltipSide {
TOP = 'top',
RIGHT = 'right',
BOTTOM = 'bottom',
LEFT = 'left'
LEFT = 'left',
RIGHT = 'right',
TOP = 'top'
}
/**
* MCP prompt display variant
*/
export enum McpPromptVariant {
MESSAGE = 'message',
ATTACHMENT = 'attachment'
ATTACHMENT = 'attachment',
MESSAGE = 'message'
}
/**
@@ -39,8 +39,8 @@ export enum HtmlInputType {
* Alert level that drives the context gauge dial color.
*/
export enum ColorLevel {
OK = 'ok',
WARNING = 'warning',
CRITICAL = 'critical',
NEUTRAL = 'neutral'
NEUTRAL = 'neutral',
OK = 'ok',
WARNING = 'warning'
}
@@ -8,36 +8,15 @@
* demand if they aren't cached yet.
*/
import { chatStore, conversationsStore, modelsStore, serverStore } from '$lib/stores';
import { conversationsStore, modelsStore, serverStore } from '$lib/stores';
import { getConversationModel } from '$lib/utils';
export function useChatScreenActiveModel() {
const isRouter = $derived(serverStore.isRouterMode);
const conversationModel = $derived(
chatStore.getConversationModel(conversationsStore.activeMessages as DatabaseMessage[])
getConversationModel(conversationsStore.activeMessages as DatabaseMessage[])
);
const activeModelId = $derived.by(() => {
const options = modelsStore.models;
if (!isRouter) {
return options.length > 0 ? options[0].model : null;
}
const selectedId = modelsStore.selectedModelId;
if (selectedId) {
const model = options.find((m) => m.id === selectedId);
if (model) return model.model;
}
if (conversationModel) {
const model = options.find((m) => m.model === conversationModel);
if (model) return model.model;
}
return null;
});
const activeModelId = $derived(modelsStore.activeModelId);
let modelPropsVersion = $state(0);
+1 -1
View File
@@ -58,7 +58,7 @@ export function usePwa() {
// PWA pages update via the service worker path; the storage check is the non-PWA fallback only
if (navigator.serviceWorker?.controller) return;
const currentVersion = versionStore.value;
const currentVersion = versionStore.frontend;
if (!currentVersion) return;
@@ -1,8 +1,9 @@
import { REASONING_EFFORT_LEVELS, REASONING_EFFORT_TOKENS } from '$lib/constants';
import { ReasoningEffort } from '$lib/enums';
import { chatStore, conversationsStore, modelsStore, serverStore } from '$lib/stores';
import { conversationsStore, modelsStore, serverStore } from '$lib/stores';
import type { ReasoningEffortLevel } from '$lib/types';
import type { DatabaseMessage } from '$lib/types/database';
import { getConversationModel } from '$lib/utils';
export interface UseReasoningMenuReturn {
readonly modelSupportsThinking: boolean;
@@ -24,7 +25,7 @@ export interface UseReasoningMenuReturn {
*/
export function useReasoningMenu(): UseReasoningMenuReturn {
const conversationModel = $derived(
chatStore.getConversationModel(conversationsStore.activeMessages as DatabaseMessage[])
getConversationModel(conversationsStore.activeMessages as DatabaseMessage[])
);
// a router chat can carry reasoning from an earlier turn before the props
// cache is primed, so a model that already produced thinking still qualifies
@@ -94,8 +94,8 @@ export function useToolsPanel(): UseToolsPanelReturn {
}
function handleOpen(): void {
if (toolsStore.builtinTools.length === 0 && !toolsStore.loading) {
toolsStore.fetchBuiltinTools();
if (toolsStore.serverTools.length === 0 && !toolsStore.loading) {
toolsStore.fetchServerTools();
}
mcpStore.runHealthChecksForServers(mcpStore.getServers().filter((s) => s.enabled));
+57 -9
View File
@@ -13,6 +13,7 @@ import {
SSE_DATA_PREFIX,
SSE_DONE_MARKER,
SSE_LINE_SEPARATOR,
STREAM_QUERY_PARAMS,
STREAM_RESUME_LOCALSTORAGE_KEY_PREFIX,
STREAM_VISIBILITY_KICK_MS
} from '$lib/constants';
@@ -33,6 +34,7 @@ import type {
ApiStreamSession
} from '$lib/types/api';
import { isAbortError } from '$lib/utils/abort';
import { ApiError } from '$lib/utils/api-fetch';
import { getAuthHeaders, getJsonHeaders } from '$lib/utils/api-headers';
import { formatAttachmentText } from '$lib/utils/formatters';
import { streamIdentity } from '$lib/utils/stream-identity';
@@ -529,7 +531,7 @@ export class ChatService {
try {
const id = streamIdentity(conversationId, model);
await fetch(`${API_STREAM.BASE}?conv_id=${encodeURIComponent(id)}`, {
await fetch(ChatService.buildStreamUrl(id), {
headers: getAuthHeaders(),
method: 'DELETE'
});
@@ -538,6 +540,46 @@ export class ChatService {
}
}
/**
* Look up server-side stream sessions for the given conversation ids. Ids carry the frozen
* conv::model identity when a model was bound at POST time.
*/
static async lookupStreamSessions(conversationIds: string[]): Promise<ApiStreamSession[]> {
const resp = await fetch(API_STREAM.LOOKUP, {
body: JSON.stringify({ conversation_ids: conversationIds }),
headers: getJsonHeaders(),
method: 'POST'
});
if (!resp.ok) {
throw new ApiError(`Stream lookup failed with HTTP ${resp.status}`, resp.status);
}
const body = (await resp.json()) as unknown;
if (!Array.isArray(body)) {
throw new Error('Stream lookup returned a non-array response');
}
return body as ApiStreamSession[];
}
/**
* Fetch the full replay of a server-side stream from byte 0. Returns the raw Response so the
* caller can pipe it through the SSE parser like a fresh stream.
*/
static async fetchStreamReplay(streamId: string): Promise<Response> {
const resp = await fetch(ChatService.buildStreamUrl(streamId, 0), {
headers: getAuthHeaders()
});
if (!resp.ok) {
throw new ApiError(`Stream replay failed with HTTP ${resp.status}`, resp.status);
}
return resp;
}
/**
* Pick the running session to splice into when discoverActiveStream lists candidates for a
* conversation. Finalized sessions are not candidates: their final content was already written
@@ -629,6 +671,15 @@ export class ChatService {
return streamIdentity(conversationId, model);
}
// build the replay route url for a stream identity, from is the resume byte offset, omitted
// for the cancel route
private static buildStreamUrl(streamId: string, from?: number): string {
const query = `${STREAM_QUERY_PARAMS.CONV_ID}=${encodeURIComponent(streamId)}`;
const offset = from === undefined ? '' : `&${STREAM_QUERY_PARAMS.FROM}=${from}`;
return `${API_STREAM.BASE}?${query}${offset}`;
}
/**
* Reconnect to an interrupted stream for this conversation. Returns the fetch Response so the
* existing SSE parser drains it like a fresh stream. The server returns 200 on success, 404 if
@@ -642,13 +693,10 @@ export class ChatService {
const ac = new AbortController();
try {
const resp = await fetch(
`${API_STREAM.BASE}?conv_id=${encodeURIComponent(streamId)}&from=0`,
{
headers: getAuthHeaders(),
signal: ac.signal
}
);
const resp = await fetch(ChatService.buildStreamUrl(streamId, 0), {
headers: getAuthHeaders(),
signal: ac.signal
});
ac.abort();
@@ -668,7 +716,7 @@ export class ChatService {
const state = ChatService.getStreamState(conversationId);
const from = state?.bytesReceived ?? 0;
const id = streamIdentity(conversationId, model);
const url = `${API_STREAM.BASE}?conv_id=${encodeURIComponent(id)}&from=${from}`;
const url = ChatService.buildStreamUrl(id, from);
return await fetch(url, { headers: getAuthHeaders(), method: 'GET', signal });
}
@@ -0,0 +1,279 @@
/**
* ConversationTransferService - Stateless conversation import/export layer
*
* Owns the session file format (one JSONL record per line: a SESSION header
* followed by MESSAGE records), ZIP archiving and browser downloads.
* DB access and store refreshes stay in conversationsStore.
*/
import { EXPORT_CONV, NEWLINE, ZIP_MAGIC } from '$lib/constants';
import {
FileExtensionText,
MimeTypeApplication,
MimeTypeText,
SessionRecordType
} from '$lib/enums';
import { strFromU8, strToU8, unzipSync, zipSync } from 'fflate';
export class ConversationTransferService {
/**
*
*
* JSONL Session Format
*
*
*/
/**
* Serializes a session (a conversation with its messages) as JSONL.
* The first line is the session header (a `SessionRecordType.SESSION` record
* carrying the conversation properties); each subsequent line is a single message.
* @param data - The exported conversation payload
* @returns The JSONL string (one record per line)
*/
static serializeSessionToJsonl(data: ExportedConversation): string {
const { conv, messages } = data;
const sessionLine = JSON.stringify({
harness: EXPORT_CONV.HARNESS,
type: SessionRecordType.SESSION,
...conv
});
const messageLines = messages.map((message: DatabaseMessage) => {
// `toolCalls` is stored as a JSON string; drop it when empty, otherwise parse it.
const { toolCalls, ...rest } = message;
const normalized = toolCalls ? { ...rest, toolCalls: JSON.parse(toolCalls) } : rest;
return JSON.stringify({ message: normalized, type: SessionRecordType.MESSAGE });
});
return [sessionLine, ...messageLines].join(NEWLINE);
}
/**
* Parses the JSONL session format produced by {@link serializeSessionToJsonl}.
* A `SessionRecordType.SESSION` line starts a new session; following
* `SessionRecordType.MESSAGE` lines are appended to it. Supports multiple
* sessions in a single file.
* @param text - The JSONL file contents
* @returns The parsed conversations with their messages
*/
static parseSessionsJsonl(text: string): ExportedConversation[] {
const sessions: ExportedConversation[] = [];
let current: ExportedConversation | null = null;
for (const line of text.split(NEWLINE)) {
const trimmed = line.trim();
if (!trimmed) continue;
const record = JSON.parse(trimmed);
if (record.type === SessionRecordType.SESSION) {
// Drop the discriminator and harness marker; the rest is the conversation.
const conv = { ...record };
delete conv.type;
delete conv.harness;
current = { conv: conv as DatabaseConversation, messages: [] };
sessions.push(current);
} else if (record.type === SessionRecordType.MESSAGE) {
if (!current) {
throw new Error('Invalid JSONL: message record before any session record');
}
const message = record.message as DatabaseMessage;
// `toolCalls` is parsed to an array on export; the DB stores it as a string.
if (message.toolCalls !== undefined && typeof message.toolCalls !== 'string') {
message.toolCalls = JSON.stringify(message.toolCalls);
}
current.messages.push(message);
}
// Ignore unknown record types for forward compatibility.
}
return sessions;
}
/**
* Reports whether the text is the JSONL session format, whose first non-empty
* line is a `SessionRecordType.SESSION` record. A legacy JSON export starts
* with an array or an object that has no such discriminator.
* @param text - The file contents
*/
private static isSessionsJsonl(text: string): boolean {
const trimmed = text.trimStart();
const lineEnd = trimmed.indexOf(NEWLINE);
const firstLine = lineEnd === -1 ? trimmed : trimmed.slice(0, lineEnd);
try {
return JSON.parse(firstLine).type === SessionRecordType.SESSION;
} catch {
// Not a standalone JSON record, so not the JSONL format.
return false;
}
}
/**
* Parses an import file into conversations, accepting the current JSONL and
* ZIP formats as well as the legacy JSON format. The format comes from the
* contents, so an import works whatever the file is named.
* @param file - The user-selected file
* @returns The parsed conversations with their messages
*/
static async parseImportFile(file: File): Promise<ExportedConversation[]> {
const bytes = new Uint8Array(await file.arrayBuffer());
if (ZIP_MAGIC.every((byte, index) => bytes[index] === byte)) {
const entries = unzipSync(bytes);
const sessions: ExportedConversation[] = [];
for (const [entryName, entryBytes] of Object.entries(entries)) {
if (!entryName.toLowerCase().endsWith(FileExtensionText.JSONL)) continue;
sessions.push(...ConversationTransferService.parseSessionsJsonl(strFromU8(entryBytes)));
}
return sessions;
}
const text = strFromU8(bytes);
if (ConversationTransferService.isSessionsJsonl(text)) {
return ConversationTransferService.parseSessionsJsonl(text);
}
// Legacy JSON format: an array of conversations or a single conversation object.
const parsed = JSON.parse(text);
if (Array.isArray(parsed)) {
return parsed;
}
if (parsed && typeof parsed === 'object' && 'conv' in parsed && 'messages' in parsed) {
return [parsed];
}
throw new Error(
'Invalid file format: expected array of conversations or single conversation object'
);
}
/**
*
*
* Downloads
*
*
*/
/**
* Generates a sanitized filename for a conversation export
* @param conversation - The conversation metadata
* @param msgs - Optional array of messages belonging to the conversation
* @returns The generated filename string
*/
static generateConversationFilename(
conversation: { id?: string; name?: string },
msgs?: DatabaseMessage[]
): string {
const conversationName = (conversation.name ?? '').trim().toLowerCase();
const sanitizedName = conversationName
.replace(EXPORT_CONV.NON_ALPHANUMERIC_REGEX, EXPORT_CONV.NONALNUM_REPLACEMENT)
.replace(EXPORT_CONV.MULTIPLE_UNDERSCORE_REGEX, '_')
.substring(0, EXPORT_CONV.NAME_SUFFIX_MAX_LENGTH);
// If we have messages, use the timestamp of the newest message
const referenceDate = msgs?.length
? new Date(Math.max(...msgs.map((m) => m.timestamp)))
: new Date();
const iso = referenceDate.toISOString().slice(0, EXPORT_CONV.ISO_TIMESTAMP_SLICE);
const formattedDate = iso
.replace(EXPORT_CONV.ISO_DATE_TIME_SEPARATOR, EXPORT_CONV.ISO_DATE_TIME_SEPARATOR_REPLACEMENT)
.replaceAll(EXPORT_CONV.ISO_TIME_SEPARATOR, EXPORT_CONV.ISO_TIME_SEPARATOR_REPLACEMENT);
const trimmedConvId = conversation.id?.slice(0, EXPORT_CONV.ID_TRIM_LENGTH) ?? '';
return `${formattedDate}_conv_${trimmedConvId}_${sanitizedName}${FileExtensionText.JSONL}`;
}
/**
* Triggers a browser download of the provided exported conversation data
* @param data - The exported conversation payload (a single conversation with its messages)
* @param filename - Filename; if omitted, a deterministic name is generated
*/
static downloadConversationFile(data: ExportedConversation, filename?: string): void {
const { conv: conversation, messages: msgs } = data;
if (!conversation) {
console.error('Invalid data: missing conversation');
return;
}
const downloadFilename =
filename ?? ConversationTransferService.generateConversationFilename(conversation, msgs);
const jsonl = ConversationTransferService.serializeSessionToJsonl(data);
const blob = new Blob([jsonl], { type: MimeTypeText.JSONL });
ConversationTransferService.triggerDownload(blob, downloadFilename);
}
/**
* Triggers a browser download of multiple conversations as a `.zip`, one
* `.jsonl` file per conversation.
* @param data - The conversations to export
*/
static downloadConversationsArchive(data: ExportedConversation[]): void {
if (data.length === 0) {
console.error('Invalid data: no conversations to export');
return;
}
const usedNames = new Set<string>();
const files: Record<string, Uint8Array> = {};
for (const session of data) {
const baseName = ConversationTransferService.generateConversationFilename(
session.conv,
session.messages
);
// Disambiguate any duplicate filenames within the archive.
let entryName = baseName;
let suffix = 1;
while (usedNames.has(entryName)) {
entryName = baseName.replace(
new RegExp(`${FileExtensionText.JSONL}$`),
`_${suffix++}${FileExtensionText.JSONL}`
);
}
usedNames.add(entryName);
files[entryName] = strToU8(ConversationTransferService.serializeSessionToJsonl(session));
}
const archiveName = `${new Date().toISOString().split(EXPORT_CONV.ISO_DATE_TIME_SEPARATOR)[0]}_conversations${FileExtensionText.ZIP}`;
const zipped = zipSync(files);
const blob = new Blob([zipped], { type: MimeTypeApplication.ZIP });
ConversationTransferService.triggerDownload(blob, archiveName);
}
/**
* Triggers a browser download of a blob under the given filename.
*/
private static triggerDownload(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
}
+12 -3
View File
@@ -103,6 +103,15 @@ export { ChatService } from './chat.service';
*/
export { DatabaseService } from './database.service';
/**
* **ConversationTransferService** - Conversation import/export format layer
*
* Owns the JSONL session format (SESSION header + MESSAGE records), ZIP
* archiving and browser downloads. Stateless; DB access and store refreshes
* stay in conversationsStore.
*/
export { ConversationTransferService } from './conversation-transfer.service';
/**
* **ModelsService** - Model management API communication
*
@@ -262,9 +271,9 @@ export { ParameterSyncService } from './parameter-sync.service';
export { MCPService } from './mcp.service';
/**
* **SandboxService** - Frontend JavaScript execution in a browser sandbox
* **SandboxService** - Browser JavaScript execution in a browser sandbox
*
* Stateless executor for the run_javascript frontend tool. Model generated
* Stateless executor for the run_javascript browser tool. Model generated
* code runs in a Web Worker spawned inside a sandboxed iframe with an opaque
* origin: no access to the app origin, its storage or its API, and outgoing
* requests carry a null origin. The code never touches a main thread, so the
@@ -274,7 +283,7 @@ export { MCPService } from './mcp.service';
* **Architecture & Relationships:**
* - **SandboxService** (this class): Stateless sandbox execution
* - **toolsStore**: Exposes the tool definition when the sandbox is enabled
* - **agenticStore**: Dispatches ToolSource.FRONTEND calls here
* - **agenticStore**: Dispatches ToolSource.BROWSER calls here
*
* @see buildSandboxToolDefinition in utils/sandbox-tool - tool schema sent to the LLM
* @see agenticStore in stores/agentic.svelte.ts - tool dispatch
+92 -1
View File
@@ -1,7 +1,15 @@
import { API_MODELS, MODEL_ID } from '$lib/constants';
import { base } from '$app/paths';
import {
API_MODELS,
MODEL_ID,
SSE_DATA_PREFIX,
SSE_LINE_SEPARATOR,
SSE_RECORD_SEPARATOR
} from '$lib/constants';
import { ServerModelStatus } from '$lib/enums';
import type { ParsedModelId } from '$lib/types/models';
import { apiFetch, apiPost, normalizeModelName } from '$lib/utils';
import { getAuthHeaders } from '$lib/utils/api-headers';
export class ModelsService {
/**
@@ -100,6 +108,89 @@ export class ModelsService {
return model.status.value === ServerModelStatus.LOADING;
}
/**
*
*
* Status Feed
*
*
*/
private static readonly SSE_RECONNECT_MS = 1000;
/**
* Read the /models/sse feed and invoke onEvent for each parsed envelope.
* Reconnects on network drops until the signal aborts. Splits the byte
* stream into SSE records on the blank line boundary; the payload rides in
* the data lines as a JSON envelope with its own model, event and data fields.
*/
static async watchModelEvents(
signal: AbortSignal,
onEvent: (event: ApiModelsSseEvent) => void
): Promise<void> {
const decoder = new TextDecoder();
while (!signal.aborted) {
try {
const response = await fetch(`${base}${API_MODELS.SSE}`, {
headers: getAuthHeaders(),
signal
});
if (response.ok && response.body) {
const reader = response.body.getReader();
let buffer = '';
while (!signal.aborted) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let boundary = buffer.indexOf(SSE_RECORD_SEPARATOR);
while (boundary !== -1) {
const event = ModelsService.parseStatusRecord(buffer.slice(0, boundary));
if (event) onEvent(event);
buffer = buffer.slice(boundary + SSE_RECORD_SEPARATOR.length);
boundary = buffer.indexOf(SSE_RECORD_SEPARATOR);
}
}
}
} catch {
// network drop or abort falls through to the reconnect delay
}
if (signal.aborted) return;
await new Promise((resolve) => setTimeout(resolve, ModelsService.SSE_RECONNECT_MS));
}
}
/**
* Parse one SSE record into its JSON envelope, or null when the record
* carries no data payload or malformed JSON.
*/
private static parseStatusRecord(record: string): ApiModelsSseEvent | null {
const payload = record
.split(SSE_LINE_SEPARATOR)
.filter((line) => line.startsWith(SSE_DATA_PREFIX))
.map((line) => line.slice(SSE_DATA_PREFIX.length).trim())
.join(SSE_LINE_SEPARATOR);
if (payload.length === 0) return null;
try {
return JSON.parse(payload) as ApiModelsSseEvent;
} catch {
return null;
}
}
/**
*
*
@@ -28,15 +28,15 @@ function fileExtension(path: string): string {
}
/**
* **ReadMediaService** - frontend executor for the `read_media` tool
* **ReadMediaService** - browser executor for the `read_media` tool
*
* The tool is synthetic: no such tool exists on the server. It reads the file
* through the built-in `read_file` tool with the `base64` response type, then
* through the server `read_file` tool with the `base64` response type, then
* turns the bytes into a data URI line. The agentic store lifts that line into
* an image or audio attachment on the tool result message, which is what makes
* the model perceive the file instead of reading a wall of base64.
*
* Living in the frontend is what lets it exist only for models that can
* Living in the browser is what lets it exist only for models that can
* actually use the result - the server has no idea which model is selected.
*
* @see buildReadMediaToolDefinition in constants/read-media.ts - tool schema sent to the LLM
@@ -82,7 +82,7 @@ export class ReadMediaService {
}
const raw = await ToolsService.executeToolRaw(
BuiltInTool.READ_FILE,
BuiltInTool.SERVER_READ_FILE,
{ path },
signal,
cwd,
+2 -2
View File
@@ -68,7 +68,7 @@ function formatReply(reply: SandboxReply): ToolExecutionResult {
export class SandboxService {
/**
* Execute a frontend sandbox tool call and return its output.
* Execute a browser sandbox tool call and return its output.
* One disposable iframe per execution, removed on completion,
* timeout or abort. Removing the iframe terminates the worker
* at the browser level, so runaway code cannot outlive it.
@@ -79,7 +79,7 @@ export class SandboxService {
signal?: AbortSignal
): Promise<ToolExecutionResult> {
if (toolName !== SANDBOX_TOOL_NAME) {
return { content: `Unknown frontend tool: ${toolName}`, isError: true };
return { content: `Unknown browser tool: ${toolName}`, isError: true };
}
const code = typeof params.code === 'string' ? params.code : '';
+7 -7
View File
@@ -1,23 +1,23 @@
import { base } from '$app/paths';
import { API_TOOLS, HEADERS } from '$lib/constants';
import { ToolResponseField } from '$lib/enums';
import type { ServerBuiltinToolInfo, ToolExecutionResult } from '$lib/types';
import type { ServerToolInfo, ToolExecutionResult } from '$lib/types';
import { apiFetch } from '$lib/utils';
import { getJsonHeaders } from '$lib/utils/api-headers';
import { parseSseJsonStream, type SseJsonEvent } from '$lib/utils/sse';
export class ToolsService {
/**
* Fetch the list of built-in tools from the server.
* Fetch the list of server tools from the server.
*
* @returns Array of tool definitions in OpenAI-compatible format
*/
static async list(): Promise<ServerBuiltinToolInfo[]> {
return apiFetch<ServerBuiltinToolInfo[]>(API_TOOLS.LIST);
static async list(): Promise<ServerToolInfo[]> {
return apiFetch<ServerToolInfo[]>(API_TOOLS.LIST);
}
/**
* Execute a built-in tool on the server.
* Execute a server tool on the server.
*
* @param cwd - Working directory for the tool call, sent as the
* x-tool-cwd request header. The server resolves relative paths
@@ -48,7 +48,7 @@ export class ToolsService {
}
/**
* Execute a built-in tool and return the raw JSON response. Unlike
* Execute a server tool and return the raw JSON response. Unlike
* executeTool, this preserves structured fields (e.g. file_glob_search's
* `entries` and `base`) that the flattened ToolExecutionResult drops.
*
@@ -77,7 +77,7 @@ export class ToolsService {
}
/**
* Stream a built-in tool's output chunks from the server. The server
* Stream a server tool's output chunks from the server. The server
* `POST /tools` endpoint with `{stream: true}` emits `data: {"chunk": "..."}`
* events followed by a terminal `data: {"done": true}` (optionally with
* `error`). Yields the chunk string for each partial event.
+12 -12
View File
@@ -326,8 +326,8 @@ class AgenticStore {
const maxTurns = Number(settings.agenticMaxTurns) || DEFAULT_AGENTIC_CONFIG.maxTurns;
const hasTools =
mcpStore.hasEnabledServers(perChatOverrides) ||
toolsStore.builtinTools.length > 0 ||
toolsStore.frontendTools.length > 0 ||
toolsStore.serverTools.length > 0 ||
toolsStore.browserTools.length > 0 ||
toolsStore.customTools.length > 0;
return {
@@ -455,9 +455,9 @@ class AgenticStore {
this._continueResolvers.delete(conversationId);
this._steeringMessages.delete(conversationId);
// Ensure built-in tools are fetched before checking if agentic is enabled
if (toolsStore.builtinTools.length === 0 && !toolsStore.loading) {
await toolsStore.fetchBuiltinTools();
// Ensure server tools are fetched before checking if agentic is enabled
if (toolsStore.serverTools.length === 0 && !toolsStore.loading) {
await toolsStore.fetchServerTools();
}
const agenticConfig = this.getConfig(settingsStore.config, perChatOverrides);
@@ -906,8 +906,8 @@ class AgenticStore {
} else {
try {
if (
toolSource === ToolSource.BUILTIN &&
toolName === BuiltInTool.EXEC_SHELL_COMMAND &&
toolSource === ToolSource.SERVER &&
toolName === BuiltInTool.SERVER_EXEC_SHELL_COMMAND &&
createToolResultMessage &&
updateToolResultMessage
) {
@@ -938,7 +938,7 @@ class AgenticStore {
}
}
result = accumulated;
} else if (toolSource === ToolSource.BUILTIN) {
} else if (toolSource === ToolSource.SERVER) {
const args = this.parseToolArguments(toolCall.function.arguments);
const cwd = conversationsStore.activeConversation?.cwd;
const executionResult = await ToolsService.executeTool(toolName, args, signal, cwd);
@@ -946,16 +946,16 @@ class AgenticStore {
result = executionResult.content;
if (executionResult.isError) toolSuccess = false;
} else if (toolSource === ToolSource.FRONTEND) {
} else if (toolSource === ToolSource.BROWSER) {
const args = this.parseToolArguments(toolCall.function.arguments);
let executionResult: ToolExecutionResult;
if (toolName === BuiltInTool.GET_DATETIME) {
if (toolName === BuiltInTool.BROWSER_GET_DATETIME) {
executionResult = executeGetDatetimeTool();
} else if (toolName === BuiltInTool.GET_INFO) {
} else if (toolName === BuiltInTool.SERVER_GET_INFO) {
executionResult = executeBrowserInfoTool();
} else if (toolName === BuiltInTool.READ_MEDIA) {
} else if (toolName === BuiltInTool.BROWSER_READ_MEDIA) {
executionResult = await ReadMediaService.executeTool(
args,
{
@@ -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;
}
};
+8 -65
View File
@@ -14,7 +14,6 @@
import {
CONVERSATION_ID_SEPARATOR,
CWD_CLEARED_TEXT,
HEADERS,
INACTIVE_CONVERSATION,
STREAM_RESUME_RETRY_MS,
SYSTEM_MESSAGE_PLACEHOLDER,
@@ -25,7 +24,6 @@ import {
ErrorDialogType,
MessageRole,
MessageType,
MimeTypeApplication,
ReasoningEffort,
StreamConnectionState
} from '$lib/enums';
@@ -58,7 +56,7 @@ import {
findMessageById,
formatCwdMessage,
generateConversationTitle,
getAuthHeaders,
getConversationModel,
isAbortError,
normalizeModelName,
streamIdentity
@@ -109,9 +107,6 @@ class ChatStore {
private isEditModeActive = $state(false);
private addFilesHandler: ((files: File[]) => void) | null = $state(null);
pendingEditMessageId = $state<string | null>(null);
private messageUpdateCallback:
| ((messageId: string, updates: Partial<DatabaseMessage>) => void)
| null = null;
private _pendingDraftMessage = $state<string>('');
private _pendingDraftFiles = $state<ChatUploadedFile[]>([]);
@@ -225,33 +220,12 @@ class ChatStore {
async probeServerStream(convId: string): Promise<ApiStreamSession | null> {
if (!convId) return null;
let listResp: Response;
try {
// POST the one conv id we are probing
listResp = await fetch(`./v1/streams/lookup`, {
body: JSON.stringify({ conversation_ids: [convId] }),
headers: { ...getAuthHeaders(), [HEADERS.CONTENT_TYPE]: MimeTypeApplication.JSON },
method: 'POST'
});
} catch (e) {
console.warn('probeServerStream fetch failed:', e);
return null;
}
if (!listResp.ok) {
console.warn(`probeServerStream got HTTP ${listResp.status} for conv ${convId}`);
return null;
}
let sessions: ApiStreamSession[];
try {
sessions = (await listResp.json()) as ApiStreamSession[];
sessions = await ChatService.lookupStreamSessions([convId]);
} catch (e) {
console.warn('probeServerStream JSON parse failed:', e);
console.warn(`probeServerStream failed for conv ${convId}:`, e);
return null;
}
@@ -296,18 +270,9 @@ class ChatStore {
let response: Response;
try {
response = await fetch(`./v1/stream?conv_id=${encodeURIComponent(id)}&from=0`, {
headers: getAuthHeaders()
});
response = await ChatService.fetchStreamReplay(id);
} catch (e) {
console.error('attachServerStream replay fetch failed:', e);
unlock();
return;
}
if (!response.ok) {
console.warn(`attachServerStream replay got HTTP ${response.status} for conv ${convId}`);
console.error(`attachServerStream replay failed for conv ${convId}:`, e);
unlock();
return;
@@ -807,21 +772,9 @@ class ChatStore {
let sessions: ApiStreamSession[];
try {
const resp = await fetch('./v1/streams/lookup', {
body: JSON.stringify({ conversation_ids: lookupIds }),
headers: { ...getAuthHeaders(), [HEADERS.CONTENT_TYPE]: MimeTypeApplication.JSON },
method: 'POST'
});
if (!resp.ok) return;
const body = (await resp.json()) as unknown;
if (!Array.isArray(body)) return;
sessions = body as ApiStreamSession[];
sessions = await ChatService.lookupStreamSessions(lookupIds);
} catch (e) {
console.warn('syncRemoteRunningStreams fetch failed:', e);
console.warn('syncRemoteRunningStreams lookup failed:', e);
return;
}
@@ -1335,7 +1288,7 @@ class ChatStore {
let effectiveModel: string | null | undefined = undefined;
if (serverStore.isRouterMode) {
const conversationModel = this.getConversationModel(allMessages);
const conversationModel = getConversationModel(allMessages);
effectiveModel = modelOverride || modelsStore.selectedModelName || conversationModel;
}
@@ -2788,16 +2741,6 @@ class ChatStore {
}
}
getConversationModel(messages: DatabaseMessage[]): string | null {
for (let i = messages.length - 1; i >= 0; i--) {
const message = messages[i];
if (message.role === MessageRole.ASSISTANT && message.model) return message.model;
}
return null;
}
private getApiOptions(): Record<string, unknown> {
const currentConfig = settingsStore.config;
const hasValue = (value: unknown): boolean =>
@@ -49,23 +49,8 @@ function deriveLiveStats(state: ApiProcessingState | null): LiveStats | null {
}
class ContextStatsStore {
// Resolve the model the stats report context for: explicit selection >
// last assistant model > single-model mode (mirrors useChatScreenActiveModel).
activeModelId = $derived.by(() => {
if (!serverStore.isRouterMode) {
return modelsStore.singleModelName;
}
const selectedId = modelsStore.selectedModelId;
if (selectedId) {
const model = modelsStore.models.find((m) => m.id === selectedId);
if (model) return model.model;
}
return chatStore.getConversationModel(conversationsStore.activeMessages as DatabaseMessage[]);
});
// The canonical resolution lives in modelsStore.activeModelId.
activeModelId = $derived(modelsStore.activeModelId);
isActiveModelLoaded = $derived(
this.activeModelId !== null &&
+5 -289
View File
@@ -20,21 +20,9 @@
import { browser } from '$app/environment';
import { goto } from '$app/navigation';
import {
EXPORT_CONV,
NEWLINE,
REASONING_EFFORT_DEFAULT_LOCALSTORAGE_KEY,
ROUTES,
ZIP_MAGIC
} from '$lib/constants';
import {
FileExtensionText,
MessageRole,
MimeTypeApplication,
MimeTypeText,
ReasoningEffort,
SessionRecordType
} from '$lib/enums';
import { REASONING_EFFORT_DEFAULT_LOCALSTORAGE_KEY, ROUTES } from '$lib/constants';
import { MessageRole, ReasoningEffort } from '$lib/enums';
import { ConversationTransferService } from '$lib/services/conversation-transfer.service';
import { DatabaseService } from '$lib/services/database.service';
import { MigrationService } from '$lib/services/migration.service';
import { RouterService } from '$lib/services/router.service';
@@ -43,7 +31,6 @@ import { mcpStore } from '$lib/stores/mcp.svelte';
import { settingsStore } from '$lib/stores/settings.svelte';
import type { McpServerOverride } from '$lib/types/database';
import { filterByLeafNodeId, findLeafNode, generateConversationTitle } from '$lib/utils';
import { strFromU8, strToU8, unzipSync, zipSync } from 'fflate';
import { SvelteSet } from 'svelte/reactivity';
import { toast } from 'svelte-sonner';
@@ -100,14 +87,6 @@ class ConversationsStore {
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 */
private initPromise: Promise<void> | null = null;
@@ -143,23 +122,6 @@ class ConversationsStore {
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;
}
/**
*
*
@@ -504,7 +466,7 @@ class ConversationsStore {
return;
}
this.downloadConversationsArchive(exported);
ConversationTransferService.downloadConversationsArchive(exported);
toast.success(
exported.length === 1
@@ -976,247 +938,6 @@ class ConversationsStore {
*
*/
/**
* Generates a sanitized filename for a conversation export
* @param conversation - The conversation metadata
* @param msgs - Optional array of messages belonging to the conversation
* @returns The generated filename string
*/
generateConversationFilename(
conversation: { id?: string; name?: string },
msgs?: DatabaseMessage[]
): string {
const conversationName = (conversation.name ?? '').trim().toLowerCase();
const sanitizedName = conversationName
.replace(EXPORT_CONV.NON_ALPHANUMERIC_REGEX, EXPORT_CONV.NONALNUM_REPLACEMENT)
.replace(EXPORT_CONV.MULTIPLE_UNDERSCORE_REGEX, '_')
.substring(0, EXPORT_CONV.NAME_SUFFIX_MAX_LENGTH);
// If we have messages, use the timestamp of the newest message
const referenceDate = msgs?.length
? new Date(Math.max(...msgs.map((m) => m.timestamp)))
: new Date();
const iso = referenceDate.toISOString().slice(0, EXPORT_CONV.ISO_TIMESTAMP_SLICE);
const formattedDate = iso
.replace(EXPORT_CONV.ISO_DATE_TIME_SEPARATOR, EXPORT_CONV.ISO_DATE_TIME_SEPARATOR_REPLACEMENT)
.replaceAll(EXPORT_CONV.ISO_TIME_SEPARATOR, EXPORT_CONV.ISO_TIME_SEPARATOR_REPLACEMENT);
const trimmedConvId = conversation.id?.slice(0, EXPORT_CONV.ID_TRIM_LENGTH) ?? '';
return `${formattedDate}_conv_${trimmedConvId}_${sanitizedName}${FileExtensionText.JSONL}`;
}
/**
* Serializes a session (a conversation with its messages) as JSONL.
* The first line is the session header (a `SessionRecordType.SESSION` record
* carrying the conversation properties); each subsequent line is a single message.
* @param data - The exported conversation payload
* @returns The JSONL string (one record per line)
*/
serializeSessionToJsonl(data: ExportedConversation): string {
const { conv, messages } = data;
const sessionLine = JSON.stringify({
harness: EXPORT_CONV.HARNESS,
type: SessionRecordType.SESSION,
...conv
});
const messageLines = messages.map((message: DatabaseMessage) => {
// `toolCalls` is stored as a JSON string; drop it when empty, otherwise parse it.
const { toolCalls, ...rest } = message;
const normalized = toolCalls ? { ...rest, toolCalls: JSON.parse(toolCalls) } : rest;
return JSON.stringify({ message: normalized, type: SessionRecordType.MESSAGE });
});
return [sessionLine, ...messageLines].join(NEWLINE);
}
/**
* Parses the JSONL session format produced by {@link serializeSessionToJsonl}.
* A `SessionRecordType.SESSION` line starts a new session; following
* `SessionRecordType.MESSAGE` lines are appended to it. Supports multiple
* sessions in a single file.
* @param text - The JSONL file contents
* @returns The parsed conversations with their messages
*/
parseSessionsJsonl(text: string): ExportedConversation[] {
const sessions: ExportedConversation[] = [];
let current: ExportedConversation | null = null;
for (const line of text.split(NEWLINE)) {
const trimmed = line.trim();
if (!trimmed) continue;
const record = JSON.parse(trimmed);
if (record.type === SessionRecordType.SESSION) {
// Drop the discriminator and harness marker; the rest is the conversation.
const conv = { ...record };
delete conv.type;
delete conv.harness;
current = { conv: conv as DatabaseConversation, messages: [] };
sessions.push(current);
} else if (record.type === SessionRecordType.MESSAGE) {
if (!current) {
throw new Error('Invalid JSONL: message record before any session record');
}
const message = record.message as DatabaseMessage;
// `toolCalls` is parsed to an array on export; the DB stores it as a string.
if (message.toolCalls !== undefined && typeof message.toolCalls !== 'string') {
message.toolCalls = JSON.stringify(message.toolCalls);
}
current.messages.push(message);
}
// Ignore unknown record types for forward compatibility.
}
return sessions;
}
/**
* Reports whether the text is the JSONL session format, whose first non-empty
* line is a `SessionRecordType.SESSION` record. A legacy JSON export starts
* with an array or an object that has no such discriminator.
* @param text - The file contents
*/
private isSessionsJsonl(text: string): boolean {
const trimmed = text.trimStart();
const lineEnd = trimmed.indexOf(NEWLINE);
const firstLine = lineEnd === -1 ? trimmed : trimmed.slice(0, lineEnd);
try {
return JSON.parse(firstLine).type === SessionRecordType.SESSION;
} catch {
// Not a standalone JSON record, so not the JSONL format.
return false;
}
}
/**
* Parses an import file into conversations, accepting the current JSONL and
* ZIP formats as well as the legacy JSON format. The format comes from the
* contents, so an import works whatever the file is named.
* @param file - The user-selected file
* @returns The parsed conversations with their messages
*/
async parseImportFile(file: File): Promise<ExportedConversation[]> {
const bytes = new Uint8Array(await file.arrayBuffer());
if (ZIP_MAGIC.every((byte, index) => bytes[index] === byte)) {
const entries = unzipSync(bytes);
const sessions: ExportedConversation[] = [];
for (const [entryName, entryBytes] of Object.entries(entries)) {
if (!entryName.toLowerCase().endsWith(FileExtensionText.JSONL)) continue;
sessions.push(...this.parseSessionsJsonl(strFromU8(entryBytes)));
}
return sessions;
}
const text = strFromU8(bytes);
if (this.isSessionsJsonl(text)) {
return this.parseSessionsJsonl(text);
}
// Legacy JSON format: an array of conversations or a single conversation object.
const parsed = JSON.parse(text);
if (Array.isArray(parsed)) {
return parsed;
}
if (parsed && typeof parsed === 'object' && 'conv' in parsed && 'messages' in parsed) {
return [parsed];
}
throw new Error(
'Invalid file format: expected array of conversations or single conversation object'
);
}
/**
* Triggers a browser download of the provided exported conversation data
* @param data - The exported conversation payload (a single conversation with its messages)
* @param filename - Filename; if omitted, a deterministic name is generated
*/
downloadConversationFile(data: ExportedConversation, filename?: string): void {
const { conv: conversation, messages: msgs } = data;
if (!conversation) {
console.error('Invalid data: missing conversation');
return;
}
const downloadFilename = filename ?? this.generateConversationFilename(conversation, msgs);
const jsonl = this.serializeSessionToJsonl(data);
const blob = new Blob([jsonl], { type: MimeTypeText.JSONL });
this.triggerDownload(blob, downloadFilename);
}
/**
* Triggers a browser download of multiple conversations as a `.zip`, one
* `.jsonl` file per conversation.
* @param data - The conversations to export
*/
downloadConversationsArchive(data: ExportedConversation[]): void {
if (data.length === 0) {
console.error('Invalid data: no conversations to export');
return;
}
const usedNames = new SvelteSet<string>();
const files: Record<string, Uint8Array> = {};
for (const session of data) {
const baseName = this.generateConversationFilename(session.conv, session.messages);
// Disambiguate any duplicate filenames within the archive.
let entryName = baseName;
let suffix = 1;
while (usedNames.has(entryName)) {
entryName = baseName.replace(
new RegExp(`${FileExtensionText.JSONL}$`),
`_${suffix++}${FileExtensionText.JSONL}`
);
}
usedNames.add(entryName);
files[entryName] = strToU8(this.serializeSessionToJsonl(session));
}
const archiveName = `${new Date().toISOString().split(EXPORT_CONV.ISO_DATE_TIME_SEPARATOR)[0]}_conversations${FileExtensionText.ZIP}`;
const zipped = zipSync(files);
const blob = new Blob([zipped], { type: MimeTypeApplication.ZIP });
this.triggerDownload(blob, archiveName);
}
/**
* Triggers a browser download of a blob under the given filename.
*/
private triggerDownload(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
/**
* Downloads a single conversation as a JSONL file, serializing the full message tree.
* @param convId - The conversation ID to download
@@ -1231,7 +952,7 @@ class ConversationsStore {
const messages = await DatabaseService.getConversationMessages(convId);
this.downloadConversationFile({ conv: conversation, messages });
ConversationTransferService.downloadConversationFile({ conv: conversation, messages });
}
/**
@@ -1251,8 +972,3 @@ class ConversationsStore {
}
export const conversationsStore = new ConversationsStore();
// Auto-initialize in browser
if (browser) {
conversationsStore.init();
}
+63 -44
View File
@@ -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 { MEDIA_QUERIES } from '$lib/constants';
import { DEFAULT_MOBILE_BREAKPOINT, MEDIA_QUERIES } from '$lib/constants';
import { MediaQuery } from 'svelte/reactivity';
/**
* iOS UA token detection.
@@ -17,53 +29,60 @@ const UA_PATTERNS = {
WEBVIEW_IOS: /CriOS|FxiOS|EdgiOS|GSA/
} as const;
interface DeviceContext {
class DeviceStore {
/** 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. */
isIOSSafari: boolean;
readonly isIOSSafari: boolean = false;
/** Any WKWebView context on iOS: in-app browsers, embedded web views, and the
* third-party iOS browsers (all of which share the WKWebView engine). */
isWKWebView: boolean;
readonly isWKWebView: boolean = false;
/** 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;
}
constructor() {
if (!browser) return;
const ua = navigator.userAgent;
const isTouch = navigator.maxTouchPoints > 0;
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
// token instead. WKWebView typically omits 'Safari/' entirely.
const hasSafariToken = UA_PATTERNS.SAFARI.test(ua) && !UA_PATTERNS.WEBVIEW_IOS.test(ua);
this.isIOSSafari = this.isIOSDevice && hasSafariToken;
this.isWKWebView = this.isIOSDevice && !hasSafariToken;
// navigator.standalone is the legacy iOS-only flag (deprecated but still
// present); display-mode: standalone is the modern standard (Safari 16.4+).
this.isStandalone =
window.matchMedia(MEDIA_QUERIES.DISPLAY_MODE_STANDALONE).matches ||
(navigator as Navigator & { standalone?: boolean }).standalone === true;
this.systemTheme.isDark = window.matchMedia(MEDIA_QUERIES.PREFERS_DARK).matches;
// 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);
standaloneMql.addEventListener('change', (e) => {
this.isStandalone = e.matches;
});
const darkMql = window.matchMedia(MEDIA_QUERIES.PREFERS_DARK);
darkMql.addEventListener('change', (e) => {
this.systemTheme.isDark = e.matches;
});
}
}
const SERVER_DEFAULT: DeviceContext = {
isIOSDevice: false,
isIOSSafari: false,
isStandalone: false,
isWKWebView: false
};
function detect(): DeviceContext {
if (!browser) return SERVER_DEFAULT;
const ua = navigator.userAgent;
const isTouch = navigator.maxTouchPoints > 0;
const 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
// token instead. WKWebView typically omits 'Safari/' entirely.
const hasSafariToken = UA_PATTERNS.SAFARI.test(ua) && !UA_PATTERNS.WEBVIEW_IOS.test(ua);
const isIOSSafari = isIOSDevice && hasSafariToken;
const isWKWebView = isIOSDevice && !hasSafariToken;
// navigator.standalone is the legacy iOS-only flag (deprecated but still
// present); display-mode: standalone is the modern standard (Safari 16.4+).
const isStandalone =
window.matchMedia(MEDIA_QUERIES.DISPLAY_MODE_STANDALONE).matches ||
(navigator as Navigator & { standalone?: boolean }).standalone === true;
return { isIOSDevice, isIOSSafari, isStandalone, isWKWebView };
}
export const device = $state<DeviceContext>(detect());
if (browser) {
// 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) => {
device.isStandalone = e.matches;
});
}
export const deviceStore = new DeviceStore();
+1 -21
View File
@@ -53,26 +53,6 @@ export { permissionsStore } from './permissions.svelte';
export { toolsStore } from './tools.svelte';
// ENVIRONMENT / META
export { buildInfoStore } from './build-info.svelte';
export { versionStore } from './version.svelte';
export { device } 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';
export { deviceStore } from './device.svelte';
+40
View File
@@ -0,0 +1,40 @@
/**
* Explicit store initialization, run once and shared by every caller.
*
* Order matters: migrations run first because they rename and rewrite
* localStorage keys, so every store that reads localStorage initializes
* only after they complete. Constructors and module-level side effects
* stay empty so import order can no longer change startup behavior.
*
* The returned promise resolves once the persisted state is in memory, which
* route loads await before reading settings: they run ahead of the root layout
* script. The conversation list loads in the background, awaited by the chat
* page that renders it.
*/
// direct imports, not via the barrel, to avoid circular deps
import { conversationsStore } from './conversations.svelte';
import { permissionsStore } from './permissions.svelte';
import { settingsStore } from './settings.svelte';
import { toolsStore } from './tools.svelte';
import { versionStore } from './version.svelte';
import { browser } from '$app/environment';
import { MigrationService } from '$lib/services/migration.service';
let startup: Promise<void> | null = null;
export function initStores(): Promise<void> {
if (!browser) return Promise.resolve();
startup ??= (async () => {
await MigrationService.runAllMigrations();
settingsStore.initialize();
permissionsStore.initialize();
toolsStore.initialize();
void versionStore.initialize();
void conversationsStore.init();
})();
return startup;
}
+31 -76
View File
@@ -1,12 +1,4 @@
import { base } from '$app/paths';
import {
API_MODELS,
FAVORITE_MODELS_LOCALSTORAGE_KEY,
MODEL_PROPS_CACHE,
SSE_DATA_PREFIX,
SSE_LINE_SEPARATOR,
SSE_RECORD_SEPARATOR
} from '$lib/constants';
import { FAVORITE_MODELS_LOCALSTORAGE_KEY, MODEL_PROPS_CACHE } from '$lib/constants';
import {
FileTypeCategory,
ModelModality,
@@ -20,12 +12,12 @@ import { conversationsStore } from '$lib/stores/conversations.svelte';
import { serverStore } from '$lib/stores/server.svelte';
// deep imports, not the '$lib/utils' barrel: it re-exports modules that reach back
// into the stores, and going through it here would read a half-built module
import { getAuthHeaders } from '$lib/utils/api-headers';
import { TTLCache } from '$lib/utils/cache-ttl';
import {
detectThinkingSupport,
detectThinkingSupportWithReason
} from '$lib/utils/chat-template-thinking-detector';
import { getConversationModel } from '$lib/utils/conversation-utils';
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
import { toast } from 'svelte-sonner';
@@ -141,6 +133,33 @@ class ModelsStore {
return props.model_path.split(/(\\|\/)/).pop() || null;
}
/**
* Model the active conversation view resolves to. Router mode: the user's
* selection first, then the conversation's own model. Otherwise the single
* served model, from the models list or the server props as a fallback.
*/
get activeModelId(): string | null {
if (!serverStore.isRouterMode) {
return this.models.length > 0 ? this.models[0].model : this.singleModelName;
}
if (this.selectedModelId) {
const selected = this.models.find((m) => m.id === this.selectedModelId);
if (selected) return selected.model;
}
const conversationModel = getConversationModel(conversationsStore.activeMessages);
if (conversationModel) {
const model = this.models.find((m) => m.model === conversationModel);
if (model) return model.model;
}
return null;
}
get selectedModelContextSize(): number | null {
if (!this.selectedModelName) return null;
@@ -717,8 +736,6 @@ class ModelsStore {
*/
// reconnect delay after the feed drops or the server is not ready yet
private static readonly SSE_RECONNECT_MS = 1000;
/**
* Open the /models/sse feed and keep it live with auto reconnect.
* Idempotent and router mode only. The feed drives status and progress,
@@ -752,72 +769,10 @@ class ModelsStore {
}
/**
* Read the feed and reconnect until unsubscribed. Splits the byte stream
* into SSE records on the blank line boundary.
* Read the feed and reconnect until unsubscribed.
*/
private async runStatusReader(signal: AbortSignal): Promise<void> {
const decoder = new TextDecoder();
while (!signal.aborted) {
try {
const response = await fetch(`${base}${API_MODELS.SSE}`, {
headers: getAuthHeaders(),
signal
});
if (response.ok && response.body) {
const reader = response.body.getReader();
let buffer = '';
while (!signal.aborted) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let boundary = buffer.indexOf(SSE_RECORD_SEPARATOR);
while (boundary !== -1) {
this.handleStatusRecord(buffer.slice(0, boundary));
buffer = buffer.slice(boundary + SSE_RECORD_SEPARATOR.length);
boundary = buffer.indexOf(SSE_RECORD_SEPARATOR);
}
}
}
} catch {
// network drop or abort falls through to the reconnect delay
}
if (signal.aborted) return;
await new Promise((resolve) => setTimeout(resolve, ModelsStore.SSE_RECONNECT_MS));
}
}
/**
* Parse one SSE record. The payload rides in the data lines as a JSON
* envelope that carries its own model, event and data fields.
*/
private handleStatusRecord(record: string): void {
const payload = record
.split(SSE_LINE_SEPARATOR)
.filter((line) => line.startsWith(SSE_DATA_PREFIX))
.map((line) => line.slice(SSE_DATA_PREFIX.length).trim())
.join(SSE_LINE_SEPARATOR);
if (payload.length === 0) return;
let envelope: ApiModelsSseEvent;
try {
envelope = JSON.parse(payload);
} catch {
return;
}
this.applyStatusEvent(envelope);
await ModelsService.watchModelEvents(signal, (event) => this.applyStatusEvent(event));
}
/**
@@ -5,7 +5,11 @@ import { SvelteSet } from 'svelte/reactivity';
class PermissionsStore {
private _tools = $state(new SvelteSet<string>());
constructor() {
/**
* Load persisted permissions. Called by initStores() after migrations
* have run.
*/
initialize(): void {
// browser-only init: skip on SSR to avoid localStorage side effects
if (!browser) return;
@@ -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);
}
};
}
+19 -15
View File
@@ -40,9 +40,9 @@ import {
} from '$lib/constants';
import { ColorMode } from '$lib/enums';
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
import { serverStore } from '$lib/stores/server.svelte';
import { isMobile } from '$lib/stores/viewport.svelte';
import type { SettingsExportType } from '$lib/types';
import {
configToParameterRecord,
@@ -85,12 +85,6 @@ class SettingsStore {
return ParameterSyncService.extractServerDefaults(serverStore.defaultParams);
}
constructor() {
if (browser) {
this.initialize();
}
}
/**
*
*
@@ -100,9 +94,12 @@ class SettingsStore {
*/
/**
* Initialize the settings store by loading from localStorage
* Initialize the settings store by loading from localStorage.
* Called by initStores() after migrations have run.
*/
initialize() {
if (!browser) return;
try {
this.loadConfig();
this.migrateLegacyTheme();
@@ -138,7 +135,7 @@ class SettingsStore {
// Default sendOnEnter to false on mobile when the user has no saved preference
if (!(SETTINGS_KEYS.SEND_ON_ENTER in savedVal)) {
if (isMobile.current) {
if (deviceStore.isMobile) {
this.config[SETTINGS_KEYS.SEND_ON_ENTER] = false;
}
}
@@ -361,17 +358,24 @@ class SettingsStore {
// UI settings are the admin's defaults for new users: applied once on
// the first visit, never on later loads, so the user's config can
// diverge. "Reset to Default" is the explicit way back to the baseline.
// A first visit config carries factory values only, so a key that
// already diverges here was set by the user before the baseline could
// be reached, through the API key splash, and stays theirs.
if (uiSettings && this.isFirstVisit) {
this.isFirstVisit = false;
for (const [key, value] of Object.entries(uiSettings)) {
if (!this.userOverrides.has(key) && value !== undefined) {
setConfigValue(this.config, key, value);
if (value === undefined || this.userOverrides.has(key)) continue;
// theme lives in mode-watcher, not just in config -> propagate
if (key === SETTINGS_KEYS.THEME) {
setMode(value as ColorMode);
}
if (getConfigValue(this.config, key) !== getConfigValue(SETTING_CONFIG_DEFAULT, key)) {
continue;
}
setConfigValue(this.config, key, value);
// theme lives in mode-watcher, not just in config -> propagate
if (key === SETTINGS_KEYS.THEME) {
setMode(value as ColorMode);
}
}
}
-14
View File
@@ -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;
});
}
+42 -38
View File
@@ -28,17 +28,21 @@ import { SvelteMap, SvelteSet } from 'svelte/reactivity';
/** Stable selection identity for a tool, shared by the disabled set and the permission store */
class ToolsStore {
private _builtinTools = $state<OpenAIToolDefinition[]>([]);
private _serverTools = $state<OpenAIToolDefinition[]>([]);
private _loading = $state(false);
private _error = $state<string | null>(null);
private _disabledTools = $state(new SvelteSet<string>());
// builtin tools that resolve their paths against the working directory,
// server tools that resolve their paths against the working directory,
// as declared by the server in its `/tools` listing
private _cwdAwareTools = $state(new SvelteSet<string>());
private _toolsEndpointUnreachable = $state(false);
private _serverHome = $state<string | null | undefined>(undefined);
constructor() {
/**
* Load persisted disabled tools and fetch the builtin tool list.
* Called by initStores() after migrations have run.
*/
initialize(): void {
// browser-only init: skip on SSR to avoid localStorage/fetch side effects
if (!browser) return;
@@ -58,7 +62,7 @@ class ToolsStore {
console.error('[ToolsStore] Failed to load disabled tools from localStorage:', err);
}
this.fetchBuiltinTools();
this.fetchServerTools();
}
private persistDisabledTools(): void {
@@ -78,10 +82,10 @@ class ToolsStore {
return serverId ? `mcp-${serverId}:${name}` : `mcp:${name}`;
case ToolSource.CUSTOM:
return `custom:${name}`;
case ToolSource.FRONTEND:
return `frontend:${name}`;
case ToolSource.BROWSER:
return `browser:${name}`;
default:
return `builtin:${name}`;
return `server:${name}`;
}
}
@@ -164,8 +168,8 @@ class ToolsStore {
};
}
get builtinTools(): OpenAIToolDefinition[] {
return this._builtinTools;
get serverTools(): OpenAIToolDefinition[] {
return this._serverTools;
}
get serverHome(): string | null {
@@ -176,7 +180,7 @@ class ToolsStore {
return this.mcpEntries().map((e) => e.definition);
}
get frontendTools(): OpenAIToolDefinition[] {
get browserTools(): OpenAIToolDefinition[] {
const tools: OpenAIToolDefinition[] = [buildGetDatetimeToolDefinition()];
if (settingsStore.config.jsSandboxEnabled) {
@@ -188,25 +192,25 @@ class ToolsStore {
if (readMedia) tools.push(readMedia);
// provide browser's get_info tool if server doesn't provide one
if (!this.hasBuiltinTool(BuiltInTool.GET_INFO)) {
if (!this.hasServerTool(BuiltInTool.SERVER_GET_INFO)) {
tools.push(buildBrowserInfoToolDefinition());
}
return tools;
}
private hasBuiltinTool(name: BuiltInTool): boolean {
return this._builtinTools.some((def) => def.function.name === name);
private hasServerTool(name: BuiltInTool): boolean {
return this._serverTools.some((def) => def.function.name === name);
}
/**
* `read_media` runs in the frontend on top of the server's `read_file`, so it
* `read_media` runs in the browser on top of the server's `read_file`, so it
* exists only when that tool is served and the active model can perceive the
* bytes. The server cannot make this call - it does not know which model the
* conversation uses.
*/
private readMediaTool(): OpenAIToolDefinition | null {
if (!this.hasBuiltinTool(BuiltInTool.READ_FILE)) return null;
if (!this.hasServerTool(BuiltInTool.SERVER_READ_FILE)) return null;
const model = modelsStore.selectedModelName ?? modelsStore.models[0]?.model ?? '';
@@ -304,23 +308,23 @@ class ToolsStore {
entries.push(entry);
};
for (const def of this._builtinTools) {
for (const def of this._serverTools) {
const name = def.function.name;
push({
definition: def,
key: this.toolKey(ToolSource.BUILTIN, name),
source: ToolSource.BUILTIN
key: this.toolKey(ToolSource.SERVER, name),
source: ToolSource.SERVER
});
}
for (const def of this.frontendTools) {
for (const def of this.browserTools) {
const name = def.function.name;
push({
definition: def,
key: this.toolKey(ToolSource.FRONTEND, name),
source: ToolSource.FRONTEND
key: this.toolKey(ToolSource.BROWSER, name),
source: ToolSource.BROWSER
});
}
@@ -384,17 +388,17 @@ class ToolsStore {
return entry.serverName ?? '';
case ToolSource.CUSTOM:
return TOOL_GROUP_LABELS[ToolSource.CUSTOM];
case ToolSource.FRONTEND:
return TOOL_GROUP_LABELS[ToolSource.FRONTEND];
case ToolSource.BROWSER:
return TOOL_GROUP_LABELS[ToolSource.BROWSER];
default:
return TOOL_GROUP_LABELS[ToolSource.BUILTIN];
return TOOL_GROUP_LABELS[ToolSource.SERVER];
}
}
/**
* Enabled tool definitions for sending to the LLM.
* MCP tool schemas are normalized here so the wire payload is consistent
* across all four sources (built-in, frontend/sandbox, MCP, custom JSON).
* across all four sources (server, browser/sandbox, MCP, custom JSON).
* The API identifies tools by name, so a name is sent at most once.
*/
getEnabledToolsForLLM(): OpenAIToolDefinition[] {
@@ -417,8 +421,8 @@ class ToolsStore {
result.push(def);
};
for (const def of this._builtinTools) take(def);
for (const def of this.frontendTools) take(def);
for (const def of this._serverTools) take(def);
for (const def of this.browserTools) take(def);
// mcpEntries() over mcpStore directly so wire shape stays normalized and aligned with the tools UI.
for (const entry of this.mcpEntries()) take(entry.definition);
for (const def of this.customTools) take(def);
@@ -542,11 +546,11 @@ class ToolsStore {
if (entry.serverName) return mcpStore.getServerDisplayName(entry.serverName);
if (entry.source === ToolSource.BUILTIN) return TOOL_SERVER_LABELS[ToolSource.BUILTIN];
if (entry.source === ToolSource.SERVER) return TOOL_SERVER_LABELS[ToolSource.SERVER];
if (entry.source === ToolSource.CUSTOM) return TOOL_SERVER_LABELS[ToolSource.CUSTOM];
if (entry.source === ToolSource.FRONTEND) return TOOL_SERVER_LABELS[ToolSource.FRONTEND];
if (entry.source === ToolSource.BROWSER) return TOOL_SERVER_LABELS[ToolSource.BROWSER];
return '';
}
@@ -556,27 +560,27 @@ class ToolsStore {
return this.findEntryByName(toolName)?.key ?? null;
}
/** Check if there are any enabled tools available (builtin, MCP, or custom) */
/** Check if there are any enabled tools available (server, MCP, or custom) */
get hasEnabledTools(): boolean {
return this.getEnabledToolsForLLM().length > 0;
}
/**
* Check if a working directory is worth setting: at least one builtin tool
* Check if a working directory is worth setting: at least one server tool
* that reads it is both served and left enabled by the user.
*/
get hasEnabledCwdTools(): boolean {
return this._builtinTools.some((def) => {
return this._serverTools.some((def) => {
const name = def.function.name;
return (
this._cwdAwareTools.has(name) &&
!this._disabledTools.has(this.toolKey(ToolSource.BUILTIN, name))
!this._disabledTools.has(this.toolKey(ToolSource.SERVER, name))
);
});
}
async fetchBuiltinTools(): Promise<void> {
async fetchServerTools(): Promise<void> {
if (this._loading) return;
this._loading = true;
@@ -586,7 +590,7 @@ class ToolsStore {
try {
const toolInfos = await ToolsService.list();
this._builtinTools = toolInfos.map((info) => info.definition);
this._serverTools = toolInfos.map((info) => info.definition);
this._cwdAwareTools = new SvelteSet(
toolInfos.filter((info) => info.uses_cwd).map((info) => info.tool)
);
@@ -599,9 +603,9 @@ class ToolsStore {
// TODO: check status code instead of relying on message
if (errorMessage.includes('this feature is disabled')) {
this._toolsEndpointUnreachable = true;
console.info('[ToolsStore] Built-in tools are disabled on the server');
console.info('[ToolsStore] Server tools are disabled on the server');
} else {
console.error('[ToolsStore] Failed to fetch built-in tools:', err);
console.error('[ToolsStore] Failed to fetch server tools:', err);
}
} finally {
this._loading = false;
@@ -618,7 +622,7 @@ class ToolsStore {
if (this._serverHome !== undefined) return this._serverHome;
try {
const res = await ToolsService.executeToolRaw(BuiltInTool.FILE_GLOB_SEARCH, {
const res = await ToolsService.executeToolRaw(BuiltInTool.SERVER_FILE_GLOB_SEARCH, {
limit: 1,
max_depth: 1,
path: HOME_TILDE,
+46 -25
View File
@@ -1,44 +1,65 @@
/**
* versionStore - Frontend build version
* versionStore - Build version information
*
* Reads from SvelteKit's `_app/version.json` generated by the @vite-pwa/sveltekit
* plugin. The version string changes on every build, so comparing it against
* localStorage reliably detects server upgrades.
* - `build`: llama.cpp build number from `build.json`, embedded at llama.cpp
* build time (LLAMA_BUILD_NUMBER). Shown in the UI when `showBuildVersion`
* 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 { base } from '$app/paths';
let version = $state<string>('');
class VersionStore {
build = $state<string>('');
frontend = $state<string>('');
async function loadVersion() {
if (!browser) return;
/**
* Fetch the version files. Called by initStores(); order-independent,
* so it runs in the background.
*/
initialize(): void {
if (!browser) return;
if (import.meta.env.DEV) {
version = 'dev';
if (import.meta.env.DEV) {
this.build = 'dev';
this.frontend = 'dev';
return;
return;
}
void this.load();
}
try {
const res = await fetch(`${base}/_app/version.json`, { cache: 'no-store' });
private async load(): Promise<void> {
try {
const res = await fetch(`${base}/build.json`, { cache: 'no-store' });
if (res.ok) {
const data = await res.json();
if (res.ok) {
const data = await res.json();
version = data.version ?? '';
this.build = data.version ?? '';
}
} catch {
// build.json missing or unreachable - leave as empty string
}
try {
const res = await fetch(`${base}/_app/version.json`, { cache: 'no-store' });
if (res.ok) {
const data = await res.json();
this.frontend = data.version ?? '';
}
} catch {
// version.json missing or unreachable - leave as empty string
}
} catch {
// _app/version.json missing or unreachable - leave as empty string
}
}
loadVersion();
export const versionStore = {
get value(): string {
return version;
}
};
export const versionStore = new VersionStore();
@@ -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`);
+2 -2
View File
@@ -147,7 +147,7 @@ export type {
ServerStatus,
ToolCallParams,
ToolExecutionResult,
ServerBuiltinToolInfo,
ServerToolInfo,
Tool,
Prompt,
GetPromptResult,
@@ -208,7 +208,7 @@ export type {
export type { DesktopIconStripItem } from './navigation';
// Tools types
export type { ToolEntry, ToolGroup, BuiltinToolUiEntry } from './tools';
export type { ToolEntry, ToolGroup, ToolUiEntry } from './tools';
// Reasoning
export type { ReasoningEffortLevel } from './reasoning';
+2 -2
View File
@@ -285,10 +285,10 @@ export interface ToolExecutionResult {
isError: boolean;
}
export interface ServerBuiltinToolInfo {
export interface ServerToolInfo {
display_name: string;
tool: string;
type: ToolSource.BUILTIN;
type: ToolSource.SERVER;
permissions: {
write: boolean;
};
+4 -4
View File
@@ -3,12 +3,12 @@ import type { ToolSource } from '$lib/enums';
import type { Component } from 'svelte';
/**
* UI metadata for a built-in or frontend tool, keyed by its `BuiltInTool` id.
* UI metadata for a server or browser tool, keyed by its `BuiltInTool` id.
*/
export interface BuiltinToolUiEntry {
export interface ToolUiEntry {
icon: Component;
label: string;
source: ToolSource.BUILTIN | ToolSource.FRONTEND;
source: ToolSource.SERVER | ToolSource.BROWSER;
}
export interface ToolEntry {
@@ -17,7 +17,7 @@ export interface ToolEntry {
serverName?: string;
/** For MCP tools, the server ID (used for permission keys) */
serverId?: string;
/** Stable selection identity: builtin:name, mcp-<serverId>:name, mcp:name, custom:name */
/** Stable selection identity: server:name, mcp-<serverId>:name, mcp:name, custom:name */
key: string;
definition: OpenAIToolDefinition;
}
-13
View File
@@ -1,13 +0,0 @@
import { BUILTIN_TOOL_UI } from '$lib/constants';
import type { BuiltinToolUiEntry } from '$lib/types';
/**
* Resolve the UI metadata (label + icon) for a built-in tool by its name.
* Falls back to null for unknown or non-built-in tools so callers can render
* a generic chrome instead.
*/
export function getBuiltinToolUi(toolName: string | undefined): BuiltinToolUiEntry | null {
if (!toolName) return null;
return (BUILTIN_TOOL_UI as Record<string, BuiltinToolUiEntry>)[toolName] ?? null;
}
@@ -1,8 +1,23 @@
/**
* Utility functions for conversation data manipulation
*/
import { MessageRole } from '$lib/enums';
import type { DatabaseMessage } from '$lib/types';
/**
* Model that generated the conversation's latest assistant message, or null
* when no assistant message carries one.
*/
export function getConversationModel(messages: readonly DatabaseMessage[]): string | null {
for (let i = messages.length - 1; i >= 0; i--) {
const message = messages[i];
if (message.role === MessageRole.ASSISTANT && message.model) return message.model;
}
return null;
}
/**
* Creates a map of conversation IDs to their message counts from exported conversation data
* @param exportedData - Array of exported conversations with their messages
+1 -1
View File
@@ -1,5 +1,5 @@
/**
* Frontend executor for the `get_datetime` tool. It runs in the browser, so it
* Browser executor for the `get_datetime` tool. It runs in the browser, so it
* reports the user's own clock and time zone instead of the server's UTC time -
* a chat about "tomorrow" means the user's tomorrow, not the host's.
*
+1 -1
View File
@@ -42,7 +42,7 @@ export async function runGlobSearch(
}
const res = await ToolsService.executeToolRaw(
BuiltInTool.FILE_GLOB_SEARCH,
BuiltInTool.SERVER_FILE_GLOB_SEARCH,
{ include: args.include, limit, max_depth: args.maxDepth, path: args.path, type },
signal
);
+5 -4
View File
@@ -54,6 +54,7 @@ export { modelLoadFraction, modelLoadProgressText } from './progress';
export {
createMessageCountMap,
getMessageCount,
getConversationModel,
buildConversationTree,
type ConversationTreeItem
} from './conversation-utils';
@@ -127,7 +128,7 @@ export { sanitizeKeyValuePairKey, sanitizeKeyValuePairValue } from './sanitize';
// Image error fallback utilities
export { getImageErrorFallbackHtml } from './image-error-fallback';
// SSE-with-JSON stream iterator (used by built-in tool streaming, decoupled
// SSE-with-JSON stream iterator (used by server tool streaming, decoupled
// from chat.service.ts which embeds its own SSE parser for resume support)
export { parseSseJsonStream } from './sse';
@@ -310,7 +311,7 @@ export {
withAbortSignal
} from './abort';
// Tool-call meta utilities. Parsers for each built-in tool live next to
// Tool-call meta utilities. Parsers for each server tool live next to
// their renderer family under
// `src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/`.
// This module only carries the helpers that genuinely cross tool
@@ -321,7 +322,7 @@ export { tryParseToolResultObject } from './tool-call-meta';
// Per-tool UI metadata (label + icon) used by the tool-call chrome.
// Re-exported through $lib/utils so renderer components can read the
// label without depending on $lib/constants directly.
export { getBuiltinToolUi } from './built-in-tools';
export { getToolUi } from './tool-ui';
// Chat command picker
@@ -331,7 +332,7 @@ export { getChatCommands } from './chat-commands';
// SANDBOX_TOOL_DEFINITION is deprecated; kept for backward compatibility.
export { buildSandboxToolDefinition, SANDBOX_TOOL_DEFINITION } from './sandbox-tool';
// Frontend `get_datetime` executor (the browser clock, not the server's)
// Browser `get_datetime` executor (the browser clock, not the server's)
export { executeGetDatetimeTool } from './get-datetime';
// Browser fallback for the server's get_info tool
+3 -3
View File
@@ -50,10 +50,10 @@ const FAVICON_PATH = '/favicon.ico';
// (and that callers read off `SearchResult`), so `FieldKey.TITLE` is a
// drop-in for the literal `'title'`.
enum FieldKey {
TITLE = 'title',
URL = 'url',
AUTHOR = 'author',
PUBLISHED = 'published',
AUTHOR = 'author'
TITLE = 'title',
URL = 'url'
}
const FIELD_PREFIXES: ReadonlyArray<{ key: FieldKey; prefix: string }> = [
{ key: FieldKey.TITLE, prefix: 'Title:' },
+13
View File
@@ -0,0 +1,13 @@
import { TOOL_UI } from '$lib/constants';
import type { ToolUiEntry } from '$lib/types';
/**
* Resolve the UI metadata (label + icon) for a server or browser tool by its
* name. Falls back to null for unknown tools so callers can render a generic
* chrome instead.
*/
export function getToolUi(toolName: string | undefined): ToolUiEntry | null {
if (!toolName) return null;
return (TOOL_UI as Record<string, ToolUiEntry>)[toolName] ?? null;
}
+1 -1
View File
@@ -77,7 +77,7 @@
onMount(async () => {
if (!conversationsStore.isInitialized) {
await conversationsStore.initialize();
await conversationsStore.init();
}
conversationsStore.clearActiveConversation();
+4
View File
@@ -1,6 +1,10 @@
import type { PageLoad } from './$types';
import { initStores } from '$lib/stores/init';
import { validateApiKey } from '$lib/utils';
export const load: PageLoad = async ({ fetch }) => {
// loads run before the root layout script, so the stored API key reaches
// the probe only once the settings store has read localStorage
await initStores();
await validateApiKey(fetch);
};
@@ -1,6 +1,10 @@
import type { PageLoad } from './$types';
import { initStores } from '$lib/stores/init';
import { validateApiKey } from '$lib/utils';
export const load: PageLoad = async ({ fetch }) => {
// loads run before the root layout script, so the stored API key reaches
// the probe only once the settings store has read localStorage
await initStores();
await validateApiKey(fetch);
};
+12 -8
View File
@@ -19,16 +19,16 @@
import { usePwa } from '$lib/hooks/use-pwa.svelte';
import { RouterService } from '$lib/services/router.service';
import {
buildInfoStore,
chatStore,
conversationsStore,
isMobile,
deviceStore,
mcpStore,
modelsStore,
serverStore,
settingsStore,
theme
versionStore
} from '$lib/stores';
import { initStores } from '$lib/stores/init';
import { ModeWatcher } from 'mode-watcher';
import { untrack } from 'svelte';
import { onMount } from 'svelte';
@@ -36,6 +36,10 @@
import { pwaAssetsHead } from 'virtual:pwa-assets/head';
let { children } = $props();
// migrations and store startup, ordered explicitly instead of import side effects
void initStores();
let innerHeight = $state<number | undefined>();
let innerWidth = $state(browser ? window.innerWidth : 0);
@@ -55,7 +59,7 @@
const { needRefresh, updateServiceWorker } = pwa;
function updateFavicon() {
const dark = theme.isSystemDark;
const dark = deviceStore.systemTheme.isDark;
let icoLink = document.querySelector(FAVICON_SELECTORS.ICO_48X48) as HTMLLinkElement | null;
@@ -153,7 +157,7 @@
}
$effect(() => {
void theme.isSystemDark;
void deviceStore.systemTheme.isDark;
updateFavicon();
});
@@ -274,7 +278,7 @@
<div class="flex flex-col md:flex-row">
<SidebarNavigation
onSearchClick={() => {
if (isMobile.current) {
if (deviceStore.isMobile) {
goto(ROUTES.SEARCH);
} else if (chatSidebar?.activateSearchMode) {
chatSidebar.activateSearchMode();
@@ -294,8 +298,8 @@
<!-- PWA update prompt + version -->
<div class="fixed right-4 bottom-4 z-9999 flex flex-col items-end gap-1">
{#if showBuildVersion && buildInfoStore.value}
<span class="text-[10px] tabular-nums text-muted-foreground">{buildInfoStore.value}</span>
{#if showBuildVersion && versionStore.build}
<span class="text-[10px] tabular-nums text-muted-foreground">{versionStore.build}</span>
{/if}
<PwaRefreshAlert
+2 -2
View File
@@ -5,7 +5,7 @@
import { SearchInput, SidebarNavigationSearchResults } from '$lib/components/app';
import { ROUTES } from '$lib/constants';
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 searchInputRef = $state<HTMLInputElement | null>(null);
@@ -23,7 +23,7 @@
// Search page is intended for mobile; on desktop the sidebar already exposes
// in-place search, so bounce back to a chat.
$effect(() => {
if (browser && !isMobile.current) {
if (browser && !deviceStore.isMobile) {
goto(ROUTES.NEW_CHAT, { replaceState: true });
}
});