mirror of
https://github.com/LostRuins/koboldcpp.git
synced 2026-09-19 17:25:07 +02:00
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:
@@ -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
|
||||
|
||||
+1
-1
@@ -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
-2
@@ -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} />
|
||||
|
||||
+5
-26
@@ -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}
|
||||
|
||||
+3
-4
@@ -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
-1
@@ -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;
|
||||
|
||||
+6
-1
@@ -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();
|
||||
|
||||
+2
-2
@@ -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,
|
||||
|
||||
+2
-2
@@ -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 });
|
||||
}
|
||||
|
||||
+3
-3
@@ -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 });
|
||||
}
|
||||
|
||||
+3
-3
@@ -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();
|
||||
}
|
||||
|
||||
+10
-10
@@ -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} />
|
||||
|
||||
+2
-2
@@ -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) : []
|
||||
|
||||
+2
-2
@@ -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}>
|
||||
|
||||
+3
-3
@@ -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)
|
||||
);
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
|
||||
+2
-2
@@ -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.
|
||||
*/
|
||||
|
||||
+11
-11
@@ -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();
|
||||
}
|
||||
}}
|
||||
|
||||
+4
-4
@@ -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?.();
|
||||
|
||||
+4
-3
@@ -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';
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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,6 +2,6 @@
|
||||
* Splash screen orientation for iOS apple-touch-startup-image
|
||||
*/
|
||||
export enum SplashOrientation {
|
||||
PORTRAIT = 'portrait',
|
||||
LANDSCAPE = 'landscape'
|
||||
LANDSCAPE = 'landscape',
|
||||
PORTRAIT = 'portrait'
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 : '';
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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`);
|
||||
@@ -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';
|
||||
|
||||
Vendored
+2
-2
@@ -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;
|
||||
};
|
||||
|
||||
Vendored
+4
-4
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,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.
|
||||
*
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:' },
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -77,7 +77,7 @@
|
||||
|
||||
onMount(async () => {
|
||||
if (!conversationsStore.isInitialized) {
|
||||
await conversationsStore.initialize();
|
||||
await conversationsStore.init();
|
||||
}
|
||||
|
||||
conversationsStore.clearActiveConversation();
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user