refactor: Constant objects instead of multiple single value constants

This commit is contained in:
Aleksander Grygier
2026-08-11 17:59:15 +02:00
parent d4400d1056
commit dde7f6bc82
55 changed files with 551 additions and 665 deletions
@@ -1,10 +1,10 @@
import { NEWLINE, TAB } from '../src/lib/constants/special-characters.constants';
import {
APPLE_DEVICES,
BUILD_CONFIG,
REGEX_PATTERNS,
SPLASH_LINK
} from '../src/lib/constants/pwa.constants';
import { NEWLINE, TAB } from '../src/lib/constants/special-characters.constants';
import { SplashOrientation } from '../src/lib/enums/splash.enums';
import type { SplashDimensions } from '../src/lib/types';
import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
@@ -1,5 +1,5 @@
<script lang="ts">
import { TRIM_LEADING_PADDING_REGEX, TRIM_TRAILING_PADDING_REGEX } from '$lib/constants';
import { CODE_BLOCK } from '$lib/constants';
import { ColorMode } from '$lib/enums';
import { isMobile } from '$lib/stores/viewport.svelte';
import type { ContentToken, SourceHistoryEntry } from '$lib/utils';
@@ -105,8 +105,8 @@
const prefix = open[0];
const language = open[1].trim().split(/\s+/)[0] ?? '';
const content = segment.slice(prefix.length, -3);
const leading = content.match(TRIM_LEADING_PADDING_REGEX)?.[0] ?? '';
const trailing = content.match(TRIM_TRAILING_PADDING_REGEX)?.[0] ?? '';
const leading = content.match(CODE_BLOCK.TRIM_LEADING_PADDING_REGEX)?.[0] ?? '';
const trailing = content.match(CODE_BLOCK.TRIM_TRAILING_PADDING_REGEX)?.[0] ?? '';
const core = content.slice(leading.length, content.length - trailing.length);
// autoDetect off: re-guessing the language on every keystroke
// costs ~38ms a call and flickers while typing
@@ -4,11 +4,7 @@
import HighlightedMatch from '$lib/components/app/forms/HighlightedMatch.svelte';
import * as Popover from '$lib/components/ui/popover';
import * as Tooltip from '$lib/components/ui/tooltip';
import {
FILE_GLOB_SEARCH_PICKERS_DEFAULT_SEARCH_DEPTH,
HOME_TILDE,
SEARCH_DEBOUNCE_MS
} from '$lib/constants';
import { FILE_GLOB_SEARCH_PICKERS, HOME_TILDE, SEARCH } from '$lib/constants';
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';
@@ -70,7 +66,7 @@
const searchDepth = $derived.by(() => {
const n = Number(config().mentionSearchMaxDepth);
return Number.isInteger(n) && n > 0 ? n : FILE_GLOB_SEARCH_PICKERS_DEFAULT_SEARCH_DEPTH;
return Number.isInteger(n) && n > 0 ? n : FILE_GLOB_SEARCH_PICKERS.DEFAULT_SEARCH_DEPTH;
});
const home = $derived(toolsStore.serverHome);
@@ -80,7 +76,7 @@
const search = useDebouncedSearch({
canRun: () => isOpen && fileSearchEnabled,
debounceMs: SEARCH_DEBOUNCE_MS,
debounceMs: SEARCH.DEBOUNCE_MS,
getQuery: () => trimmedQuery,
run: async (query, signal, isCurrent) => {
try {
@@ -4,16 +4,7 @@
import { FolderOpen } from '@lucide/svelte';
import SearchInput from '$lib/components/app/forms/SearchInput.svelte';
import * as Popover from '$lib/components/ui/popover';
import {
DEFAULT_MOBILE_BREAKPOINT,
HOME_TILDE,
MAX_RESULTS_SHOWN,
NATIVE_LIMIT,
NATIVE_MAX_DEPTH,
SEARCH_DEBOUNCE_MS,
SEARCH_LIMIT,
SEARCH_MAX_DEPTH
} from '$lib/constants';
import { DEFAULT_MOBILE_BREAKPOINT, HOME_TILDE, SEARCH } from '$lib/constants';
import { BuiltInTool, GlobSearchType, KeyboardKey } from '$lib/enums';
import { useDebouncedSearch } from '$lib/hooks/use-debounced-search.svelte';
import { usePickerNavigation } from '$lib/hooks/use-picker-navigation.svelte';
@@ -142,7 +133,7 @@
// children too, so path navigation does not require a trailing slash.
const search = useDebouncedSearch({
canRun: () => isOpen && fileSearchEnabled,
debounceMs: SEARCH_DEBOUNCE_MS,
debounceMs: SEARCH.DEBOUNCE_MS,
getQuery: () => query.trim(),
run: async (q, signal, isCurrent) => {
const trimmed = q.trim();
@@ -162,8 +153,8 @@
const res = await runGlobSearchWithChildren(
trimmed,
homeBase ?? HOME_TILDE,
SEARCH_MAX_DEPTH,
SEARCH_LIMIT,
SEARCH.MAX_DEPTH,
SEARCH.LIMIT,
signal,
{ type: GlobSearchType.DIR }
);
@@ -179,7 +170,7 @@
}
searchScope = res.exactDir ?? res.args.path;
queryResults = res.entries.map((e) => e.path).slice(0, MAX_RESULTS_SHOWN);
queryResults = res.entries.map((e) => e.path).slice(0, SEARCH.MAX_RESULTS_SHOWN);
if (queryResults.length > 0) {
nav.reset(0);
@@ -223,8 +214,8 @@
try {
const res = await ToolsService.executeToolRaw(BuiltInTool.FILE_GLOB_SEARCH, {
include: buildCaseInsensitiveGlob(name),
limit: NATIVE_LIMIT,
max_depth: NATIVE_MAX_DEPTH,
limit: SEARCH.NATIVE_LIMIT,
max_depth: SEARCH.NATIVE_MAX_DEPTH,
path: homeBase ?? HOME_TILDE,
type: GlobSearchType.DIR
});
@@ -2,7 +2,7 @@
import { parseReadFileMeta } from './parsers/read-file';
import ToolCallBlock from './ToolCallBlock.svelte';
import { SyntaxHighlightedCode } from '$lib/components/app';
import { DEFAULT_LANGUAGE, MAX_HEIGHT_CODE_BLOCK } from '$lib/constants';
import { CODE_BLOCK, MAX_HEIGHT_CODE_BLOCK } from '$lib/constants';
import { type AgenticSection } from '$lib/utils';
interface Props {
@@ -32,7 +32,7 @@
{#if section.toolResult}
<SyntaxHighlightedCode
code={section.toolResult}
language={readFileMeta?.language ?? DEFAULT_LANGUAGE}
language={readFileMeta?.language ?? CODE_BLOCK.DEFAULT_LANGUAGE}
maxHeight={MAX_HEIGHT_CODE_BLOCK}
/>
{:else}
@@ -4,11 +4,7 @@
// can render incrementally as the file path streams in.
import { parseToolArgs } from './_shared';
import {
DEFAULT_LANGUAGE,
FILE_PATH_SEPARATOR_REGEX,
TEXT_LANGUAGE_PREFIX_REGEX
} from '$lib/constants';
import { CODE_BLOCK, FILE_PATH_SEPARATOR_REGEX } from '$lib/constants';
import { BuiltInTool } from '$lib/enums';
import { type AgenticSection, getFileTypeByExtension } from '$lib/utils';
@@ -50,7 +46,9 @@ export function parseReadFileMeta(section: AgenticSection): ReadFileMeta | null
}
const fileType = getFileTypeByExtension(fileName);
const language = fileType ? fileType.replace(TEXT_LANGUAGE_PREFIX_REGEX, '') : DEFAULT_LANGUAGE;
const language = fileType
? fileType.replace(CODE_BLOCK.TEXT_LANGUAGE_PREFIX_REGEX, '')
: CODE_BLOCK.DEFAULT_LANGUAGE;
return { fileName, language, lineRange };
}
@@ -4,11 +4,7 @@
// result blob.
import { parseToolArgs } from './_shared';
import {
DEFAULT_LANGUAGE,
FILE_PATH_SEPARATOR_REGEX,
TEXT_LANGUAGE_PREFIX_REGEX
} from '$lib/constants';
import { CODE_BLOCK, FILE_PATH_SEPARATOR_REGEX } from '$lib/constants';
import { BuiltInTool } from '$lib/enums';
import { type AgenticSection, getFileTypeByExtension, tryParseToolResultObject } from '$lib/utils';
@@ -36,7 +32,8 @@ export function parseWriteFileMeta(section: AgenticSection): WriteFileMeta | nul
const fileName = rawPath.split(FILE_PATH_SEPARATOR_REGEX).pop() || rawPath;
const content = typeof args.content === 'string' ? args.content : '';
const language =
getFileTypeByExtension(rawPath)?.replace(TEXT_LANGUAGE_PREFIX_REGEX, '') ?? DEFAULT_LANGUAGE;
getFileTypeByExtension(rawPath)?.replace(CODE_BLOCK.TEXT_LANGUAGE_PREFIX_REGEX, '') ??
CODE_BLOCK.DEFAULT_LANGUAGE;
const resultObj = tryParseToolResultObject(section.toolResult);
const bytesWritten =
resultObj && Number.isFinite(Number(resultObj.bytes)) ? Number(resultObj.bytes) : undefined;
@@ -26,7 +26,7 @@
} from '$lib/components/app';
import {
BOOL_TRUE_STRING,
CODE_BLOCK_HEADER_CLASS,
CODE_BLOCK_CLASS,
DATA_ERROR_BOUND_ATTR,
DATA_ERROR_HANDLED_ATTR,
DIAGRAM_VIEW_MODE_ATTR,
@@ -39,15 +39,8 @@
MERMAID_SYNTAX_ATTR,
MERMAID_WRAPPER_CLASS,
SETTINGS_KEYS,
SVG_BLOCK_CLASS,
SVG_INLINE_SHADOW_STYLE,
SVG_LANGUAGE,
SVG_RENDERED_ATTR,
SVG_SOURCE_ATTR,
SVG_TAG_PREFIX,
SVG_WRAPPER_CLASS,
TOGGLE_SOURCE_BTN_CLASS,
XML_LANGUAGE
SVG,
TOGGLE_SOURCE_BTN_CLASS
} from '$lib/constants';
import { ColorMode, UrlProtocol } from '$lib/enums';
import { FileTypeText } from '$lib/enums/files.enums';
@@ -105,9 +98,9 @@
if (!block) return null;
if (block.language === SVG_LANGUAGE) return block.code;
if (block.language === SVG.LANGUAGE) return block.code;
if (block.language === XML_LANGUAGE && block.code.trimStart().startsWith(SVG_TAG_PREFIX))
if (block.language === SVG.XML_LANGUAGE && block.code.trimStart().startsWith(SVG.TAG_PREFIX))
return block.code;
return null;
@@ -137,7 +130,7 @@
// Mount the streaming svg into its shadow host on every chunk so it renders live
$effect(() => {
if (streamingSvgHost) mountSvgShadow(streamingSvgHost, liveSvgHtml, SVG_INLINE_SHADOW_STYLE);
if (streamingSvgHost) mountSvgShadow(streamingSvgHost, liveSvgHtml, SVG.INLINE_SHADOW_STYLE);
});
let streamingCodeScrollContainer = $state<HTMLDivElement>();
@@ -535,7 +528,7 @@
event.preventDefault();
event.stopPropagation();
const wrapper = toggleBtn.closest(`.${MERMAID_WRAPPER_CLASS}, .${SVG_WRAPPER_CLASS}`);
const wrapper = toggleBtn.closest(`.${MERMAID_WRAPPER_CLASS}, .${SVG.WRAPPER_CLASS}`);
if (!wrapper) return;
@@ -593,16 +586,16 @@
}
// Check if clicking on copy or preview button in svg block
const svgCopyBtn = target.closest(`.${SVG_WRAPPER_CLASS} .copy-code-btn`);
const svgPreviewBtn = target.closest(`.${SVG_WRAPPER_CLASS} .preview-code-btn`);
const svgCopyBtn = target.closest(`.${SVG.WRAPPER_CLASS} .copy-code-btn`);
const svgPreviewBtn = target.closest(`.${SVG.WRAPPER_CLASS} .preview-code-btn`);
if (svgCopyBtn || svgPreviewBtn) {
const wrapper = target.closest(`.${SVG_WRAPPER_CLASS}`);
const wrapper = target.closest(`.${SVG.WRAPPER_CLASS}`);
if (!wrapper) return;
const preElement = wrapper.querySelector<HTMLElement>(
`pre.${SVG_BLOCK_CLASS}[${SVG_SOURCE_ATTR}]`
`pre.${SVG.BLOCK_CLASS}[${SVG.SOURCE_ATTR}]`
);
if (!preElement) return;
@@ -611,7 +604,7 @@
event.preventDefault();
event.stopPropagation();
try {
await copyToClipboard(preElement.getAttribute(SVG_SOURCE_ATTR) ?? '');
await copyToClipboard(preElement.getAttribute(SVG.SOURCE_ATTR) ?? '');
} catch (error) {
console.error('Failed to copy svg source:', error);
}
@@ -622,7 +615,7 @@
if (svgPreviewBtn) {
event.preventDefault();
event.stopPropagation();
mermaidPreviewSvgHtml = sanitizeSvg(preElement.getAttribute(SVG_SOURCE_ATTR) ?? '');
mermaidPreviewSvgHtml = sanitizeSvg(preElement.getAttribute(SVG.SOURCE_ATTR) ?? '');
svgPreviewLive = false;
mermaidPreviewOpen = true;
@@ -633,14 +626,14 @@
// A click on the header chrome targets the action buttons, never the
// diagram. Guard so a header click can not fall through to the click to
// zoom branches below, whatever the scroll position or stacking.
if (target.closest(`.${CODE_BLOCK_HEADER_CLASS}`)) return;
if (target.closest(`.${CODE_BLOCK_CLASS.HEADER}`)) return;
// Open preview when clicking the svg block itself. A final block carries its
// source, a streaming block does not and is mirrored live into the dialog.
const svgEl = target.closest(`.${SVG_BLOCK_CLASS}`);
const svgEl = target.closest(`.${SVG.BLOCK_CLASS}`);
if (svgEl) {
const source = svgEl.getAttribute(SVG_SOURCE_ATTR);
const source = svgEl.getAttribute(SVG.SOURCE_ATTR);
if (source !== null) {
mermaidPreviewSvgHtml = sanitizeSvg(source);
@@ -739,15 +732,15 @@
if (!containerRef) return;
const nodes = containerRef.querySelectorAll<HTMLElement>(
`pre.${SVG_BLOCK_CLASS}:not([${SVG_RENDERED_ATTR}])`
`pre.${SVG.BLOCK_CLASS}:not([${SVG.RENDERED_ATTR}])`
);
if (nodes.length === 0) return;
nodes.forEach((node) => {
node.setAttribute(SVG_RENDERED_ATTR, 'true');
node.setAttribute(SVG.RENDERED_ATTR, 'true');
const source = node.getAttribute(SVG_SOURCE_ATTR) ?? node.textContent ?? '';
const source = node.getAttribute(SVG.SOURCE_ATTR) ?? node.textContent ?? '';
const clean = sanitizeSvg(source);
if (clean) {
@@ -755,7 +748,7 @@
const host = document.createElement('div');
node.appendChild(host);
mountSvgShadow(host, clean, SVG_INLINE_SHADOW_STYLE);
mountSvgShadow(host, clean, SVG.INLINE_SHADOW_STYLE);
}
});
}
@@ -919,7 +912,7 @@
</div>
{#if liveSvgHtml}
<div class="svg-scroll-container">
<div class={SVG_BLOCK_CLASS}>
<div class={SVG.BLOCK_CLASS}>
<div bind:this={streamingSvgHost}></div>
</div>
</div>
@@ -4,17 +4,11 @@
*/
import {
CODE_BLOCK_ACTIONS_CLASS,
CODE_BLOCK_HEADER_CLASS,
CODE_BLOCK_SCROLL_CONTAINER_CLASS,
CODE_BLOCK_CLASS,
CODE_ICON_SVG,
CODE_LANGUAGE_CLASS,
COPY_CODE_BTN_CLASS,
COPY_ICON_SVG,
DIAGRAM_SOURCE_CLASS,
PREVIEW_CODE_BTN_CLASS,
PREVIEW_ICON_SVG,
RELATIVE_CLASS,
TOGGLE_SOURCE_BTN_CLASS
} from '$lib/constants';
import type { Element, ElementContent } from 'hast';
@@ -65,7 +59,7 @@ export function createButton(
* Creates a copy button element.
*/
export function createCopyButton(id: string, idAttribute: string, title: string = 'Copy'): Element {
return createButton(COPY_CODE_BTN_CLASS, title, COPY_ICON_SVG, id, idAttribute);
return createButton(CODE_BLOCK_CLASS.COPY_BTN, title, COPY_ICON_SVG, id, idAttribute);
}
/**
@@ -76,7 +70,7 @@ export function createPreviewButton(
idAttribute: string,
title: string = 'Preview'
): Element {
return createButton(PREVIEW_CODE_BTN_CLASS, title, PREVIEW_ICON_SVG, id, idAttribute);
return createButton(CODE_BLOCK_CLASS.PREVIEW_BTN, title, PREVIEW_ICON_SVG, id, idAttribute);
}
/**
@@ -120,7 +114,7 @@ export function createSourceView(
type: 'element'
}
],
properties: { className: [DIAGRAM_SOURCE_CLASS, CODE_BLOCK_SCROLL_CONTAINER_CLASS] },
properties: { className: [DIAGRAM_SOURCE_CLASS, CODE_BLOCK_CLASS.SCROLL_CONTAINER] },
tagName: 'div',
type: 'element'
};
@@ -134,7 +128,7 @@ export function createBlockHeader(
id: string,
idAttribute: string,
actions: Element[],
languageClassName: string = CODE_LANGUAGE_CLASS
languageClassName: string = CODE_BLOCK_CLASS.LANGUAGE
): Element {
return {
children: [
@@ -146,12 +140,12 @@ export function createBlockHeader(
},
{
children: actions,
properties: { className: [CODE_BLOCK_ACTIONS_CLASS] },
properties: { className: [CODE_BLOCK_CLASS.ACTIONS] },
tagName: 'div',
type: 'element'
}
],
properties: { className: [CODE_BLOCK_HEADER_CLASS] },
properties: { className: [CODE_BLOCK_CLASS.HEADER] },
tagName: 'div',
type: 'element'
};
@@ -185,7 +179,7 @@ export function createWrapper(
return {
children: [header, createScrollContainer(preElement, scrollContainerClass), ...extraChildren],
properties: {
className: [wrapperClass, RELATIVE_CLASS],
className: [wrapperClass, CODE_BLOCK_CLASS.RELATIVE],
...additionalAttributes
} as Element['properties'],
tagName: 'div',
@@ -17,7 +17,7 @@ import {
createWrapper,
generateBlockId
} from './code-block-utils';
import { CODE_BLOCK_SCROLL_CONTAINER_CLASS, CODE_BLOCK_WRAPPER_CLASS } from '$lib/constants';
import { CODE_BLOCK_CLASS } from '$lib/constants';
import type { Element, ElementContent, Root } from 'hast';
import type { Plugin } from 'unified';
import { visit } from 'unist-util-visit';
@@ -78,8 +78,8 @@ export const rehypeEnhanceCodeBlocks: Plugin<[], Root> = () => {
const wrapper = createWrapper(
header,
node,
CODE_BLOCK_WRAPPER_CLASS,
CODE_BLOCK_SCROLL_CONTAINER_CLASS
CODE_BLOCK_CLASS.WRAPPER,
CODE_BLOCK_CLASS.SCROLL_CONTAINER
);
// Replace pre with wrapper in parent
@@ -19,16 +19,7 @@ import {
generateBlockId
} from './code-block-utils';
import type { DiagramPreData } from './pre-transform';
import {
DIAGRAM_VIEW_MODE_ATTR,
DIAGRAM_VIEW_RENDERED,
SVG_BLOCK_CLASS,
SVG_ID_ATTR,
SVG_LANGUAGE,
SVG_SCROLL_CONTAINER_CLASS,
SVG_SOURCE_ATTR,
SVG_WRAPPER_CLASS
} from '$lib/constants';
import { DIAGRAM_VIEW_MODE_ATTR, DIAGRAM_VIEW_RENDERED, SVG } from '$lib/constants';
import type { Element, ElementContent, Root } from 'hast';
import type { Plugin } from 'unified';
import { visit } from 'unist-util-visit';
@@ -48,11 +39,11 @@ export const rehypeEnhanceSvgBlocks: Plugin<[], Root> = () => {
if (!Array.isArray(className)) return;
const isSvg = className.some((cls) => typeof cls === 'string' && cls === SVG_BLOCK_CLASS);
const isSvg = className.some((cls) => typeof cls === 'string' && cls === SVG.BLOCK_CLASS);
if (!isSvg) return;
const svgId = generateBlockId(SVG_LANGUAGE, 'idxSvgBlock');
const svgId = generateBlockId(SVG.LANGUAGE, 'idxSvgBlock');
// Extract the svg source (text content of the pre element)
const svgSource = node.children
.map((child) => {
@@ -65,26 +56,26 @@ export const rehypeEnhanceSvgBlocks: Plugin<[], Root> = () => {
// Store the svg source in data attribute for copy and render
node.properties = {
...node.properties,
[SVG_ID_ATTR]: svgId,
[SVG_SOURCE_ATTR]: svgSource
[SVG.ID_ATTR]: svgId,
[SVG.SOURCE_ATTR]: svgSource
};
const actions = [
createCopyButton(svgId, SVG_ID_ATTR, 'Copy svg source'),
createToggleSourceButton(svgId, SVG_ID_ATTR, 'Toggle svg source'),
createPreviewButton(svgId, SVG_ID_ATTR, 'Preview svg')
createCopyButton(svgId, SVG.ID_ATTR, 'Copy svg source'),
createToggleSourceButton(svgId, SVG.ID_ATTR, 'Toggle svg source'),
createPreviewButton(svgId, SVG.ID_ATTR, 'Preview svg')
];
const header = createBlockHeader(SVG_LANGUAGE, svgId, SVG_ID_ATTR, actions);
const header = createBlockHeader(SVG.LANGUAGE, svgId, SVG.ID_ATTR, actions);
const preservedCode = (node.data as DiagramPreData | undefined)?.sourceCode;
const sourceView = createSourceView(preservedCode, svgSource, SVG_LANGUAGE);
const sourceView = createSourceView(preservedCode, svgSource, SVG.LANGUAGE);
const wrapper = createWrapper(
header,
node,
SVG_WRAPPER_CLASS,
SVG_SCROLL_CONTAINER_CLASS,
SVG.WRAPPER_CLASS,
SVG.SCROLL_CONTAINER_CLASS,
{
[DIAGRAM_VIEW_MODE_ATTR]: DIAGRAM_VIEW_RENDERED,
[SVG_ID_ATTR]: svgId
[SVG.ID_ATTR]: svgId
},
[sourceView]
);
@@ -1,5 +1,5 @@
import { createPreTransform } from './pre-transform';
import { SVG_BLOCK_CLASS, SVG_LANGUAGE, SVG_TAG_PREFIX, XML_LANGUAGE } from '$lib/constants';
import { SVG } from '$lib/constants';
/**
* Converts svg code blocks to <pre class="svg-block"> for client-side rendering.
@@ -7,7 +7,7 @@ import { SVG_BLOCK_CLASS, SVG_LANGUAGE, SVG_TAG_PREFIX, XML_LANGUAGE } from '$li
* svg inside an xml fence.
*/
export const rehypeSvgPre = createPreTransform(
[SVG_LANGUAGE, XML_LANGUAGE],
SVG_BLOCK_CLASS,
(text) => text.startsWith(SVG_TAG_PREFIX)
[SVG.LANGUAGE, SVG.XML_LANGUAGE],
SVG.BLOCK_CLASS,
(text) => text.startsWith(SVG.TAG_PREFIX)
);
@@ -1,6 +1,6 @@
<script lang="ts">
import MermaidPreviewControls from './MermaidPreviewControls.svelte';
import { SVG_DIALOG_SHADOW_STYLE } from '$lib/constants';
import { SVG } from '$lib/constants';
import { mountSvgShadow } from '$lib/utils/svg-shadow';
interface Props {
@@ -13,7 +13,7 @@
// Re-mount on every svgHtml change so a live streaming svg keeps rendering while zoomed
$effect(() => {
if (svgHost) mountSvgShadow(svgHost, svgHtml, SVG_DIALOG_SHADOW_STYLE);
if (svgHost) mountSvgShadow(svgHost, svgHtml, SVG.DIALOG_SHADOW_STYLE);
});
// Zoom and pan state
@@ -4,13 +4,12 @@
import { Button } from '$lib/components/ui/button';
import * as Dialog from '$lib/components/ui/dialog';
import {
BEARER_PREFIX,
BOOL_FALSE_STRING,
BOOL_TRUE_STRING,
DISMISSED_RECOMMENDED_MCP_SERVERS_LOCALSTORAGE_KEY,
HEADERS,
MCP_SERVER_ID_PREFIX,
RECOMMENDED_MCP_SERVERS,
REDACTED_HEADERS
RECOMMENDED_MCP_SERVERS
} from '$lib/constants';
import { HealthCheckStatus } from '$lib/enums';
import { conversationsStore } from '$lib/stores/conversations.svelte';
@@ -61,10 +60,10 @@
let bearerTokenFilled = $derived.by(() => {
const pairs = parseHeadersToArray(newServerHeaders);
const bearerPrefix = BEARER_PREFIX.toLowerCase();
const bearerPrefix = HEADERS.BEARER.toLowerCase();
const bearer = pairs.find(
(p) =>
REDACTED_HEADERS.has(p.key.trim().toLowerCase()) &&
HEADERS.REDACTED.has(p.key.trim().toLowerCase()) &&
p.value.trim().toLowerCase().startsWith(bearerPrefix)
);
@@ -2,13 +2,7 @@
import { KeyValuePairs } from '$lib/components/app';
import { Input } from '$lib/components/ui/input';
import { Switch } from '$lib/components/ui/switch';
import {
AUTHORIZATION_HEADER,
BEARER_PREFIX,
CLI_FLAGS,
MCP_SERVER_URL_PLACEHOLDER,
REDACTED_HEADERS
} from '$lib/constants';
import { CLI_FLAGS, HEADERS, MCP_SERVER_URL_PLACEHOLDER } from '$lib/constants';
import { UrlProtocol } from '$lib/enums';
import { mcpStore } from '$lib/stores/mcp.svelte';
import type { KeyValuePair } from '$lib/types';
@@ -72,10 +66,10 @@
// carry a Bearer scheme. Anything else (e.g. Basic, raw tokens) stays in the
// KV section so the user can still edit those values verbatim.
const matchesAuthorizationKey = (key: string): boolean =>
REDACTED_HEADERS.has(key.trim().toLowerCase());
HEADERS.REDACTED.has(key.trim().toLowerCase());
const isBearerScheme = (value: string): boolean =>
value.trim().toLowerCase().startsWith(BEARER_PREFIX.toLowerCase());
value.trim().toLowerCase().startsWith(HEADERS.BEARER.toLowerCase());
const ownedByBearerUi = (p: KeyValuePair): boolean =>
matchesAuthorizationKey(p.key) && isBearerScheme(p.value);
@@ -102,7 +96,7 @@
if (!auth) return '';
return auth.value.trim().slice(BEARER_PREFIX.length).trim();
return auth.value.trim().slice(HEADERS.BEARER.length).trim();
});
$effect(() => {
@@ -125,7 +119,7 @@
const trimmed = token.trim();
if (trimmed) {
filtered.push({ key: AUTHORIZATION_HEADER, value: `${BEARER_PREFIX}${trimmed}` });
filtered.push({ key: HEADERS.AUTHORIZATION, value: `${HEADERS.BEARER}${trimmed}` });
}
updateHeaderPairs(filtered);
@@ -5,13 +5,7 @@
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import Label from '$lib/components/ui/label/label.svelte';
import {
AUTHORIZATION_HEADER,
BEARER_PREFIX,
ICON_CLASS_DEFAULT,
ROUTES,
SETTINGS_KEYS
} from '$lib/constants';
import { HEADERS, ICON_CLASS_DEFAULT, ROUTES, SETTINGS_KEYS } from '$lib/constants';
import { KeyboardKey } from '$lib/enums';
import { serverLoading, serverStore } from '$lib/stores/server.svelte';
import { config, settingsStore } from '$lib/stores/settings.svelte';
@@ -76,8 +70,8 @@
// Test the API key by making a real request to the server
const response = await fetch(`${base}/props`, {
headers: {
[AUTHORIZATION_HEADER]: `${BEARER_PREFIX}${apiKeyInput.trim()}`,
'Content-Type': 'application/json'
'Content-Type': 'application/json',
[HEADERS.AUTHORIZATION]: `${HEADERS.BEARER}${apiKeyInput.trim()}`
}
});
@@ -5,22 +5,14 @@ export const ATTACHMENT_SAVED_REGEX = /\[Attachment saved: ([^\]]+)\]/;
// JSON detection: trimmed content opens with an object or array literal.
export const TOOL_RESULT_JSON_OPEN_REGEX = /^[[{]/;
// Markdown structural markers used by `looksLikeMarkdown`. Inline / line-level.
export const MARKDOWN_CODE_FENCE_REGEX = /^(```|~~~)/m;
export const MARKDOWN_ATX_HEADING_REGEX = /^#{1,6}\s+\S/;
export const MARKDOWN_BLOCKQUOTE_REGEX = /^>\s+\S/;
export const MARKDOWN_LIST_BULLET_REGEX = /^\s*[-*+]\s+\S/;
export const MARKDOWN_LIST_NUMBERED_REGEX = /^\s*\d+[.)]\s+\S/;
export const MARKDOWN_LINK_REGEX = /\[[^\]\n]+\]\([^)\s]+\)/;
export const MARKDOWN_BOLD_REGEX = /\*\*[^*\n]+\*\*|__[^_\n]+__/;
export const MARKDOWN_TABLE_SEPARATOR_REGEX = /^\s*\|?[\s:|-]+\|?\s*$/;
// Search-summary wire format used by file-glob and grep tools:
// <matches>
// ---
// Total matches: N
export const SEARCH_SUMMARY_SEPARATOR = '---\n';
export const SEARCH_SUMMARY_TOTAL_REGEX = /Total matches:\s*(\d+)/;
export const SEARCH_SUMMARY = {
SEPARATOR: '---\n',
TOTAL_REGEX: /Total matches:\s*(\d+)/
} as const;
// Separator rendered between stats in the tool-result footer (e.g. between a
// result message and the byte/edit count). Plain ASCII spaces bracket a hyphen
+29 -39
View File
@@ -3,52 +3,42 @@
*/
/**
* Default TTL (Time-To-Live) for cache entries in milliseconds
* @default 5 minutes
* Default cache limits when no per-cache overrides are given.
*/
export const DEFAULT_CACHE_TTL_MS = 5 * 60 * 1000;
export const CACHE = {
/** Default maximum number of entries in a cache */
DEFAULT_MAX_ENTRIES: 100,
/** Default TTL (Time-To-Live) for cache entries in milliseconds (5 minutes) */
DEFAULT_TTL_MS: 5 * 60 * 1000
} as const;
/**
* Default maximum number of entries in a cache
* @default 100
* TTL and size for the model props cache.
* Props don't change frequently, so we can cache them longer.
*/
export const DEFAULT_CACHE_MAX_ENTRIES = 100;
export const MODEL_PROPS_CACHE = {
/** Maximum number of model props to cache */
MAX_ENTRIES: 50,
/** TTL for model props cache entries in milliseconds (10 minutes) */
TTL_MS: 10 * 60 * 1000
} as const;
/**
* TTL for model props cache in milliseconds
* Props don't change frequently, so we can cache them longer
* @default 10 minutes
* TTL and size for the MCP resource cache.
*/
export const MODEL_PROPS_CACHE_TTL_MS = 10 * 60 * 1000;
export const MCP_RESOURCE_CACHE = {
/** Maximum number of MCP resources to cache */
MAX_ENTRIES: 50,
/** TTL for MCP resource cache entries in milliseconds (5 minutes) */
TTL_MS: 5 * 60 * 1000
} as const;
/**
* Maximum number of model props to cache
* @default 50
* Limits for pruning inactive conversation states held in memory.
*/
export const MODEL_PROPS_CACHE_MAX_ENTRIES = 50;
/**
* Maximum number of MCP resources to cache
* @default 50
*/
export const MCP_RESOURCE_CACHE_MAX_ENTRIES = 50;
/**
* TTL for MCP resource cache entries in milliseconds
* @default 5 minutes
*/
export const MCP_RESOURCE_CACHE_TTL_MS = 5 * 60 * 1000;
/**
* Maximum number of inactive conversation states to keep in memory
* States for conversations beyond this limit will be cleaned up
* @default 10
*/
export const MAX_INACTIVE_CONVERSATION_STATES = 10;
/**
* Maximum age (in ms) for inactive conversation states before cleanup
* States older than this will be removed during cleanup
* @default 30 minutes
*/
export const INACTIVE_CONVERSATION_STATE_MAX_AGE_MS = 30 * 60 * 1000;
export const INACTIVE_CONVERSATION = {
/** Maximum age (in ms) for inactive conversation states before cleanup (30 minutes) */
MAX_AGE_MS: 30 * 60 * 1000,
/** Maximum number of inactive conversation states to keep in memory */
MAX_STATES: 10
} as const;
@@ -1,34 +1,42 @@
// Constants for the markdown code-block renderer: language/fence handling and CSS classes.
export const DEFAULT_LANGUAGE = 'text';
export const LANG_PATTERN = /^(\w*)\n?/;
export const AMPERSAND_REGEX = /&/g;
export const LT_REGEX = /</g;
export const GT_REGEX = />/g;
export const FENCE_PATTERN = /^```|\n```/g;
/** Parsing and escaping helpers for the markdown code-block renderer. */
export const CODE_BLOCK = {
AMPERSAND_REGEX: /&/g,
/** Language fallback used when no language is specified. */
DEFAULT_LANGUAGE: 'text',
/** Matches opening/closing markdown code fences. */
FENCE_PATTERN: /^```|\n```/g,
GT_REGEX: />/g,
/** Matches the language specifier at the start of a code fence. */
LANG_PATTERN: /^(\w*)\n?/,
LT_REGEX: /</g,
// Whitespace-only empty lines (between start of string and first non-empty line).
// Used by trimCodePadding to drop leading/trailing phantom blank rows from LLM
// payload wrappers without touching internal blank lines.
export const TRIM_LEADING_PADDING_REGEX = /^(?:[ \t]*\n)+/;
export const TRIM_TRAILING_PADDING_REGEX = /(?:\n[ \t]*)+$/;
// Matches the `text:` prefix that file-type identifiers use to denote a
// plain-text language (e.g. `text:typescript`). Used by tool-call renderers
// to recover the underlying highlight.js language.
TEXT_LANGUAGE_PREFIX_REGEX: /^text:/,
// Whitespace-only empty lines (between start of string and first non-empty line).
// Used by trimCodePadding to drop leading/trailing phantom blank rows from LLM
// payload wrappers without touching internal blank lines.
TRIM_LEADING_PADDING_REGEX: /^(?:[ \t]*\n)+/,
TRIM_TRAILING_PADDING_REGEX: /(?:\n[ \t]*)+$/
} as const;
// Matches either Unix or Windows path separators so `String.split(REGEX)` can
// recover the trailing file-name segment from either `/foo/bar.txt` or
// `C:\foo\bar.txt`. Used wherever a parameter accepts a user-supplied path.
export const FILE_PATH_SEPARATOR_REGEX = /[\\/]/;
// Matches the `text:` prefix that file-type identifiers use to denote a
// plain-text language (e.g. `text:typescript`). Used by tool-call renderers
// to recover the underlying highlight.js language.
export const TEXT_LANGUAGE_PREFIX_REGEX = /^text:/;
// CSS classes applied by the markdown code-block renderer.
export const CODE_BLOCK_SCROLL_CONTAINER_CLASS = 'code-block-scroll-container';
export const CODE_BLOCK_WRAPPER_CLASS = 'code-block-wrapper';
export const CODE_BLOCK_HEADER_CLASS = 'code-block-header';
export const CODE_BLOCK_ACTIONS_CLASS = 'code-block-actions';
export const CODE_LANGUAGE_CLASS = 'code-language';
export const COPY_CODE_BTN_CLASS = 'copy-code-btn';
export const PREVIEW_CODE_BTN_CLASS = 'preview-code-btn';
export const RELATIVE_CLASS = 'relative';
/** CSS classes applied by the markdown code-block renderer. */
export const CODE_BLOCK_CLASS = {
ACTIONS: 'code-block-actions',
COPY_BTN: 'copy-code-btn',
HEADER: 'code-block-header',
LANGUAGE: 'code-language',
PREVIEW_BTN: 'preview-code-btn',
RELATIVE: 'relative',
SCROLL_CONTAINER: 'code-block-scroll-container',
WRAPPER: 'code-block-wrapper'
} as const;
@@ -9,8 +9,8 @@ export const MIME_TYPE_PREFIXES = {
} as const;
export const MIME_TYPE_SUBSTRINGS = {
JSON: 'json',
JAVASCRIPT: 'javascript',
JSON: 'json',
TYPESCRIPT: 'typescript'
} as const;
+27 -29
View File
@@ -1,34 +1,32 @@
export const MEGAPIXELS_TO_PIXELS = 1_000_000;
/** Image handling constants */
export const HEIC_JPEG_QUALITY = 0.85;
export const IMAGE = {
/** JPEG quality used when transcoding HEIC images. */
HEIC_JPEG_QUALITY: 0.85,
/** Unit conversion: pixels per megapixel. */
MEGAPIXELS_TO_PIXELS: 1_000_000
} as const;
/**
* JPEG and EXIF binary format constants for orientation parsing.
*/
/** Bytes of file prefix to scan, the APP1 EXIF segment sits near the start */
export const EXIF_SCAN_BYTE_LIMIT = 128 * 1024;
/** JPEG start of image marker */
export const JPEG_SOI_MARKER = 0xffd8;
/** APP1 segment marker byte, carries the EXIF payload */
export const APP1_MARKER = 0xe1;
/** Start of scan marker byte, compressed data begins and no EXIF follows */
export const SOS_MARKER = 0xda;
/** "Exif" signature opening the APP1 payload, big endian uint32 */
export const EXIF_SIGNATURE = 0x45786966;
/** TIFF byte order mark for little endian ("II") */
export const TIFF_LITTLE_ENDIAN = 0x4949;
/** TIFF magic number following the byte order mark */
export const TIFF_MAGIC = 42;
/** EXIF tag id holding the orientation value */
export const EXIF_ORIENTATION_TAG = 0x0112;
/** Size in bytes of one IFD directory entry */
export const IFD_ENTRY_SIZE = 12;
export const EXIF = {
/** APP1 segment marker byte, carries the EXIF payload */
APP1_MARKER: 0xe1,
/** "Exif" signature opening the APP1 payload, big endian uint32 */
EXIF_SIGNATURE: 0x45786966,
/** Size in bytes of one IFD directory entry */
IFD_ENTRY_SIZE: 12,
/** JPEG start of image marker */
JPEG_SOI_MARKER: 0xffd8,
/** EXIF tag id holding the orientation value */
ORIENTATION_TAG: 0x0112,
/** Bytes of file prefix to scan, the APP1 EXIF segment sits near the start */
SCAN_BYTE_LIMIT: 128 * 1024,
/** Start of scan marker byte, compressed data begins and no EXIF follows */
SOS_MARKER: 0xda,
/** TIFF byte order mark for little endian ("II") */
TIFF_LITTLE_ENDIAN: 0x4949,
/** TIFF magic number following the byte order mark */
TIFF_MAGIC: 42
} as const;
+1 -1
View File
@@ -8,6 +8,7 @@ export * from './database.constants';
export * from './reasoning-effort.constants';
export * from './recommended-mcp-servers.constants';
export * from './storage.constants';
export * from './icons.constants';
export * from './attachment-menu.constants';
export * from './auto-scroll.constants';
export * from './context-gauge-popup.constants';
@@ -20,7 +21,6 @@ export * from './chat-form.constants';
export * from './chat-commands.constants';
export * from './cli-flags.constants';
export * from './code-block.constants';
export * from './icons.constants';
export * from './context-keys.constants';
export * from './control-actions.constants';
export * from './css-classes.constants';
@@ -3,3 +3,15 @@ export const DATA_ERROR_BOUND_ATTR = 'errorBound';
export const DATA_ERROR_HANDLED_ATTR = 'errorHandled';
export const BOOL_TRUE_STRING = 'true';
export const BOOL_FALSE_STRING = 'false';
/** Markdown structural markers used by `looksLikeMarkdown`. Inline / line-level. */
export const MARKDOWN = {
ATX_HEADING_REGEX: /^#{1,6}\s+\S/,
BLOCKQUOTE_REGEX: /^>\s+\S/,
BOLD_REGEX: /\*\*[^*\n]+\*\*|__[^_\n]+__/,
CODE_FENCE_REGEX: /^(```|~~~)/m,
LINK_REGEX: /\[[^\]\n]+\]\([^)\s]+\)/,
LIST_BULLET_REGEX: /^\s*[-*+]\s+\S/,
LIST_NUMBERED_REGEX: /^\s*\d+[.)]\s+\S/,
TABLE_SEPARATOR_REGEX: /^\s*\|?[\s:|-]+\|?\s*$/
} as const;
+43 -40
View File
@@ -36,11 +36,14 @@ export const DEFAULT_MCP_CONFIG = {
export const MCP_SERVER_ID_PREFIX = 'LlamaUI-MCP-Server';
export const MCP_RECONNECT_INITIAL_DELAY = 1000;
export const MCP_RECONNECT_BACKOFF_MULTIPLIER = 2;
export const MCP_RECONNECT_MAX_DELAY = 30000;
/** Per-attempt timeout for a single reconnection attempt before giving up and backing off. */
export const MCP_RECONNECT_ATTEMPT_TIMEOUT_MS = 15_000;
/** Backoff policy for reconnecting to a dropped MCP server. */
export const MCP_RECONNECT = {
/** Per-attempt timeout for a single reconnection attempt before giving up and backing off. */
ATTEMPT_TIMEOUT_MS: 15_000,
BACKOFF_MULTIPLIER: 2,
INITIAL_DELAY: 1000,
MAX_DELAY: 30000
};
/** Maximum number of MCP server avatars to display in the chat form */
export const MAX_DISPLAYED_MCP_AVATARS = 4;
@@ -48,40 +51,45 @@ export const MAX_DISPLAYED_MCP_AVATARS = 4;
/** Expected count when two theme-less icons represent a light/dark pair */
export const EXPECTED_THEMED_ICON_PAIR_COUNT = 2;
/** CORS proxy URL query parameter name */
export const CORS_PROXY_URL_PARAM = 'url';
/** CORS proxy connection settings */
export const CORS_PROXY = {
/** Header prefix for headers that should be forwarded by the CORS proxy */
HEADER_PREFIX: 'x-llama-server-proxy-header-',
/** CORS proxy URL query parameter name */
URL_PARAM: 'url'
} as const;
/** Header prefix for headers that should be forwarded by the CORS proxy */
export const CORS_PROXY_HEADER_PREFIX = 'x-llama-server-proxy-header-';
/** HTTP header handling for API and MCP requests. */
export const HEADERS = {
/** Canonical casing for the Authorization header (RFC 7235) */
AUTHORIZATION: 'Authorization',
/** Bearer scheme prefix used for Authorization headers (RFC 6750) */
BEARER: 'Bearer ',
/** Content-Type HTTP header name */
CONTENT_TYPE: 'Content-Type',
/** Number of trailing characters to keep visible when partially redacting mcp-session-id */
export const MCP_SESSION_ID_VISIBLE_CHARS = 5;
/** Partial-redaction rules for MCP headers: header name -> visible trailing chars */
PARTIAL_REDACT: new Map<string, number>([['mcp-session-id', 5]]),
/** Partial-redaction rules for MCP headers: header name -> visible trailing chars */
export const MCP_PARTIAL_REDACT_HEADERS = new Map<string, number>([
['mcp-session-id', MCP_SESSION_ID_VISIBLE_CHARS]
]);
/** Header names whose values should be redacted in diagnostic logs */
REDACTED: new Set([
'authorization',
'api-key',
'cookie',
'mcp-session-id',
'proxy-authorization',
'set-cookie',
'x-auth-token',
'x-api-key'
])
};
/** Bearer scheme prefix used for Authorization headers (RFC 6750) */
export const BEARER_PREFIX = 'Bearer ';
/** Canonical casing for the Authorization header (RFC 7235) */
export const AUTHORIZATION_HEADER = 'Authorization';
/** Content-Type HTTP header name */
export const CONTENT_TYPE_HEADER = 'Content-Type';
/** Header names whose values should be redacted in diagnostic logs */
export const REDACTED_HEADERS = new Set([
'authorization',
'api-key',
'cookie',
'mcp-session-id',
'proxy-authorization',
'set-cookie',
'x-auth-token',
'x-api-key'
]);
/** Standard SSE endpoint path indicators */
export const MCP_SSE = {
ENDPOINT: '/sse',
ENDPOINT_QUERY: '/sse?',
ENDPOINT_SLASH: '/sse/'
} as const;
/** Human-readable labels for MCP transport types */
export const MCP_TRANSPORT_LABELS: Record<MCPTransportType, string> = {
@@ -96,8 +104,3 @@ export const MCP_TRANSPORT_ICONS: Record<MCPTransportType, Component> = {
[MCPTransportType.STREAMABLE_HTTP]: Globe,
[MCPTransportType.WEBSOCKET]: Zap
};
/** Standard SSE endpoint path indicators */
export const MCP_SSE_ENDPOINT = '/sse';
export const MCP_SSE_ENDPOINT_SLASH = '/sse/';
export const MCP_SSE_ENDPOINT_QUERY = '/sse?';
@@ -1,23 +1,24 @@
// Conversation filename constants
// Conversation exporter / filename constants
// Length of the trimmed conversation ID in the filename
export const EXPORT_CONV_ID_TRIM_LENGTH = 8;
// Maximum length of the sanitized conversation name snippet
export const EXPORT_CONV_NAME_SUFFIX_MAX_LENGTH = 20;
// Characters to keep in the ISO timestamp. 19 keeps 2026-01-01T00:00:00
export const ISO_TIMESTAMP_SLICE_LENGTH = 19;
export const EXPORT_CONV = {
// Producer marker carried by the session record of a JSONL export
HARNESS: 'llama.app',
// Length of the trimmed conversation ID in the filename
ID_TRIM_LENGTH: 8,
// Replacements to the ISO date for use in the export filename
ISO_DATE_TIME_SEPARATOR: 'T',
// Producer marker carried by the session record of a JSONL export
export const SESSION_HARNESS = 'llama.app';
ISO_DATE_TIME_SEPARATOR_REPLACEMENT: '_',
// Replacements for making the conversation title filename-friendly
export const NON_ALPHANUMERIC_REGEX = /[^a-z0-9]/gi;
export const EXPORT_CONV_NONALNUM_REPLACEMENT = '_';
export const MULTIPLE_UNDERSCORE_REGEX = /_+/g;
ISO_TIME_SEPARATOR: ':',
ISO_TIME_SEPARATOR_REPLACEMENT: '-',
// Characters to keep in the ISO timestamp. 19 keeps 2026-01-01T00:00:00
ISO_TIMESTAMP_SLICE: 19,
// Replacements to the ISO date for use in the export filename
export const ISO_DATE_TIME_SEPARATOR = 'T';
export const ISO_DATE_TIME_SEPARATOR_REPLACEMENT = '_';
export const ISO_TIME_SEPARATOR = ':';
export const ISO_TIME_SEPARATOR_REPLACEMENT = '-';
MULTIPLE_UNDERSCORE_REGEX: /_+/g,
// Maximum length of the sanitized conversation name snippet
NAME_SUFFIX_MAX_LENGTH: 20,
// Replacements for making the conversation title filename-friendly
NON_ALPHANUMERIC_REGEX: /[^a-z0-9]/gi,
NONALNUM_REPLACEMENT: '_'
} as const;
@@ -1,46 +1,43 @@
/** Sentinel value returned by `indexOf` when a substring is not found. */
export const MODEL_ID_NOT_FOUND = -1;
/** Separates `<org>` from `<model>` in a model ID, e.g. `org/ModelName`. */
export const MODEL_ID_ORG_SEPARATOR = '/';
/** Separates named segments within the model path, e.g. `ModelName-7B-GGUF`. */
export const MODEL_ID_SEGMENT_SEPARATOR = '-';
/** Separates the model path from the quantization tag, e.g. `model:Q4_K_M`. */
export const MODEL_ID_QUANTIZATION_SEPARATOR = ':';
/**
* Matches a quantization/precision segment, e.g. `Q4_K_M`, `IQ4_XS`, `F16`, `BF16`, `MXFP4`.
* Case-insensitive to handle both uppercase and lowercase inputs.
* Parsing of `org/ModelName[-tag][:quant]` style model IDs.
*/
export const MODEL_QUANTIZATION_SEGMENT_RE =
/^(I?Q\d+(_[A-Z0-9]+)*|F\d+|BF\d+|MXFP\d+(_[A-Z0-9]+)*)$/i;
/**
* Matches prefix for custom quantization types, e.g. `UD-Q8_K_XL`.
*/
export const MODEL_CUSTOM_QUANTIZATION_PREFIX_RE = /^UD$/i;
export const MODEL_ID = {
/**
* Matches an activated-parameter-count segment, e.g. `A10B`, `a2.4b`.
* The leading `A`/`a` distinguishes it from a regular params segment.
*/
ACTIVATED_PARAMS_RE: /^[Aa]\d+(\.\d+)?[BbMmKkTt]$/,
/**
* Matches a parameter-count segment, e.g. `7B`, `1.5b`, `120M`.
* The optional leading `E` covers effective-parameter sizes, e.g. Gemma's
* `E2B`/`E4B` (MatFormer models sized by resident params).
*/
export const MODEL_PARAMS_RE = /^[Ee]?\d+(\.\d+)?[BbMmKkTt]$/;
/** Matches prefix for custom quantization types, e.g. `UD-Q8_K_XL`. */
CUSTOM_QUANTIZATION_PREFIX_RE: /^UD$/i,
/** Container format segments to exclude from tags (every model uses these). */
IGNORED_SEGMENTS: new Set(['GGUF', 'GGML']),
/** Sentinel value returned by `indexOf` when a substring is not found. */
NOT_FOUND: -1,
/**
* Matches an activated-parameter-count segment, e.g. `A10B`, `a2.4b`.
* The leading `A`/`a` distinguishes it from a regular params segment.
*/
export const MODEL_ACTIVATED_PARAMS_RE = /^[Aa]\d+(\.\d+)?[BbMmKkTt]$/;
/** Separates `<org>` from `<model>` in a model ID, e.g. `org/ModelName`. */
ORG_SEPARATOR: '/',
/**
* Container format segments to exclude from tags (every model uses these).
*/
export const MODEL_IGNORED_SEGMENTS = new Set(['GGUF', 'GGML']);
/**
* Matches a parameter-count segment, e.g. `7B`, `1.5b`, `120M`.
* The optional leading `E` covers effective-parameter sizes, e.g. Gemma's
* `E2B`/`E4B` (MatFormer models sized by resident params).
*/
PARAMS_RE: /^[Ee]?\d+(\.\d+)?[BbMmKkTt]$/,
/**
* Matches a trailing weight file extension, e.g. `model.gguf` -> `model`.
*/
export const MODEL_WEIGHT_EXTENSION_RE = /\.(gguf|ggml)$/i;
/**
* Matches a quantization/precision segment, e.g. `Q4_K_M`, `IQ4_XS`, `F16`, `BF16`, `MXFP4`.
* Case-insensitive to handle both uppercase and lowercase inputs.
*/
QUANTIZATION_SEGMENT_RE: /^(I?Q\d+(_[A-Z0-9]+)*|F\d+|BF\d+|MXFP\d+(_[A-Z0-9]+)*)$/i,
/** Separates the model path from the quantization tag, e.g. `model:Q4_K_M`. */
QUANTIZATION_SEPARATOR: ':',
/** Separates named segments within the model path, e.g. `ModelName-7B-GGUF`. */
SEGMENT_SEPARATOR: '-',
/** Matches a trailing weight file extension, e.g. `model.gguf` -> `model`. */
WEIGHT_EXTENSION_RE: /\.(gguf|ggml)$/i
};
@@ -3,10 +3,7 @@ import { DEFAULT_MCP_CONFIG } from './mcp.constants';
import { ROUTES, SETTINGS_SECTION_SLUGS } from './routes.constants';
import { SETTINGS_KEYS } from './settings-keys.constants';
import { TITLE_GENERATION } from './title-generation.constants';
import {
FILE_GLOB_SEARCH_PICKERS_DEFAULT_SEARCH_DEPTH,
FILE_GLOB_SEARCH_PICKERS_MAX_SEARCH_DEPTH
} from './working-directory.constants';
import { FILE_GLOB_SEARCH_PICKERS } from './working-directory.constants';
import {
AlertTriangle,
Code,
@@ -105,14 +102,14 @@ const SETTINGS_REGISTRY: Record<string, SettingsSectionEntry> = {
type: SettingsFieldType.INPUT
},
{
defaultValue: FILE_GLOB_SEARCH_PICKERS_DEFAULT_SEARCH_DEPTH,
defaultValue: FILE_GLOB_SEARCH_PICKERS.DEFAULT_SEARCH_DEPTH,
help: 'How many directory levels below the working directory the @-mention file search descends. Larger values surface deeply nested files but take longer on large trees.',
isPositiveInteger: true,
key: SETTINGS_KEYS.MENTION_SEARCH_MAX_DEPTH,
label: 'Mention search depth',
max: FILE_GLOB_SEARCH_PICKERS_MAX_SEARCH_DEPTH,
max: FILE_GLOB_SEARCH_PICKERS.MAX_SEARCH_DEPTH,
min: 1,
placeholder: `${FILE_GLOB_SEARCH_PICKERS_DEFAULT_SEARCH_DEPTH}`,
placeholder: `${FILE_GLOB_SEARCH_PICKERS.DEFAULT_SEARCH_DEPTH}`,
section: SETTINGS_SECTION_SLUGS.AGENTIC,
type: SettingsFieldType.INPUT
}
@@ -1,49 +1,57 @@
export const SVG_WRAPPER_CLASS = 'svg-block-wrapper';
export const SVG_SCROLL_CONTAINER_CLASS = 'svg-scroll-container';
export const SVG_BLOCK_CLASS = 'svg-block';
export const SVG_LANGUAGE = 'svg';
export const XML_LANGUAGE = 'xml';
export const SVG_TAG_PREFIX = '<svg';
export const SVG_SOURCE_ATTR = 'data-svg-source';
export const SVG_ID_ATTR = 'data-svg-id';
export const SVG_RENDERED_ATTR = 'data-svg-rendered';
/**
* Hard size ceiling for a single inline svg block.
* Above this the source is left as raw text instead of being rendered.
* Constants for rendering svg code blocks inline.
*/
export const SVG_MAX_BYTES = 256 * 1024;
export const SVG = {
// CSS classes applied to the inline svg block and its chrome.
BLOCK_CLASS: 'svg-block',
/**
* Shadow root style for the zoom dialog svg. Lets the svg grow past its
* intrinsic size so pan and zoom have room to work.
*/
DIALOG_SHADOW_STYLE:
':host{display:inline-block}svg{min-height:min(50vh,12rem);min-width:min(80vw,20rem);max-width:none;max-height:none;height:auto;width:auto;display:block}',
ID_ATTR: 'data-svg-id',
/**
* DOMPurify config for untrusted svg coming from model output.
*
* foreignObject and script stay forbidden unconditionally, they are the only
* inline svg vectors that execute arbitrary html or js. Everything else is
* allowed for maximum rendering compatibility: href and xlink:href stay so
* use, image, a and animateMotion work, and DOMPurify still neutralizes
* javascript: and data: uri schemes natively. External resource refs are
* allowed by design on a local first tool, the user browser fetches them.
*
* The sanitized svg is always mounted inside a shadow root (see svg-shadow),
* so an author <style> stays scoped to that root and can not reach the page.
*/
export const SVG_SANITIZE_CONFIG = {
FORBID_TAGS: ['foreignObject', 'script'],
USE_PROFILES: { svg: true, svgFilters: true }
/**
* Shadow root style for an inline svg block. Mirrors the centered, padded
* sizing the light dom used before the svg moved behind a shadow boundary.
*/
INLINE_SHADOW_STYLE:
':host{display:block;width:100%;text-align:center}svg{display:block;margin:0 auto;width:auto;height:auto;max-width:100%;max-height:70vh;min-height:8rem;padding:3rem 1rem}',
// Languages that mark a code block as svg content.
LANGUAGE: 'svg',
/**
* Hard size ceiling for a single inline svg block.
* Above this the source is left as raw text instead of being rendered.
*/
MAX_BYTES: 256 * 1024,
RENDERED_ATTR: 'data-svg-rendered',
/**
* DOMPurify config for untrusted svg coming from model output.
*
* foreignObject and script stay forbidden unconditionally, they are the only
* inline svg vectors that execute arbitrary html or js. Everything else is
* allowed for maximum rendering compatibility: href and xlink:href stay so
* use, image, a and animateMotion work, and DOMPurify still neutralizes
* javascript: and data: uri schemes natively. External resource refs are
* allowed by design on a local first tool, the user browser fetches them.
*
* The sanitized svg is always mounted inside a shadow root (see svg-shadow),
* so an author <style> stays scoped to that root and can not reach the page.
*/
SANITIZE_CONFIG: {
FORBID_TAGS: ['foreignObject', 'script'],
USE_PROFILES: { svg: true, svgFilters: true }
},
SCROLL_CONTAINER_CLASS: 'svg-scroll-container',
// data-attributes used to stash per-block svg state on the DOM node.
SOURCE_ATTR: 'data-svg-source',
TAG_PREFIX: '<svg',
WRAPPER_CLASS: 'svg-block-wrapper',
XML_LANGUAGE: 'xml'
};
/**
* Shadow root style for an inline svg block. Mirrors the centered, padded
* sizing the light dom used before the svg moved behind a shadow boundary.
*/
export const SVG_INLINE_SHADOW_STYLE =
':host{display:block;width:100%;text-align:center}svg{display:block;margin:0 auto;width:auto;height:auto;max-width:100%;max-height:70vh;min-height:8rem;padding:3rem 1rem}';
/**
* Shadow root style for the zoom dialog svg. Lets the svg grow past its
* intrinsic size so pan and zoom have room to work.
*/
export const SVG_DIALOG_SHADOW_STYLE =
':host{display:inline-block}svg{min-height:min(50vh,12rem);min-width:min(80vw,20rem);max-width:none;max-height:none;height:auto;width:auto;display:block}';
@@ -6,44 +6,45 @@
* escaped (passed through literally) so a query never changes matching.
*/
export const GLOB_WILDCARD = '*';
/** Label shown for the working-directory picker / `/cwd` slash command. */
export const SET_WORKING_DIRECTORY_LABEL = 'Set working directory';
/** Character that starts and ends a glob character-class fragment. */
export const GLOB_RANGE_OPEN = '[';
export const GLOB_RANGE_CLOSE = ']';
export const GLOB = {
/** `C:`, the drive part of a Windows absolute path. */
DRIVE_PREFIX_REGEX: /^[A-Za-z]:/,
/** `C:` or `C:/`, the root of a Windows drive-absolute path. */
DRIVE_ROOT_REGEX: /^[A-Za-z]:\/?/,
/** Character that ends a glob character-class fragment. */
RANGE_CLOSE: ']',
/** Character that starts a glob character-class fragment. */
RANGE_OPEN: '[',
/** Query characters that carry glob meaning and are passed through literally. */
SPECIAL_CHARS: '*?[]',
/** `//host/share` or `//host/share/`, the root of a UNC path. */
UNC_ROOT_REGEX: /^\/\/[^/]+\/[^/]+\/?/,
/** Wildcard character in a glob pattern. */
WILDCARD: '*',
/** Separator Windows accepts alongside `/`, and a legal POSIX filename character. */
WINDOWS_SEPARATOR: '\\'
} as const;
/** Query characters that carry glob meaning and are passed through literally. */
export const GLOB_SPECIAL_CHARS = '*?[]';
export const SEARCH = {
// Search tuning for the picker's file_glob_search calls.
DEBOUNCE_MS: 180,
LIMIT: 100,
// Home-relative globs descend deeper than path navigation, which only
// needs the direct children of the parent.
MAX_DEPTH: 6,
MAX_RESULTS_SHOWN: 20,
NATIVE_LIMIT: 20,
// Native folder-picker resolution searches a shallow, bounded window.
NATIVE_MAX_DEPTH: 4,
PATH_NAV_MAX_DEPTH: 1
} as const;
/** Separator Windows accepts alongside `/`, and a legal POSIX filename character. */
export const WINDOWS_SEPARATOR = '\\';
/** `C:`, the drive part of a Windows absolute path. */
export const DRIVE_PREFIX_REGEX = /^[A-Za-z]:/;
/** `C:` or `C:/`, the root of a Windows drive-absolute path. */
export const DRIVE_ROOT_REGEX = /^[A-Za-z]:\/?/;
/** `//host/share` or `//host/share/`, the root of a UNC path. */
export const UNC_ROOT_REGEX = /^\/\/[^/]+\/[^/]+\/?/;
// Search tuning for the picker's file_glob_search calls.
export const SEARCH_DEBOUNCE_MS = 180;
export const SEARCH_LIMIT = 100;
export const MAX_RESULTS_SHOWN = 20;
// Home-relative globs descend deeper than path navigation, which only
// needs the direct children of the parent.
export const SEARCH_MAX_DEPTH = 6;
export const PATH_NAV_MAX_DEPTH = 1;
// Native folder-picker resolution searches a shallow, bounded window.
export const NATIVE_MAX_DEPTH = 4;
export const NATIVE_LIMIT = 20;
/** Upper bound the mention search depth setting accepts. The server itself imposes no depth cap (0 = unlimited); this is a UI sanity bound. */
export const FILE_GLOB_SEARCH_PICKERS_MAX_SEARCH_DEPTH = 32;
/** Depth the pickers fall back to when the user setting is invalid. */
export const FILE_GLOB_SEARCH_PICKERS_DEFAULT_SEARCH_DEPTH = 10;
export const FILE_GLOB_SEARCH_PICKERS = {
/** Depth the pickers fall back to when the user setting is invalid. */
DEFAULT_SEARCH_DEPTH: 10,
/** Upper bound the mention search depth setting accepts. The server itself imposes no depth cap (0 = unlimited); this is a UI sanity bound. */
MAX_SEARCH_DEPTH: 32
} as const;
+5 -1
View File
@@ -1324,7 +1324,11 @@ export class ChatService {
for (const legacyContextFile of legacyContextFiles) {
contentParts.push({
text: formatAttachmentText(AttachmentLabel.FILE, legacyContextFile.name, legacyContextFile.content),
text: formatAttachmentText(
AttachmentLabel.FILE,
legacyContextFile.name,
legacyContextFile.content
),
type: ContentPartType.TEXT
});
}
+7 -7
View File
@@ -13,12 +13,12 @@ import type {
Tool
} from '@modelcontextprotocol/sdk/types.js';
import {
CORS_PROXY,
CORS_PROXY_ENDPOINT,
CORS_PROXY_HEADER_PREFIX,
DEFAULT_CLIENT_VERSION,
DEFAULT_IMAGE_MIME_TYPE,
DEFAULT_MCP_CONFIG,
MCP_PARTIAL_REDACT_HEADERS
HEADERS
} from '$lib/constants';
import {
MCPConnectionPhase,
@@ -120,7 +120,7 @@ export class MCPService {
const details: DiagnosticRequestDetails = {
body: summarizeRequestBody(body),
credentials: init?.credentials ?? baseInit.credentials,
headers: sanitizeHeaders(requestHeaders, extraRedactedHeaders, MCP_PARTIAL_REDACT_HEADERS),
headers: sanitizeHeaders(requestHeaders, extraRedactedHeaders, HEADERS.PARTIAL_REDACT),
method: getRequestMethod(input, init, baseInit).toUpperCase(),
mode: init?.mode ?? baseInit.mode,
url: getRequestUrl(input)
@@ -141,8 +141,8 @@ export class MCPService {
) {
for (const [key, value] of new Headers(headers).entries()) {
const proxiedKey =
useProxy && !key.toLowerCase().startsWith(CORS_PROXY_HEADER_PREFIX)
? `${CORS_PROXY_HEADER_PREFIX}${key}`
useProxy && !key.toLowerCase().startsWith(CORS_PROXY.HEADER_PREFIX)
? `${CORS_PROXY.HEADER_PREFIX}${key}`
: key;
requestHeaders.set(proxiedKey, value);
@@ -361,7 +361,7 @@ export class MCPService {
{
response: {
durationMs,
headers: sanitizeHeaders(response.headers, undefined, MCP_PARTIAL_REDACT_HEADERS),
headers: sanitizeHeaders(response.headers, undefined, HEADERS.PARTIAL_REDACT),
status: response.status,
statusText: response.statusText,
url
@@ -751,7 +751,7 @@ export class MCPService {
headers: sanitizeHeaders(
serverConfig.headers,
Object.keys(serverConfig.headers ?? {}),
MCP_PARTIAL_REDACT_HEADERS
HEADERS.PARTIAL_REDACT
),
serverName,
transportType,
+19 -31
View File
@@ -1,16 +1,4 @@
import {
API_MODELS,
MODEL_ACTIVATED_PARAMS_RE,
MODEL_CUSTOM_QUANTIZATION_PREFIX_RE,
MODEL_ID_NOT_FOUND,
MODEL_ID_ORG_SEPARATOR,
MODEL_ID_QUANTIZATION_SEPARATOR,
MODEL_ID_SEGMENT_SEPARATOR,
MODEL_IGNORED_SEGMENTS,
MODEL_PARAMS_RE,
MODEL_QUANTIZATION_SEGMENT_RE,
MODEL_WEIGHT_EXTENSION_RE
} from '$lib/constants';
import { API_MODELS, MODEL_ID } from '$lib/constants';
import { ServerModelStatus } from '$lib/enums';
import type { ParsedModelId } from '$lib/types/models';
import { apiFetch, apiPost, normalizeModelName } from '$lib/utils';
@@ -142,13 +130,13 @@ export class ModelsService {
};
// strip directory path and weight extension so a bare `-m /path/file.gguf`
// parses like a clean repo id; the HF `org/model` form is preserved
const source = normalizeModelName(modelId).replace(MODEL_WEIGHT_EXTENSION_RE, '');
const source = normalizeModelName(modelId).replace(MODEL_ID.WEIGHT_EXTENSION_RE, '');
// 1. Extract colon-separated quantization (e.g. `model:Q4_K_M`)
const colonIdx = source.indexOf(MODEL_ID_QUANTIZATION_SEPARATOR);
const colonIdx = source.indexOf(MODEL_ID.QUANTIZATION_SEPARATOR);
let modelPath: string;
if (colonIdx !== MODEL_ID_NOT_FOUND) {
if (colonIdx !== MODEL_ID.NOT_FOUND) {
result.quantization = source.slice(colonIdx + 1) || null;
modelPath = source.slice(0, colonIdx);
} else {
@@ -156,11 +144,11 @@ export class ModelsService {
}
// 2. Extract org name (e.g. `org/model` -> org = "org")
const slashIdx = modelPath.indexOf(MODEL_ID_ORG_SEPARATOR);
const slashIdx = modelPath.indexOf(MODEL_ID.ORG_SEPARATOR);
let modelStr: string;
if (slashIdx !== MODEL_ID_NOT_FOUND) {
if (slashIdx !== MODEL_ID.NOT_FOUND) {
result.orgName = modelPath.slice(0, slashIdx);
modelStr = modelPath.slice(slashIdx + 1);
} else {
@@ -170,16 +158,16 @@ export class ModelsService {
// 3. Handle dot-separated quantization (e.g. `model-name.Q4_K_M`)
const dotIdx = modelStr.lastIndexOf('.');
if (dotIdx !== MODEL_ID_NOT_FOUND && !result.quantization) {
if (dotIdx !== MODEL_ID.NOT_FOUND && !result.quantization) {
const afterDot = modelStr.slice(dotIdx + 1);
if (MODEL_QUANTIZATION_SEGMENT_RE.test(afterDot)) {
if (MODEL_ID.QUANTIZATION_SEGMENT_RE.test(afterDot)) {
result.quantization = afterDot;
modelStr = modelStr.slice(0, dotIdx);
}
}
const segments = modelStr.split(MODEL_ID_SEGMENT_SEPARATOR);
const segments = modelStr.split(MODEL_ID.SEGMENT_SEPARATOR);
// 4. Detect trailing quantization from dash-separated segments
// Handle UD-prefixed quantization (e.g. `UD-Q8_K_XL`) and
@@ -188,8 +176,8 @@ export class ModelsService {
const last = segments[segments.length - 1];
const secondLast = segments.length > 2 ? segments[segments.length - 2] : null;
if (MODEL_QUANTIZATION_SEGMENT_RE.test(last)) {
if (secondLast && MODEL_CUSTOM_QUANTIZATION_PREFIX_RE.test(secondLast)) {
if (MODEL_ID.QUANTIZATION_SEGMENT_RE.test(last)) {
if (secondLast && MODEL_ID.CUSTOM_QUANTIZATION_PREFIX_RE.test(secondLast)) {
result.quantization = `${secondLast}-${last}`;
segments.splice(segments.length - 2, 2);
} else {
@@ -200,33 +188,33 @@ export class ModelsService {
}
// 5. Find params and activated params
let paramsIdx = MODEL_ID_NOT_FOUND;
let activatedParamsIdx = MODEL_ID_NOT_FOUND;
let paramsIdx = MODEL_ID.NOT_FOUND;
let activatedParamsIdx = MODEL_ID.NOT_FOUND;
for (let i = 0; i < segments.length; i++) {
const seg = segments[i];
if (paramsIdx === MODEL_ID_NOT_FOUND && MODEL_PARAMS_RE.test(seg)) {
if (paramsIdx === MODEL_ID.NOT_FOUND && MODEL_ID.PARAMS_RE.test(seg)) {
paramsIdx = i;
result.params = seg.toUpperCase();
} else if (paramsIdx !== MODEL_ID_NOT_FOUND && MODEL_ACTIVATED_PARAMS_RE.test(seg)) {
} else if (paramsIdx !== MODEL_ID.NOT_FOUND && MODEL_ID.ACTIVATED_PARAMS_RE.test(seg)) {
activatedParamsIdx = i;
result.activatedParams = seg.toUpperCase();
}
}
// 6. Model name = segments before params; tags = remaining segments after params
const pivotIdx = paramsIdx !== MODEL_ID_NOT_FOUND ? paramsIdx : segments.length;
const pivotIdx = paramsIdx !== MODEL_ID.NOT_FOUND ? paramsIdx : segments.length;
result.modelName = segments.slice(0, pivotIdx).join(MODEL_ID_SEGMENT_SEPARATOR) || null;
result.modelName = segments.slice(0, pivotIdx).join(MODEL_ID.SEGMENT_SEPARATOR) || null;
if (paramsIdx !== MODEL_ID_NOT_FOUND) {
if (paramsIdx !== MODEL_ID.NOT_FOUND) {
result.tags = segments.slice(paramsIdx + 1).filter((_, relIdx) => {
const absIdx = paramsIdx + 1 + relIdx;
if (absIdx === activatedParamsIdx) return false;
return !MODEL_IGNORED_SEGMENTS.has(segments[absIdx].toUpperCase());
return !MODEL_ID.IGNORED_SEGMENTS.has(segments[absIdx].toUpperCase());
});
}
+6 -7
View File
@@ -12,9 +12,8 @@
*/
import {
CONTENT_TYPE_HEADER,
INACTIVE_CONVERSATION_STATE_MAX_AGE_MS,
MAX_INACTIVE_CONVERSATION_STATES,
HEADERS,
INACTIVE_CONVERSATION,
STREAM_RESUME_RETRY_MS,
SYSTEM_MESSAGE_PLACEHOLDER,
TITLE_GENERATION
@@ -234,7 +233,7 @@ class ChatStore {
// POST the one conv id we are probing
listResp = await fetch(`./v1/streams/lookup`, {
body: JSON.stringify({ conversation_ids: [convId] }),
headers: { ...getAuthHeaders(), [CONTENT_TYPE_HEADER]: MimeTypeApplication.JSON },
headers: { ...getAuthHeaders(), [HEADERS.CONTENT_TYPE]: MimeTypeApplication.JSON },
method: 'POST'
});
} catch (e) {
@@ -808,7 +807,7 @@ class ChatStore {
try {
const resp = await fetch('./v1/streams/lookup', {
body: JSON.stringify({ conversation_ids: lookupIds }),
headers: { ...getAuthHeaders(), [CONTENT_TYPE_HEADER]: MimeTypeApplication.JSON },
headers: { ...getAuthHeaders(), [HEADERS.CONTENT_TYPE]: MimeTypeApplication.JSON },
method: 'POST'
});
@@ -930,8 +929,8 @@ class ChatStore {
for (const { convId, lastAccessed } of cleanupCandidates) {
if (
cleanupCandidates.length - cleanedUp > MAX_INACTIVE_CONVERSATION_STATES ||
now - lastAccessed > INACTIVE_CONVERSATION_STATE_MAX_AGE_MS
cleanupCandidates.length - cleanedUp > INACTIVE_CONVERSATION.MAX_STATES ||
now - lastAccessed > INACTIVE_CONVERSATION.MAX_AGE_MS
) {
this.cleanupConversationState(convId);
cleanedUp++;
+10 -20
View File
@@ -21,20 +21,10 @@
import { browser } from '$app/environment';
import { goto } from '$app/navigation';
import {
EXPORT_CONV_ID_TRIM_LENGTH,
EXPORT_CONV_NAME_SUFFIX_MAX_LENGTH,
EXPORT_CONV_NONALNUM_REPLACEMENT,
ISO_DATE_TIME_SEPARATOR,
ISO_DATE_TIME_SEPARATOR_REPLACEMENT,
ISO_TIME_SEPARATOR,
ISO_TIME_SEPARATOR_REPLACEMENT,
ISO_TIMESTAMP_SLICE_LENGTH,
MULTIPLE_UNDERSCORE_REGEX,
EXPORT_CONV,
NEWLINE,
NON_ALPHANUMERIC_REGEX,
REASONING_EFFORT_DEFAULT_LOCALSTORAGE_KEY,
ROUTES,
SESSION_HARNESS,
ZIP_MAGIC
} from '$lib/constants';
import {
@@ -1002,18 +992,18 @@ class ConversationsStore {
): string {
const conversationName = (conversation.name ?? '').trim().toLowerCase();
const sanitizedName = conversationName
.replace(NON_ALPHANUMERIC_REGEX, EXPORT_CONV_NONALNUM_REPLACEMENT)
.replace(MULTIPLE_UNDERSCORE_REGEX, '_')
.substring(0, EXPORT_CONV_NAME_SUFFIX_MAX_LENGTH);
.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, ISO_TIMESTAMP_SLICE_LENGTH);
const iso = referenceDate.toISOString().slice(0, EXPORT_CONV.ISO_TIMESTAMP_SLICE);
const formattedDate = iso
.replace(ISO_DATE_TIME_SEPARATOR, ISO_DATE_TIME_SEPARATOR_REPLACEMENT)
.replaceAll(ISO_TIME_SEPARATOR, ISO_TIME_SEPARATOR_REPLACEMENT);
const trimmedConvId = conversation.id?.slice(0, EXPORT_CONV_ID_TRIM_LENGTH) ?? '';
.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}`;
}
@@ -1028,7 +1018,7 @@ class ConversationsStore {
serializeSessionToJsonl(data: ExportedConversation): string {
const { conv, messages } = data;
const sessionLine = JSON.stringify({
harness: SESSION_HARNESS,
harness: EXPORT_CONV.HARNESS,
type: SessionRecordType.SESSION,
...conv
});
@@ -1209,7 +1199,7 @@ class ConversationsStore {
files[entryName] = strToU8(this.serializeSessionToJsonl(session));
}
const archiveName = `${new Date().toISOString().split(ISO_DATE_TIME_SEPARATOR)[0]}_conversations${FileExtensionText.ZIP}`;
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 });
@@ -13,8 +13,7 @@
import {
BINARY_CONTENT_LABEL,
MCP_RESOURCE_ATTACHMENT_ID_PREFIX,
MCP_RESOURCE_CACHE_MAX_ENTRIES,
MCP_RESOURCE_CACHE_TTL_MS,
MCP_RESOURCE_CACHE,
NEWLINE,
RESOURCE_UNKNOWN_TYPE
} from '$lib/constants';
@@ -251,7 +250,7 @@ class MCPResourceStore {
*/
cacheResourceContent(resource: MCPResourceInfo, content: MCPResourceContent[]): void {
// Enforce cache size limit
if (this._cachedResources.size >= MCP_RESOURCE_CACHE_MAX_ENTRIES) {
if (this._cachedResources.size >= MCP_RESOURCE_CACHE.MAX_ENTRIES) {
// Remove oldest entry
const oldestKey = this._cachedResources.keys().next().value;
@@ -280,7 +279,7 @@ class MCPResourceStore {
// Check if cache is still valid
const age = Date.now() - cached.fetchedAt.getTime();
if (age > MCP_RESOURCE_CACHE_TTL_MS && !cached.subscribed) {
if (age > MCP_RESOURCE_CACHE.TTL_MS && !cached.subscribed) {
// Cache expired and not subscribed, remove it
this._cachedResources.delete(uri);
+7 -10
View File
@@ -26,14 +26,11 @@ import type { ListChangedHandlers } from '@modelcontextprotocol/sdk/types.js';
import { browser } from '$app/environment';
import { SETTINGS_KEYS } from '$lib/constants';
import {
DEFAULT_CACHE_TTL_MS,
CACHE,
DEFAULT_MCP_CONFIG,
EXPECTED_THEMED_ICON_PAIR_COUNT,
MCP_ALLOWED_ICON_MIME_TYPES,
MCP_RECONNECT_ATTEMPT_TIMEOUT_MS,
MCP_RECONNECT_BACKOFF_MULTIPLIER,
MCP_RECONNECT_INITIAL_DELAY,
MCP_RECONNECT_MAX_DELAY,
MCP_RECONNECT,
MCP_SERVER_ID_PREFIX
} from '$lib/constants';
import {
@@ -917,7 +914,7 @@ class MCPStore {
}
this.reconnectingServers.add(serverName);
let backoff = MCP_RECONNECT_INITIAL_DELAY;
let backoff = MCP_RECONNECT.INITIAL_DELAY;
// Flag set by the phase callback when a DISCONNECTED event fires while
// reconnectingServers still holds this server (see JSDoc above).
let needsReconnect = false;
@@ -936,10 +933,10 @@ class MCPStore {
() =>
reject(
new Error(
`Reconnect attempt timed out after ${MCP_RECONNECT_ATTEMPT_TIMEOUT_MS}ms`
`Reconnect attempt timed out after ${MCP_RECONNECT.ATTEMPT_TIMEOUT_MS}ms`
)
),
MCP_RECONNECT_ATTEMPT_TIMEOUT_MS
MCP_RECONNECT.ATTEMPT_TIMEOUT_MS
)
);
@@ -980,7 +977,7 @@ class MCPStore {
break;
} catch (error) {
console.warn(`[MCPStore][${serverName}] Reconnection failed:`, error);
backoff = Math.min(backoff * MCP_RECONNECT_BACKOFF_MULTIPLIER, MCP_RECONNECT_MAX_DELAY);
backoff = Math.min(backoff * MCP_RECONNECT.BACKOFF_MULTIPLIER, MCP_RECONNECT.MAX_DELAY);
}
}
} finally {
@@ -1737,7 +1734,7 @@ class MCPStore {
// Cache is valid for 5 minutes
const age = Date.now() - serverRes.lastFetched.getTime();
return age < DEFAULT_CACHE_TTL_MS;
return age < CACHE.DEFAULT_TTL_MS;
});
if (allServersCached) {
+3 -4
View File
@@ -2,8 +2,7 @@ import { base } from '$app/paths';
import {
API_MODELS,
FAVORITE_MODELS_LOCALSTORAGE_KEY,
MODEL_PROPS_CACHE_MAX_ENTRIES,
MODEL_PROPS_CACHE_TTL_MS,
MODEL_PROPS_CACHE,
SSE_DATA_PREFIX,
SSE_LINE_SEPARATOR,
SSE_RECORD_SEPARATOR
@@ -81,8 +80,8 @@ class ModelsStore {
* TTL: 10 minutes — props don't change frequently.
*/
private modelPropsCache = new TTLCache<string, ApiLlamaCppServerProps>({
maxEntries: MODEL_PROPS_CACHE_MAX_ENTRIES,
ttlMs: MODEL_PROPS_CACHE_TTL_MS
maxEntries: MODEL_PROPS_CACHE.MAX_ENTRIES,
ttlMs: MODEL_PROPS_CACHE.TTL_MS
});
private modelPropsFetching = $state<Set<string>>(new Set());
+13 -21
View File
@@ -1,17 +1,9 @@
import {
ATTACHMENT_SAVED_REGEX,
MARKDOWN_ATX_HEADING_REGEX,
MARKDOWN_BLOCKQUOTE_REGEX,
MARKDOWN_BOLD_REGEX,
MARKDOWN_CODE_FENCE_REGEX,
MARKDOWN_LINK_REGEX,
MARKDOWN_LIST_BULLET_REGEX,
MARKDOWN_LIST_NUMBERED_REGEX,
MARKDOWN_TABLE_SEPARATOR_REGEX,
MARKDOWN,
NEWLINE,
REASONING_TAGS,
SEARCH_SUMMARY_SEPARATOR,
SEARCH_SUMMARY_TOTAL_REGEX,
SEARCH_SUMMARY,
TOOL_RESULT_JSON_OPEN_REGEX
} from '$lib/constants';
import {
@@ -283,11 +275,11 @@ export function splitSearchSummaryList(
text: string,
captureTotal: (n: number) => void
): { lines: string[] } {
const separatorIndex = text.indexOf(SEARCH_SUMMARY_SEPARATOR);
const separatorIndex = text.indexOf(SEARCH_SUMMARY.SEPARATOR);
const matchesText = separatorIndex === -1 ? text : text.slice(0, separatorIndex);
const summaryText =
separatorIndex === -1 ? '' : text.slice(separatorIndex + SEARCH_SUMMARY_SEPARATOR.length);
const totalMatch = summaryText.match(SEARCH_SUMMARY_TOTAL_REGEX);
separatorIndex === -1 ? '' : text.slice(separatorIndex + SEARCH_SUMMARY.SEPARATOR.length);
const totalMatch = summaryText.match(SEARCH_SUMMARY.TOTAL_REGEX);
if (totalMatch) {
captureTotal(parseInt(totalMatch[1], 10));
@@ -411,31 +403,31 @@ export function classifyToolResult(content: string | undefined): ToolResultKind
*/
function looksLikeMarkdown(content: string): boolean {
// Code fences are unambiguous - triple backticks or tildes at line start.
if (MARKDOWN_CODE_FENCE_REGEX.test(content)) return true;
if (MARKDOWN.CODE_FENCE_REGEX.test(content)) return true;
const lines = content.split(NEWLINE);
for (const line of lines) {
if (MARKDOWN_ATX_HEADING_REGEX.test(line)) return true;
if (MARKDOWN.ATX_HEADING_REGEX.test(line)) return true;
if (MARKDOWN_BLOCKQUOTE_REGEX.test(line)) return true;
if (MARKDOWN.BLOCKQUOTE_REGEX.test(line)) return true;
if (MARKDOWN_LIST_BULLET_REGEX.test(line)) return true;
if (MARKDOWN.LIST_BULLET_REGEX.test(line)) return true;
if (MARKDOWN_LIST_NUMBERED_REGEX.test(line)) return true;
if (MARKDOWN.LIST_NUMBERED_REGEX.test(line)) return true;
}
// Inline structural markers anywhere in the body.
if (MARKDOWN_LINK_REGEX.test(content)) return true;
if (MARKDOWN.LINK_REGEX.test(content)) return true;
if (MARKDOWN_BOLD_REGEX.test(content)) return true;
if (MARKDOWN.BOLD_REGEX.test(content)) return true;
// Tables: a pipe-bearing header line followed by a separator row.
if (lines.length >= 2) {
const head = lines[0];
const sep = lines[1];
if (head.includes('|') && MARKDOWN_TABLE_SEPARATOR_REGEX.test(sep)) return true;
if (head.includes('|') && MARKDOWN.TABLE_SEPARATOR_REGEX.test(sep)) return true;
}
return false;
+8 -14
View File
@@ -1,11 +1,5 @@
import { redactValue } from './redact';
import {
AUTHORIZATION_HEADER,
BEARER_PREFIX,
CONTENT_TYPE_HEADER,
CORS_PROXY_HEADER_PREFIX,
REDACTED_HEADERS
} from '$lib/constants';
import { CORS_PROXY, HEADERS } from '$lib/constants';
import { MimeTypeApplication } from '$lib/enums';
import { config } from '$lib/stores/settings.svelte';
@@ -17,7 +11,7 @@ export function getAuthHeaders(): Record<string, string> {
const currentConfig = config();
const apiKey = currentConfig.apiKey?.toString().trim();
return apiKey ? { [AUTHORIZATION_HEADER]: `${BEARER_PREFIX}${apiKey}` } : {};
return apiKey ? { [HEADERS.AUTHORIZATION]: `${HEADERS.BEARER}${apiKey}` } : {};
}
/**
@@ -25,14 +19,14 @@ export function getAuthHeaders(): Record<string, string> {
*/
export function getJsonHeaders(): Record<string, string> {
return {
[CONTENT_TYPE_HEADER]: MimeTypeApplication.JSON,
[HEADERS.CONTENT_TYPE]: MimeTypeApplication.JSON,
...getAuthHeaders()
};
}
/**
* Sanitize HTTP headers by redacting sensitive values.
* Known sensitive headers (from REDACTED_HEADERS) and any extra headers
* Known sensitive headers (from HEADERS.REDACTED) and any extra headers
* specified by the caller are fully redacted. Headers listed in
* `partialRedactHeaders` are partially redacted, showing only the
* specified number of trailing characters.
@@ -59,8 +53,8 @@ export function sanitizeHeaders(
for (const [key, value] of normalized.entries()) {
const normalizedKey = key.toLowerCase();
const unproxiedKey = normalizedKey.startsWith(CORS_PROXY_HEADER_PREFIX)
? normalizedKey.slice(CORS_PROXY_HEADER_PREFIX.length)
const unproxiedKey = normalizedKey.startsWith(CORS_PROXY.HEADER_PREFIX)
? normalizedKey.slice(CORS_PROXY.HEADER_PREFIX.length)
: normalizedKey;
const partialChars =
partialRedactHeaders?.get(normalizedKey) ?? partialRedactHeaders?.get(unproxiedKey);
@@ -68,8 +62,8 @@ export function sanitizeHeaders(
if (partialChars !== undefined) {
sanitized[key] = redactValue(value, partialChars);
} else if (
REDACTED_HEADERS.has(normalizedKey) ||
REDACTED_HEADERS.has(unproxiedKey) ||
HEADERS.REDACTED.has(normalizedKey) ||
HEADERS.REDACTED.has(unproxiedKey) ||
redactedHeaders.has(normalizedKey) ||
redactedHeaders.has(unproxiedKey)
) {
+3 -3
View File
@@ -1,7 +1,7 @@
import { error } from '@sveltejs/kit';
import { browser } from '$app/environment';
import { base } from '$app/paths';
import { AUTHORIZATION_HEADER, BEARER_PREFIX, CONTENT_TYPE_HEADER } from '$lib/constants';
import { HEADERS } from '$lib/constants';
import { MimeTypeApplication } from '$lib/enums';
import { config } from '$lib/stores/settings.svelte';
@@ -18,14 +18,14 @@ export async function validateApiKey(fetch: typeof globalThis.fetch): Promise<vo
try {
const headers: Record<string, string> = {
[CONTENT_TYPE_HEADER]: MimeTypeApplication.JSON
[HEADERS.CONTENT_TYPE]: MimeTypeApplication.JSON
};
// Probe /props even without a stored key: on a server started with
// --api-key the unauthenticated request returns 401 and surfaces the
// API key splash, which is the onboarding path for entering the key.
if (apiKey) {
headers[AUTHORIZATION_HEADER] = `${BEARER_PREFIX}${apiKey}`;
headers[HEADERS.AUTHORIZATION] = `${HEADERS.BEARER}${apiKey}`;
}
const response = await fetch(`${base}/props`, { headers });
+5 -5
View File
@@ -1,4 +1,4 @@
import { DEFAULT_CACHE_MAX_ENTRIES, DEFAULT_CACHE_TTL_MS } from '$lib/constants';
import { CACHE } from '$lib/constants';
/**
* TTL Cache - Time-To-Live cache implementation for memory optimization
@@ -36,8 +36,8 @@ export class TTLCache<K extends string, V> {
private readonly onEvict?: (key: string, value: unknown) => void;
constructor(options: TTLCacheOptions = {}) {
this.ttlMs = options.ttlMs ?? DEFAULT_CACHE_TTL_MS;
this.maxEntries = options.maxEntries ?? DEFAULT_CACHE_MAX_ENTRIES;
this.ttlMs = options.ttlMs ?? CACHE.DEFAULT_TTL_MS;
this.maxEntries = options.maxEntries ?? CACHE.DEFAULT_MAX_ENTRIES;
this.onEvict = options.onEvict;
}
@@ -217,8 +217,8 @@ export class ReactiveTTLMap<K extends string, V> {
private readonly maxEntries: number;
constructor(options: TTLCacheOptions = {}) {
this.ttlMs = options.ttlMs ?? DEFAULT_CACHE_TTL_MS;
this.maxEntries = options.maxEntries ?? DEFAULT_CACHE_MAX_ENTRIES;
this.ttlMs = options.ttlMs ?? CACHE.DEFAULT_TTL_MS;
this.maxEntries = options.maxEntries ?? CACHE.DEFAULT_MAX_ENTRIES;
}
get(key: K): V | null {
+2 -2
View File
@@ -1,5 +1,5 @@
import { getJpegOrientationFromDataURL, isJpegMimeType } from './jpeg-orientation';
import { BASE64_IMAGE_URI_REGEX, MEGAPIXELS_TO_PIXELS } from '$lib/constants';
import { BASE64_IMAGE_URI_REGEX, IMAGE } from '$lib/constants';
import { MimeTypeImage } from '$lib/enums';
/**
@@ -50,7 +50,7 @@ export function capImageDataURLSize(
const targetWidth = img.naturalWidth;
const targetHeight = img.naturalHeight;
const totalPixels = targetWidth * targetHeight;
const maxPixels = Math.floor(maxMegapixels * MEGAPIXELS_TO_PIXELS);
const maxPixels = Math.floor(maxMegapixels * IMAGE.MEGAPIXELS_TO_PIXELS);
if (maxPixels > 0 && totalPixels > maxPixels) {
const scaleFactor = Math.sqrt(maxPixels / totalPixels);
+11 -16
View File
@@ -1,14 +1,4 @@
import {
AMPERSAND_REGEX,
DEFAULT_LANGUAGE,
FENCE_PATTERN,
GT_REGEX,
LANG_PATTERN,
LT_REGEX,
NEWLINE,
TRIM_LEADING_PADDING_REGEX,
TRIM_TRAILING_PADDING_REGEX
} from '$lib/constants';
import { CODE_BLOCK, NEWLINE } from '$lib/constants';
import hljs from 'highlight.js';
export interface IncompleteCodeBlock {
@@ -81,11 +71,16 @@ export function splitGluedClosingCodeFences(markdown: string): string {
* so internal blank lines are still rendered as such.
*/
function trimCodePadding(code: string): string {
return code.replace(TRIM_LEADING_PADDING_REGEX, '').replace(TRIM_TRAILING_PADDING_REGEX, '');
return code
.replace(CODE_BLOCK.TRIM_LEADING_PADDING_REGEX, '')
.replace(CODE_BLOCK.TRIM_TRAILING_PADDING_REGEX, '');
}
function escapeCode(code: string): string {
return code.replace(AMPERSAND_REGEX, '&amp;').replace(LT_REGEX, '&lt;').replace(GT_REGEX, '&gt;');
return code
.replace(CODE_BLOCK.AMPERSAND_REGEX, '&amp;')
.replace(CODE_BLOCK.LT_REGEX, '&lt;')
.replace(CODE_BLOCK.GT_REGEX, '&gt;');
}
/** Bounded cache for highlightCode results. */
@@ -152,7 +147,7 @@ export { trimCodePadding };
export function detectIncompleteCodeBlock(markdown: string): IncompleteCodeBlock | null {
// Count all code fences in the markdown
// A code block is incomplete if there's an odd number of ``` fences
const fencePattern = new RegExp(FENCE_PATTERN.source, FENCE_PATTERN.flags);
const fencePattern = new RegExp(CODE_BLOCK.FENCE_PATTERN.source, CODE_BLOCK.FENCE_PATTERN.flags);
const fences: number[] = [];
let fenceMatch;
@@ -174,8 +169,8 @@ export function detectIncompleteCodeBlock(markdown: string): IncompleteCodeBlock
const openingIndex = fences[fences.length - 1];
const afterOpening = markdown.slice(openingIndex + 3);
// Extract language and code content
const langMatch = afterOpening.match(LANG_PATTERN);
const language = langMatch?.[1] || DEFAULT_LANGUAGE;
const langMatch = afterOpening.match(CODE_BLOCK.LANG_PATTERN);
const language = langMatch?.[1] || CODE_BLOCK.DEFAULT_LANGUAGE;
const codeStartIndex = openingIndex + 3 + (langMatch?.[0]?.length ?? 0);
const code = markdown.slice(codeStartIndex);
+3 -7
View File
@@ -3,11 +3,7 @@
*/
import { base } from '$app/paths';
import {
CORS_PROXY_ENDPOINT,
CORS_PROXY_HEADER_PREFIX,
CORS_PROXY_URL_PARAM
} from '$lib/constants';
import { CORS_PROXY, CORS_PROXY_ENDPOINT } from '$lib/constants';
/**
* Build a proxied URL that routes through llama-server's CORS proxy.
@@ -18,7 +14,7 @@ export function buildProxiedUrl(targetUrl: string): URL {
const proxyPath = `${base}${CORS_PROXY_ENDPOINT}`;
const proxyUrl = new URL(proxyPath, window.location.origin);
proxyUrl.searchParams.set(CORS_PROXY_URL_PARAM, targetUrl);
proxyUrl.searchParams.set(CORS_PROXY.URL_PARAM, targetUrl);
return proxyUrl;
}
@@ -32,7 +28,7 @@ export function buildProxiedHeaders(headers: Record<string, string>): Record<str
const proxiedHeaders: Record<string, string> = {};
for (const [key, value] of Object.entries(headers)) {
proxiedHeaders[`${CORS_PROXY_HEADER_PREFIX}${key}`] = value;
proxiedHeaders[`${CORS_PROXY.HEADER_PREFIX}${key}`] = value;
}
return proxiedHeaders;
+4 -9
View File
@@ -12,12 +12,7 @@ import {
joinPath,
rankEntries
} from './working-directory';
import {
GLOB_WILDCARD,
PATH_NAV_MAX_DEPTH,
PATH_SEPARATOR,
WINDOWS_SEPARATOR
} from '$lib/constants';
import { GLOB, PATH_SEPARATOR, SEARCH } from '$lib/constants';
import { BuiltInTool, GlobSearchType } from '$lib/enums';
import { ToolsService } from '$lib/services/tools.service';
@@ -113,7 +108,7 @@ export async function runGlobSearchWithChildren(
options: GlobSearchChildOptions = {}
): Promise<GlobSearchChildResult> {
const {
childMaxDepth = PATH_NAV_MAX_DEPTH,
childMaxDepth = SEARCH.PATH_NAV_MAX_DEPTH,
descendOnTrailingSeparator = false,
type = GlobSearchType.ALL
} = options;
@@ -128,7 +123,7 @@ export async function runGlobSearchWithChildren(
if (last) {
const wantsDescend = descendOnTrailingSeparator
? query.endsWith(PATH_SEPARATOR) || query.endsWith(WINDOWS_SEPARATOR)
? query.endsWith(PATH_SEPARATOR) || query.endsWith(GLOB.WINDOWS_SEPARATOR)
: true;
const exact = ranked.find(
(e) => e.type === 'dir' && lastPathSegment(e.path).toLowerCase() === last.toLowerCase()
@@ -137,7 +132,7 @@ export async function runGlobSearchWithChildren(
if (wantsDescend && exact) {
const exactDir = joinPath(res.base, exact.path);
const childRes = await runGlobSearch(
{ include: GLOB_WILDCARD, maxDepth: childMaxDepth, path: exactDir, rankQuery: '' },
{ include: GLOB.WILDCARD, maxDepth: childMaxDepth, path: exactDir, rankQuery: '' },
type,
limit,
signal
+2 -2
View File
@@ -1,4 +1,4 @@
import { HEIC_JPEG_QUALITY } from '$lib/constants';
import { IMAGE } from '$lib/constants';
import { MimeTypeImage } from '$lib/enums';
// heic requires a relatively large decoder, in order to reduce primary bundle size
@@ -32,7 +32,7 @@ export async function heicFileToJpegDataURL(file: File | Blob): Promise<string>
const { heicTo } = await getHeicTo();
const jpegBlob = await heicTo({
blob: file,
quality: HEIC_JPEG_QUALITY,
quality: IMAGE.HEIC_JPEG_QUALITY,
type: MimeTypeImage.JPEG
});
+11 -21
View File
@@ -1,14 +1,4 @@
import {
APP1_MARKER,
EXIF_ORIENTATION_TAG,
EXIF_SCAN_BYTE_LIMIT,
EXIF_SIGNATURE,
IFD_ENTRY_SIZE,
JPEG_SOI_MARKER,
SOS_MARKER,
TIFF_LITTLE_ENDIAN,
TIFF_MAGIC
} from '$lib/constants';
import { EXIF } from '$lib/constants';
import { MimeTypeImage } from '$lib/enums';
/**
@@ -28,7 +18,7 @@ export function getJpegOrientationFromDataURL(base64UrlJpeg: string): number {
}
// Keep the slice a multiple of 4 characters so atob accepts it
const charLimit = Math.ceil(EXIF_SCAN_BYTE_LIMIT / 3) * 4;
const charLimit = Math.ceil(EXIF.SCAN_BYTE_LIMIT / 3) * 4;
const slice = base64UrlJpeg.slice(payloadStart, payloadStart + charLimit);
const binary = atob(slice.slice(0, slice.length - (slice.length % 4)));
const bytes = new Uint8Array(binary.length);
@@ -49,7 +39,7 @@ export function getJpegOrientationFromDataURL(base64UrlJpeg: string): number {
* @returns The orientation value (1 to 8), or 1 when absent or malformed
*/
function findExifOrientation(view: DataView): number {
if (view.byteLength < 4 || view.getUint16(0) !== JPEG_SOI_MARKER) {
if (view.byteLength < 4 || view.getUint16(0) !== EXIF.JPEG_SOI_MARKER) {
return 1;
}
@@ -63,13 +53,13 @@ function findExifOrientation(view: DataView): number {
const marker = view.getUint8(offset + 1);
// Compressed image data starts here: no EXIF past this point
if (marker === SOS_MARKER) {
if (marker === EXIF.SOS_MARKER) {
return 1;
}
const segmentLength = view.getUint16(offset + 2);
if (marker === APP1_MARKER) {
if (marker === EXIF.APP1_MARKER) {
return parseExifOrientation(view, offset + 4, segmentLength);
}
@@ -92,7 +82,7 @@ function parseExifOrientation(view: DataView, start: number, segmentLength: numb
// The payload opens with the "Exif\0\0" signature
if (
start + 6 > end ||
view.getUint32(start) !== EXIF_SIGNATURE ||
view.getUint32(start) !== EXIF.EXIF_SIGNATURE ||
view.getUint16(start + 4) !== 0
) {
return 1;
@@ -104,9 +94,9 @@ function parseExifOrientation(view: DataView, start: number, segmentLength: numb
return 1;
}
const littleEndian = view.getUint16(tiff) === TIFF_LITTLE_ENDIAN;
const littleEndian = view.getUint16(tiff) === EXIF.TIFF_LITTLE_ENDIAN;
if (view.getUint16(tiff + 2, littleEndian) !== TIFF_MAGIC) {
if (view.getUint16(tiff + 2, littleEndian) !== EXIF.TIFF_MAGIC) {
return 1;
}
@@ -120,13 +110,13 @@ function parseExifOrientation(view: DataView, start: number, segmentLength: numb
// Scan IFD0 entries for the orientation tag
for (let i = 0; i < entryCount; i++) {
const entry = tiff + ifdOffset + 2 + i * IFD_ENTRY_SIZE;
const entry = tiff + ifdOffset + 2 + i * EXIF.IFD_ENTRY_SIZE;
if (entry + IFD_ENTRY_SIZE > end) {
if (entry + EXIF.IFD_ENTRY_SIZE > end) {
return 1;
}
if (view.getUint16(entry, littleEndian) === EXIF_ORIENTATION_TAG) {
if (view.getUint16(entry, littleEndian) === EXIF.ORIENTATION_TAG) {
const orientation = view.getUint16(entry + 8, littleEndian);
return orientation >= 1 && orientation <= 8 ? orientation : 1;
+4 -6
View File
@@ -15,9 +15,7 @@ import {
FILE_EXTENSION_REGEX,
IMAGE_FILE_EXTENSION_REGEX,
MCP_SERVER_ID_PREFIX,
MCP_SSE_ENDPOINT,
MCP_SSE_ENDPOINT_QUERY,
MCP_SSE_ENDPOINT_SLASH,
MCP_SSE,
MIME_TYPE_PREFIXES,
MIME_TYPE_SUBSTRINGS,
PATH_SEPARATOR,
@@ -46,9 +44,9 @@ export function detectMcpTransportFromUrl(url: string): MCPTransportType {
}
if (
normalized.endsWith(MCP_SSE_ENDPOINT) ||
normalized.endsWith(MCP_SSE_ENDPOINT_SLASH) ||
normalized.includes(MCP_SSE_ENDPOINT_QUERY)
normalized.endsWith(MCP_SSE.ENDPOINT) ||
normalized.endsWith(MCP_SSE.ENDPOINT_SLASH) ||
normalized.includes(MCP_SSE.ENDPOINT_QUERY)
) {
return MCPTransportType.SSE;
}
+5 -5
View File
@@ -1,4 +1,4 @@
import { SVG_MAX_BYTES, SVG_SANITIZE_CONFIG, SVG_TAG_PREFIX } from '$lib/constants';
import { SVG } from '$lib/constants';
import DOMPurify from 'dompurify';
/**
@@ -10,13 +10,13 @@ import DOMPurify from 'dompurify';
export function sanitizeSvg(source: string): string {
const trimmed = source.trim();
if (!trimmed || trimmed.length > SVG_MAX_BYTES) return '';
if (!trimmed || trimmed.length > SVG.MAX_BYTES) return '';
if (!trimmed.startsWith(SVG_TAG_PREFIX)) return '';
if (!trimmed.startsWith(SVG.TAG_PREFIX)) return '';
const clean = DOMPurify.sanitize(trimmed, SVG_SANITIZE_CONFIG) as unknown as string;
const clean = DOMPurify.sanitize(trimmed, SVG.SANITIZE_CONFIG) as unknown as string;
if (!clean || !clean.includes(SVG_TAG_PREFIX)) return '';
if (!clean || !clean.includes(SVG.TAG_PREFIX)) return '';
return clean;
}
+4 -1
View File
@@ -133,7 +133,10 @@ export function expandTemplate(template: string, values: Record<string, string>)
return URI_TEMPLATE_SYMBOLS.FRAGMENT + expandedParts.join(URI_TEMPLATE_SYMBOLS.COMMA);
case URI_TEMPLATE_SYMBOLS.PATH_SEGMENT:
// Path segments
return URI_TEMPLATE_SYMBOLS.PATH_SEGMENT + expandedParts.join(URI_TEMPLATE_SYMBOLS.PATH_SEGMENT);
return (
URI_TEMPLATE_SYMBOLS.PATH_SEGMENT +
expandedParts.join(URI_TEMPLATE_SYMBOLS.PATH_SEGMENT)
);
case URI_TEMPLATE_SYMBOLS.LABEL:
// Label expansion
return URI_TEMPLATE_SYMBOLS.LABEL + expandedParts.join(URI_TEMPLATE_SYMBOLS.LABEL);
+14 -20
View File
@@ -7,19 +7,12 @@
import { lastPathSegment } from './path-display';
import {
DRIVE_PREFIX_REGEX,
DRIVE_ROOT_REGEX,
GLOB_RANGE_CLOSE,
GLOB_RANGE_OPEN,
GLOB_SPECIAL_CHARS,
GLOB_WILDCARD,
GLOB,
HOME_TILDE,
LEADING_SLASHES_REGEX,
PATH_NAV_MAX_DEPTH,
PATH_SEPARATOR,
TRAILING_SLASHES_REGEX,
UNC_ROOT_REGEX,
WINDOWS_SEPARATOR
SEARCH,
TRAILING_SLASHES_REGEX
} from '$lib/constants';
export interface GlobEntry {
@@ -37,17 +30,18 @@ export interface PathQuery {
* backslash is left alone: it is a legal filename character on POSIX.
*/
function toPosixSeparators(query: string): string {
if (!DRIVE_PREFIX_REGEX.test(query) && !query.startsWith(WINDOWS_SEPARATOR)) return query;
if (!GLOB.DRIVE_PREFIX_REGEX.test(query) && !query.startsWith(GLOB.WINDOWS_SEPARATOR))
return query;
return query.split(WINDOWS_SEPARATOR).join(PATH_SEPARATOR);
return query.split(GLOB.WINDOWS_SEPARATOR).join(PATH_SEPARATOR);
}
export function rootPrefixLength(path: string): number {
const unc = path.match(UNC_ROOT_REGEX);
const unc = path.match(GLOB.UNC_ROOT_REGEX);
if (unc) return unc[0].length;
const drive = path.match(DRIVE_ROOT_REGEX);
const drive = path.match(GLOB.DRIVE_ROOT_REGEX);
if (drive) return drive[0].length;
@@ -83,20 +77,20 @@ export function splitPathQuery(query: string): PathQuery | null {
}
export function buildCaseInsensitiveGlob(query: string): string {
let out = GLOB_WILDCARD;
let out = GLOB.WILDCARD;
for (const c of query) {
const lo = c.toLowerCase();
const up = c.toUpperCase();
if (lo !== up) out += GLOB_RANGE_OPEN + lo + up + GLOB_RANGE_CLOSE;
if (lo !== up) out += GLOB.RANGE_OPEN + lo + up + GLOB.RANGE_CLOSE;
// glob metacharacters are escaped into a literal character class so a
// query like "a*b" matches a literal '*' instead of becoming "ab"
else if (GLOB_SPECIAL_CHARS.includes(c)) out += GLOB_RANGE_OPEN + c + GLOB_RANGE_CLOSE;
else if (GLOB.SPECIAL_CHARS.includes(c)) out += GLOB.RANGE_OPEN + c + GLOB.RANGE_CLOSE;
else out += c;
}
return out + GLOB_WILDCARD;
return out + GLOB.WILDCARD;
}
export interface GlobSearchArgs {
@@ -120,9 +114,9 @@ export function buildGlobSearchArgs(
const include = pathQuery
? pathQuery.last
? buildCaseInsensitiveGlob(pathQuery.last)
: GLOB_WILDCARD
: GLOB.WILDCARD
: buildCaseInsensitiveGlob(query);
const maxDepth = pathQuery ? PATH_NAV_MAX_DEPTH : searchDepth;
const maxDepth = pathQuery ? SEARCH.PATH_NAV_MAX_DEPTH : searchDepth;
return { include, last: pathQuery?.last, maxDepth, path, rankQuery: pathQuery?.last ?? query };
}
+3 -4
View File
@@ -8,10 +8,9 @@
import { PwaMetaTags, PwaRefreshAlert } from '$lib/components/pwa';
import * as Tooltip from '$lib/components/ui/tooltip';
import {
AUTHORIZATION_HEADER,
BEARER_PREFIX,
FAVICON_PATHS,
FAVICON_SELECTORS,
HEADERS,
ROUTES,
SETTINGS_KEYS,
TOOLTIP_DELAY_DURATION
@@ -117,8 +116,8 @@
page.status !== 403
) {
const headers: Record<string, string> = {
[AUTHORIZATION_HEADER]: `${BEARER_PREFIX}${apiKey.trim()}`,
'Content-Type': 'application/json'
'Content-Type': 'application/json',
[HEADERS.AUTHORIZATION]: `${HEADERS.BEARER}${apiKey.trim()}`
};
fetch(`${base}/props`, { headers })
+4 -4
View File
@@ -1,5 +1,5 @@
import { Client } from '@modelcontextprotocol/sdk/client';
import { CORS_PROXY_HEADER_PREFIX } from '$lib/constants';
import { CORS_PROXY } from '$lib/constants';
import { MCPConnectionPhase, MCPTransportType } from '$lib/enums';
import { MCPService } from '$lib/services/mcp.service';
import type { MCPConnectionLog, MCPServerConfig } from '$lib/types';
@@ -96,9 +96,9 @@ describe('MCPService', () => {
it('wraps dynamic request headers when using the CORS proxy', async () => {
const logs: MCPConnectionLog[] = [];
const proxiedAuthToken = `${CORS_PROXY_HEADER_PREFIX}x-auth-token`;
const proxiedContentType = `${CORS_PROXY_HEADER_PREFIX}content-type`;
const proxiedSessionId = `${CORS_PROXY_HEADER_PREFIX}mcp-session-id`;
const proxiedAuthToken = `${CORS_PROXY.HEADER_PREFIX}x-auth-token`;
const proxiedContentType = `${CORS_PROXY.HEADER_PREFIX}content-type`;
const proxiedSessionId = `${CORS_PROXY.HEADER_PREFIX}mcp-session-id`;
const response = new Response('{}', {
headers: { 'content-type': 'application/json' },
status: 200
+4 -4
View File
@@ -1,4 +1,4 @@
import { CORS_PROXY_HEADER_PREFIX } from '$lib/constants';
import { CORS_PROXY } from '$lib/constants';
import { sanitizeHeaders } from '$lib/utils/api-headers';
import { describe, expect, it } from 'vitest';
@@ -61,9 +61,9 @@ describe('sanitizeHeaders', () => {
});
it('redacts proxied sensitive and custom target headers', () => {
const proxiedAuthorization = `${CORS_PROXY_HEADER_PREFIX}authorization`;
const proxiedSessionId = `${CORS_PROXY_HEADER_PREFIX}mcp-session-id`;
const proxiedVendorKey = `${CORS_PROXY_HEADER_PREFIX}x-vendor-key`;
const proxiedAuthorization = `${CORS_PROXY.HEADER_PREFIX}authorization`;
const proxiedSessionId = `${CORS_PROXY.HEADER_PREFIX}mcp-session-id`;
const proxiedVendorKey = `${CORS_PROXY.HEADER_PREFIX}x-vendor-key`;
const headers = new Headers({
[proxiedAuthorization]: 'Bearer secret',
[proxiedSessionId]: 'session-12345',
@@ -1,4 +1,4 @@
import { GLOB_WILDCARD, PATH_NAV_MAX_DEPTH } from '$lib/constants';
import { GLOB, SEARCH } from '$lib/constants';
import {
buildCaseInsensitiveGlob,
buildGlobSearchArgs,
@@ -148,7 +148,7 @@ describe('buildGlobSearchArgs', () => {
expect(args.path).toBe('~');
expect(args.include).toBe(buildCaseInsensitiveGlob('proj'));
expect(args.maxDepth).toBe(PATH_NAV_MAX_DEPTH);
expect(args.maxDepth).toBe(SEARCH.PATH_NAV_MAX_DEPTH);
expect(args.rankQuery).toBe('proj');
expect(args.last).toBe('proj');
});
@@ -157,8 +157,8 @@ describe('buildGlobSearchArgs', () => {
const args = buildGlobSearchArgs('~/', '/home', DEPTH);
expect(args.path).toBe('~');
expect(args.include).toBe(GLOB_WILDCARD);
expect(args.maxDepth).toBe(PATH_NAV_MAX_DEPTH);
expect(args.include).toBe(GLOB.WILDCARD);
expect(args.maxDepth).toBe(SEARCH.PATH_NAV_MAX_DEPTH);
});
it('navigates an absolute path under its root', () => {
@@ -166,7 +166,7 @@ describe('buildGlobSearchArgs', () => {
expect(args.path).toBe('/usr/local');
expect(args.include).toBe(buildCaseInsensitiveGlob('bin'));
expect(args.maxDepth).toBe(PATH_NAV_MAX_DEPTH);
expect(args.maxDepth).toBe(SEARCH.PATH_NAV_MAX_DEPTH);
expect(args.rankQuery).toBe('bin');
expect(args.last).toBe('bin');
});