ui: Linting & Formatting scripts (#26819)

This commit is contained in:
Aleksander Grygier
2026-08-10 08:38:37 +02:00
committed by GitHub
parent 1e396e72a8
commit 92d1bb0c99
538 changed files with 8806 additions and 6036 deletions
+5 -1
View File
@@ -8,7 +8,6 @@
// the standard DOMException name for a cancelled operation
const ABORT_ERROR_NAME = 'AbortError';
// browser specific TypeError messages emitted when a fetch reader is cut by page unload,
// navigation, or a transient network drop. functionally aborts, not actionable errors
const ABORT_LIKE_MESSAGE_PATTERNS = [
@@ -62,16 +61,20 @@ export function isAbortError(error: unknown): boolean {
if (error instanceof DOMException && error.name === ABORT_ERROR_NAME) {
return true;
}
if (error instanceof Error) {
if (error.name === ABORT_ERROR_NAME) {
return true;
}
// these patterns are functionally aborts, keep them out of the red console
if (error instanceof TypeError) {
const msg = error.message ?? '';
if (ABORT_LIKE_MESSAGE_PATTERNS.some((re) => re.test(msg))) return true;
}
}
return false;
}
@@ -101,6 +104,7 @@ export function createLinkedController(...signals: (AbortSignal | undefined)[]):
// If already aborted, abort immediately
if (signal.aborted) {
controller.abort(signal.reason);
return controller;
}
+44 -22
View File
@@ -1,15 +1,8 @@
import {
AgenticSectionType,
AttachmentType,
ContinueIntentKind,
MessageRole,
ToolResultKind
} from '$lib/enums';
import {
ATTACHMENT_SAVED_REGEX,
MARKDOWN_ATX_HEADING_REGEX,
MARKDOWN_BOLD_REGEX,
MARKDOWN_BLOCKQUOTE_REGEX,
MARKDOWN_BOLD_REGEX,
MARKDOWN_CODE_FENCE_REGEX,
MARKDOWN_LINK_REGEX,
MARKDOWN_LIST_BULLET_REGEX,
@@ -21,6 +14,13 @@ import {
SEARCH_SUMMARY_TOTAL_REGEX,
TOOL_RESULT_JSON_OPEN_REGEX
} from '$lib/constants';
import {
AgenticSectionType,
AttachmentType,
ContinueIntentKind,
MessageRole,
ToolResultKind
} from '$lib/enums';
import type { ApiChatCompletionToolCall } from '$lib/types/api';
import type {
DatabaseMessage,
@@ -78,9 +78,10 @@ function deriveSingleTurnSections(
const hasContentAfterReasoning =
!!message.content?.trim() || toolCalls.length > 0 || streamingToolCalls.length > 0;
const isPending = isStreaming && !hasContentAfterReasoning;
sections.push({
type: isPending ? AgenticSectionType.REASONING_PENDING : AgenticSectionType.REASONING,
content: message.reasoningContent,
type: isPending ? AgenticSectionType.REASONING_PENDING : AgenticSectionType.REASONING,
wasInterrupted: !isStreaming && !hasContentAfterReasoning
});
}
@@ -88,16 +89,16 @@ function deriveSingleTurnSections(
// 2. Text content
if (message.content?.trim()) {
sections.push({
type: AgenticSectionType.TEXT,
content: message.content
content: message.content,
type: AgenticSectionType.TEXT
});
}
// 3. Persisted tool calls (from message.toolCalls field)
const toolCalls = parseToolCalls(message.toolCalls);
// Index tool messages by toolCallId for O(1) lookup instead of O(n) find()
const toolMsgById = new Map<string, DatabaseMessage>();
for (const tm of toolMessages) {
if (tm.toolCallId && !toolMsgById.has(tm.toolCallId)) {
toolMsgById.set(tm.toolCallId, tm);
@@ -112,29 +113,32 @@ function deriveSingleTurnSections(
: isStreaming
? AgenticSectionType.TOOL_CALL_PENDING
: AgenticSectionType.TOOL_CALL;
sections.push({
type,
content: resultMsg?.content || '',
toolName: tc.function?.name,
toolArgs: tc.function?.arguments,
toolCallId: tc.id,
toolCwd: resultMsg?.toolCwd,
toolName: tc.function?.name,
toolResult: resultMsg?.content,
toolResultExtras: resultMsg?.extra,
toolCwd: resultMsg?.toolCwd,
toolCallId: tc.id
type
});
}
// 4. Streaming tool calls (not yet persisted - currently being received)
const persistedIds = new Set(toolCalls.map((t) => t.id).filter(Boolean));
for (const tc of streamingToolCalls) {
// Skip if already in persisted tool calls
if (tc.id && persistedIds.has(tc.id)) continue;
sections.push({
type: AgenticSectionType.TOOL_CALL_STREAMING,
content: '',
toolName: tc.function?.name,
toolArgs: tc.function?.arguments,
toolCallId: tc.id
toolCallId: tc.id,
toolName: tc.function?.name,
type: AgenticSectionType.TOOL_CALL_STREAMING
});
}
@@ -168,8 +172,8 @@ export function deriveAgenticSections(
}
const sections: AgenticSection[] = [];
const firstTurnToolMsgs = collectToolMessages(toolMessages, 0);
sections.push(...deriveSingleTurnSections(message, firstTurnToolMsgs));
let i = firstTurnToolMsgs.length;
@@ -212,10 +216,12 @@ export function buildAssistantRawOutput(sections: AgenticSection[]): string {
case AgenticSectionType.REASONING:
case AgenticSectionType.REASONING_PENDING:
parts.push(`${REASONING_TAGS.START}${NEWLINE}${section.content}${REASONING_TAGS.END}`);
break;
case AgenticSectionType.TEXT:
parts.push(section.content);
break;
case AgenticSectionType.TOOL_CALL:
@@ -281,8 +287,8 @@ export function splitSearchSummaryList(
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);
if (totalMatch) {
captureTotal(parseInt(totalMatch[1], 10));
}
@@ -316,11 +322,13 @@ export function parseToolResultWithImages(
.join(NEWLINE);
const cacheKey = `${imageNames}:${toolResult}`;
const cached = toolResultLinesCache.get(cacheKey);
if (cached !== undefined) return cached;
const lines = toolResult.split(NEWLINE);
const result = lines.map((line) => {
const match = line.match(ATTACHMENT_SAVED_REGEX);
if (!match || !extras) return { text: line };
const attachmentName = match[1];
@@ -329,12 +337,13 @@ export function parseToolResultWithImages(
e.type === AttachmentType.IMAGE && e.name === attachmentName
);
return { text: line, image };
return { image, text: line };
});
if (toolResultLinesCache.size >= TOOL_RESULT_LINES_CACHE_MAX_SIZE) {
toolResultLinesCache.delete(toolResultLinesCache.keys().next().value!);
}
toolResultLinesCache.set(cacheKey, result);
return result;
@@ -359,9 +368,11 @@ export function classifyToolResult(content: string | undefined): ToolResultKind
if (!content) return ToolResultKind.TEXT;
const cached = classifyCache.get(content);
if (cached !== undefined) return cached;
const trimmed = content.trim();
if (!trimmed) return ToolResultKind.TEXT;
let result: ToolResultKind = ToolResultKind.TEXT;
@@ -383,6 +394,7 @@ export function classifyToolResult(content: string | undefined): ToolResultKind
if (classifyCache.size >= CLASSIFY_CACHE_MAX_SIZE) {
classifyCache.delete(classifyCache.keys().next().value!);
}
classifyCache.set(content, result);
return result;
@@ -405,13 +417,17 @@ function looksLikeMarkdown(content: string): boolean {
for (const line of lines) {
if (MARKDOWN_ATX_HEADING_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_NUMBERED_REGEX.test(line)) return true;
}
// Inline structural markers anywhere in the body.
if (MARKDOWN_LINK_REGEX.test(content)) return true;
if (MARKDOWN_BOLD_REGEX.test(content)) return true;
// Tables: a pipe-bearing header line followed by a separator row.
@@ -438,11 +454,14 @@ function parseToolCalls(toolCallsJson?: string): ApiChatCompletionToolCall[] {
if (!toolCallsJson) return [];
const cached = toolCallsParseCache.get(toolCallsJson);
if (cached) return cached;
let result: ApiChatCompletionToolCall[];
try {
const parsed = JSON.parse(toolCallsJson);
result = Array.isArray(parsed) ? parsed : [];
} catch {
result = [];
@@ -451,6 +470,7 @@ function parseToolCalls(toolCallsJson?: string): ApiChatCompletionToolCall[] {
if (toolCallsParseCache.size >= TOOL_CALLS_CACHE_MAX_SIZE) {
toolCallsParseCache.delete(toolCallsParseCache.keys().next().value!);
}
toolCallsParseCache.set(toolCallsJson, result);
return result;
@@ -508,6 +528,7 @@ export function classifyContinueIntent(messages: DatabaseMessage[], idx: number)
}
const hasToolCalls = parseToolCalls(target.toolCalls).length > 0;
if (!hasToolCalls) {
return { kind: ContinueIntentKind.APPEND_TEXT };
}
@@ -516,6 +537,7 @@ export function classifyContinueIntent(messages: DatabaseMessage[], idx: number)
// messages directly after the assistant turn that owns them, so the first
// non tool message marks the boundary.
let lastTrailingTool = idx;
for (let i = idx + 1; i < messages.length; i++) {
if (messages[i].role === MessageRole.TOOL) {
lastTrailingTool = i;
+13 -6
View File
@@ -1,7 +1,7 @@
import { getAuthHeaders, getJsonHeaders } from './api-headers';
import { base } from '$app/paths';
import { getJsonHeaders, getAuthHeaders } from './api-headers';
import { UrlProtocol } from '$lib/enums';
import { ERROR_MESSAGES, HTTP_CODE_TO_STRING } from '$lib/constants/error';
import { UrlProtocol } from '$lib/enums';
/**
* API Fetch Utilities
@@ -61,16 +61,15 @@ export interface ApiFetchOptions extends Omit<RequestInit, 'headers'> {
*/
export async function apiFetch<T>(path: string, options: ApiFetchOptions = {}): Promise<T> {
const { authOnly = false, headers: customHeaders, ...fetchOptions } = options;
const baseHeaders = authOnly ? getAuthHeaders() : getJsonHeaders();
const headers = { ...baseHeaders, ...customHeaders };
const url =
path.startsWith(UrlProtocol.HTTP) || path.startsWith(UrlProtocol.HTTPS)
? path
: `${base}${path}`;
let response;
try {
response = await fetch(url, {
...fetchOptions,
@@ -82,6 +81,7 @@ export async function apiFetch<T>(path: string, options: ApiFetchOptions = {}):
if (!response.ok) {
const errorMessage = await parseErrorMessage(response);
throw new ApiError(errorMessage, response.status);
}
@@ -118,11 +118,11 @@ export async function apiFetchWithParams<T>(
}
const { authOnly = false, headers: customHeaders, ...fetchOptions } = options;
const baseHeaders = authOnly ? getAuthHeaders() : getJsonHeaders();
const headers = { ...baseHeaders, ...customHeaders };
let response;
try {
response = await fetch(url.toString(), {
...fetchOptions,
@@ -134,6 +134,7 @@ export async function apiFetchWithParams<T>(
if (!response.ok) {
const errorMessage = await parseErrorMessage(response);
throw new ApiError(errorMessage, response.status);
}
@@ -154,8 +155,8 @@ export async function apiPost<T, B = unknown>(
options: ApiFetchOptions = {}
): Promise<T> {
return apiFetch<T>(path, {
method: 'POST',
body: JSON.stringify(body),
method: 'POST',
...options
});
}
@@ -167,12 +168,15 @@ export async function apiPost<T, B = unknown>(
async function parseErrorMessage(response: Response): Promise<string> {
try {
const errorData = await response.json();
if (errorData?.error?.message) {
return errorData.error.message;
}
if (errorData?.error && typeof errorData.error === 'string') {
return errorData.error;
}
if (errorData?.message) {
return errorData.message;
}
@@ -181,6 +185,7 @@ async function parseErrorMessage(response: Response): Promise<string> {
}
const httpErrorStr = HTTP_CODE_TO_STRING[response.status];
if (httpErrorStr) {
return httpErrorStr;
}
@@ -195,8 +200,10 @@ async function parseErrorMessage(response: Response): Promise<string> {
*/
function beautifyNetworkError(throwable: unknown): string {
let message;
if (throwable instanceof Error) {
message = throwable.message;
if (throwable.name === 'TypeError' && message.includes('fetch')) {
return ERROR_MESSAGES.NETWORK.UNREACHABLE;
}
+2 -2
View File
@@ -1,4 +1,4 @@
import { config } from '$lib/stores/settings.svelte';
import { redactValue } from './redact';
import {
AUTHORIZATION_HEADER,
BEARER_PREFIX,
@@ -7,7 +7,7 @@ import {
REDACTED_HEADERS
} from '$lib/constants';
import { MimeTypeApplication } from '$lib/enums';
import { redactValue } from './redact';
import { config } from '$lib/stores/settings.svelte';
/**
* Get authorization headers for API requests
+2 -1
View File
@@ -1,6 +1,6 @@
import { base } from '$app/paths';
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 { MimeTypeApplication } from '$lib/enums';
import { config } from '$lib/stores/settings.svelte';
@@ -36,6 +36,7 @@ export async function validateApiKey(fetch: typeof globalThis.fetch): Promise<vo
}
console.warn(`Server responded with status ${response.status} during API key validation`);
return;
}
} catch (err) {
+14 -12
View File
@@ -1,10 +1,10 @@
import { AttachmentType, FileTypeCategory, SpecialFileType } from '$lib/enums';
import { getFileTypeCategory, getFileTypeCategoryByExtension, isImageFile } from '$lib/utils';
import type {
AttachmentDisplayItemsOptions,
ChatAttachmentDisplayItem,
ChatUploadedFile
} from '$lib/types';
import { getFileTypeCategory, getFileTypeCategoryByExtension, isImageFile } from '$lib/utils';
/**
* Check if a display item represents an MCP prompt
@@ -14,9 +14,11 @@ export function isMcpPrompt(item: ChatAttachmentDisplayItem): boolean {
if (item.attachment?.type === AttachmentType.MCP_PROMPT) {
return true;
}
if (item.uploadedFile?.type === SpecialFileType.MCP_PROMPT && item.uploadedFile.mcpPrompt) {
return true;
}
return false;
}
@@ -47,21 +49,21 @@ function getUploadedFileCategory(file: ChatUploadedFile): FileTypeCategory | nul
export function getAttachmentDisplayItems(
options: AttachmentDisplayItemsOptions
): ChatAttachmentDisplayItem[] {
const { uploadedFiles = [], attachments = [] } = options;
const { attachments = [], uploadedFiles = [] } = options;
const items: ChatAttachmentDisplayItem[] = [];
// Add uploaded files (ChatForm)
for (const file of uploadedFiles) {
items.push({
id: file.id,
name: file.name,
size: file.size,
preview: file.preview,
isImage: getUploadedFileCategory(file) === FileTypeCategory.IMAGE,
isLoading: file.isLoading,
loadError: file.loadError,
uploadedFile: file,
textContent: file.textContent
name: file.name,
preview: file.preview,
size: file.size,
textContent: file.textContent,
uploadedFile: file
});
}
@@ -70,13 +72,13 @@ export function getAttachmentDisplayItems(
const isImage = isImageFile(attachment);
items.push({
id: `attachment-${index}`,
name: attachment.name,
size: 'size' in attachment ? attachment.size : undefined,
preview: isImage && 'base64Url' in attachment ? attachment.base64Url : undefined,
isImage,
attachment,
attachmentIndex: index,
id: `attachment-${index}`,
isImage,
name: attachment.name,
preview: isImage && 'base64Url' in attachment ? attachment.base64Url : undefined,
size: 'size' in attachment ? attachment.size : undefined,
textContent: 'content' in attachment ? attachment.content : undefined
});
}
+13 -7
View File
@@ -23,9 +23,9 @@ export class AudioRecorder {
try {
this.stream = await navigator.mediaDevices.getUserMedia({
audio: {
autoGainControl: true,
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true
noiseSuppression: true
}
});
@@ -37,6 +37,7 @@ export class AudioRecorder {
this.recordingState = true;
} catch (error) {
console.error('Failed to start recording:', error);
throw new Error('Failed to access microphone. Please check permissions.');
}
}
@@ -49,6 +50,7 @@ export class AudioRecorder {
if (!recorder || recorder.state === 'inactive') {
reject(new Error('No active recording to stop'));
return;
}
@@ -156,18 +158,19 @@ export async function convertToWav(audioBlob: Blob): Promise<Blob> {
}
const arrayBuffer = await audioBlob.arrayBuffer();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)();
try {
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
return audioBufferToWav(audioBuffer);
} finally {
audioContext.close();
}
} catch (error) {
console.error('Failed to convert audio to WAV:', error);
return audioBlob;
}
}
@@ -181,10 +184,8 @@ function audioBufferToWav(buffer: AudioBuffer): Blob {
const byteRate = sampleRate * blockAlign;
const dataSize = length * blockAlign;
const bufferSize = 44 + dataSize;
const arrayBuffer = new ArrayBuffer(bufferSize);
const view = new DataView(arrayBuffer);
const writeString = (offset: number, string: string) => {
for (let i = 0; i < string.length; i++) {
view.setUint8(offset + i, string.charCodeAt(i));
@@ -207,17 +208,22 @@ function audioBufferToWav(buffer: AudioBuffer): Blob {
// Cache channel arrays, write PCM via Int16Array (native little-endian, matches WAV)
const channels: Float32Array[] = new Array(numberOfChannels);
for (let c = 0; c < numberOfChannels; c++) {
channels[c] = buffer.getChannelData(c);
}
const pcm = new Int16Array(arrayBuffer, 44, length * numberOfChannels);
let p = 0;
for (let i = 0; i < length; i++) {
for (let c = 0; c < numberOfChannels; c++) {
let s = channels[c][i];
if (s > 1) s = 1;
else if (s < -1) s = -1;
pcm[p++] = s * 0x7fff;
}
}
@@ -237,8 +243,8 @@ export function createAudioFile(audioBlob: Blob, filename?: string): File {
const defaultFilename = `recording-${timestamp}.${extension}`;
return new File([audioBlob], filename || defaultFilename, {
type: audioBlob.type,
lastModified: Date.now()
lastModified: Date.now(),
type: audioBlob.type
});
}
+18 -5
View File
@@ -25,6 +25,7 @@ export function findMessageById(
id: string | null | undefined
): DatabaseMessage | undefined {
if (!id) return undefined;
return messages.find((m) => m.id === id);
}
@@ -52,9 +53,11 @@ export function filterByLeafNodeId(
// Find the starting node (leaf node or latest if not found)
let startNode: DatabaseMessage | undefined = nodeMap.get(leafNodeId);
if (!startNode) {
// If leaf node not found, use the message with latest timestamp
let latestTime = -1;
for (const msg of messages) {
if (msg.timestamp > latestTime) {
startNode = msg;
@@ -65,6 +68,7 @@ export function filterByLeafNodeId(
// Traverse from leaf to root, collecting messages
let currentNode: DatabaseMessage | undefined = startNode;
while (currentNode) {
// Include message if it's not root, or if we want to include root
if (currentNode.type !== 'root' || includeRoot) {
@@ -75,16 +79,19 @@ export function filterByLeafNodeId(
if (currentNode.parent === null) {
break;
}
currentNode = nodeMap.get(currentNode.parent);
}
// Sort: system messages first, then by timestamp
result.sort((a, b) => {
if (a.role === MessageRole.SYSTEM && b.role !== MessageRole.SYSTEM) return -1;
if (a.role !== MessageRole.SYSTEM && b.role === MessageRole.SYSTEM) return 1;
return a.timestamp - b.timestamp;
});
return result;
}
@@ -101,9 +108,11 @@ function findLeafNodeInMap(
messageId: string
): string {
let currentNode: DatabaseMessage | undefined = nodeMap.get(messageId);
while (currentNode && currentNode.children.length > 0) {
// Follow the last child (most recent branch)
const lastChildId = currentNode.children[currentNode.children.length - 1];
currentNode = nodeMap.get(lastChildId);
}
@@ -115,6 +124,7 @@ function findLeafNodeInMap(
*/
export function findLeafNode(messages: readonly DatabaseMessage[], messageId: string): string {
const nodeMap = new Map(messages.map((msg) => [msg.id, msg] as const));
return findLeafNodeInMap(nodeMap, messageId);
}
@@ -169,6 +179,7 @@ export function getMessageSiblings(
messageId: string
): ChatMessageSiblingInfo | null {
const message = nodeMap.get(messageId);
if (!message) {
return null;
}
@@ -177,40 +188,39 @@ export function getMessageSiblings(
if (message.parent === null) {
// No parent means this is likely a root node with no siblings
return {
currentIndex: 0,
message,
siblingIds: [messageId],
currentIndex: 0,
totalSiblings: 1
};
}
const parentNode = nodeMap.get(message.parent);
if (!parentNode) {
// Parent not found - treat as single message
return {
currentIndex: 0,
message,
siblingIds: [messageId],
currentIndex: 0,
totalSiblings: 1
};
}
// Get all sibling IDs (including self)
const siblingIds = parentNode.children;
// Convert sibling message IDs to their corresponding leaf node IDs
// This allows navigation between different conversation branches
const siblingLeafIds = siblingIds.map((siblingId: string) =>
findLeafNodeInMap(nodeMap, siblingId)
);
// Find current message's position among siblings
const currentIndex = siblingIds.indexOf(messageId);
return {
currentIndex,
message,
siblingIds: siblingLeafIds,
currentIndex,
totalSiblings: siblingIds.length
};
}
@@ -226,11 +236,14 @@ export function buildSiblingInfoMap(
): Map<string, ChatMessageSiblingInfo> {
const nodeMap = new Map(messages.map((msg) => [msg.id, msg] as const));
const siblingMap = new Map<string, ChatMessageSiblingInfo>();
for (const msg of messages) {
const info = getMessageSiblings(nodeMap, msg.id);
if (info) {
siblingMap.set(msg.id, info);
}
}
return siblingMap;
}
+24 -5
View File
@@ -1,4 +1,4 @@
import { DEFAULT_CACHE_TTL_MS, DEFAULT_CACHE_MAX_ENTRIES } from '$lib/constants';
import { DEFAULT_CACHE_MAX_ENTRIES, DEFAULT_CACHE_TTL_MS } from '$lib/constants';
/**
* TTL Cache - Time-To-Live cache implementation for memory optimization
@@ -46,15 +46,18 @@ export class TTLCache<K extends string, V> {
*/
get(key: K): V | null {
const entry = this.cache.get(key);
if (!entry) return null;
if (Date.now() > entry.expiresAt) {
this.delete(key);
return null;
}
// Update last accessed time for LRU-like behavior
entry.lastAccessed = Date.now();
return entry.value;
}
@@ -71,9 +74,9 @@ export class TTLCache<K extends string, V> {
const now = Date.now();
this.cache.set(key, {
value,
expiresAt: now + ttl,
lastAccessed: now
lastAccessed: now,
value
});
}
@@ -82,10 +85,12 @@ export class TTLCache<K extends string, V> {
*/
has(key: K): boolean {
const entry = this.cache.get(key);
if (!entry) return false;
if (Date.now() > entry.expiresAt) {
this.delete(key);
return false;
}
@@ -97,9 +102,11 @@ export class TTLCache<K extends string, V> {
*/
delete(key: K): boolean {
const entry = this.cache.get(key);
if (entry && this.onEvict) {
this.onEvict(key, entry.value);
}
return this.cache.delete(key);
}
@@ -112,6 +119,7 @@ export class TTLCache<K extends string, V> {
this.onEvict(key, entry.value);
}
}
this.cache.clear();
}
@@ -128,6 +136,7 @@ export class TTLCache<K extends string, V> {
*/
prune(): number {
const now = Date.now();
let pruned = 0;
for (const [key, entry] of this.cache) {
@@ -180,16 +189,20 @@ export class TTLCache<K extends string, V> {
*/
touch(key: K): boolean {
const entry = this.cache.get(key);
if (!entry) return false;
const now = Date.now();
if (now > entry.expiresAt) {
this.delete(key);
return false;
}
entry.expiresAt = now + this.ttlMs;
entry.lastAccessed = now;
return true;
}
}
@@ -210,14 +223,17 @@ export class ReactiveTTLMap<K extends string, V> {
get(key: K): V | null {
const entry = this.entries.get(key);
if (!entry) return null;
if (Date.now() > entry.expiresAt) {
this.entries.delete(key);
return null;
}
entry.lastAccessed = Date.now();
return entry.value;
}
@@ -230,18 +246,20 @@ export class ReactiveTTLMap<K extends string, V> {
const now = Date.now();
this.entries.set(key, {
value,
expiresAt: now + ttl,
lastAccessed: now
lastAccessed: now,
value
});
}
has(key: K): boolean {
const entry = this.entries.get(key);
if (!entry) return false;
if (Date.now() > entry.expiresAt) {
this.entries.delete(key);
return false;
}
@@ -262,6 +280,7 @@ export class ReactiveTTLMap<K extends string, V> {
prune(): number {
const now = Date.now();
let pruned = 0;
for (const [key, entry] of this.entries) {
+3 -2
View File
@@ -1,6 +1,6 @@
import { getJpegOrientationFromDataURL, isJpegMimeType } from './jpeg-orientation';
import { MEGAPIXELS_TO_PIXELS } from '$lib/constants/image-size';
import { BASE64_IMAGE_URI_REGEX } from '$lib/constants/uri-template';
import { getJpegOrientationFromDataURL, isJpegMimeType } from './jpeg-orientation';
import { MimeTypeImage } from '$lib/enums';
/**
@@ -37,7 +37,6 @@ export function capImageDataURLSize(
const orientation = isJpegMimeType(mimeType)
? getJpegOrientationFromDataURL(base64UrlImage)
: 1;
const img = new Image();
img.onload = () => {
@@ -56,6 +55,7 @@ export function capImageDataURLSize(
if (maxPixels > 0 && totalPixels > maxPixels) {
const scaleFactor = Math.sqrt(maxPixels / totalPixels);
canvas.width = Math.floor(targetWidth * scaleFactor);
canvas.height = Math.floor(targetHeight * scaleFactor);
} else if (orientation > 1) {
@@ -81,6 +81,7 @@ export function capImageDataURLSize(
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const errorMessage = `Error resizing image: ${message}`;
console.error(errorMessage, error);
reject(new Error(errorMessage));
}
@@ -12,7 +12,6 @@
*/
const THINKING_KWARG_VARS = ['enable_thinking', 'reasoning_effort', 'thinking_budget'];
/**
* Paired thinking-content tag patterns.
*
@@ -30,7 +29,6 @@ const THINKING_TAG_PATTERNS: Array<[string, string | null]> = [
['<seed:think|>', '</seed:think|>'],
['<think></think>', null]
];
const JINJA_THINKING_CONDITIONALS: RegExp[] = [
// Matches: {% if enable thinking %}, {% if enable_thinking %}, {% if (enable_thinking is defined) %}
// Handles: underscore-separated (enable_thinking), space-separated (enable thinking),
@@ -47,11 +45,13 @@ const JINJA_THINKING_CONDITIONALS: RegExp[] = [
*/
export function detectThinkingSupport(t: string): boolean {
if (!t) return false;
for (const kwarg of THINKING_KWARG_VARS) {
const regex = new RegExp(
`(\\{\\{[^{}]*\\b${kwarg}\\b[^{}]*\\}\\}|\\{%[^{}]*\\b${kwarg}\\b[^{}]*%\\})`,
'i'
);
if (regex.test(t)) return true;
}
for (const p of JINJA_THINKING_CONDITIONALS) {
@@ -60,27 +60,31 @@ export function detectThinkingSupport(t: string): boolean {
for (const [s, e] of THINKING_TAG_PATTERNS) {
if (t.includes(s) && (!e || t.includes(e))) return true;
}
return false;
}
export function detectThinkingSupportWithReason(t: string): { supported: boolean; reason: string } {
if (!t) return { supported: false, reason: 'No chat template available' };
if (!t) return { reason: 'No chat template available', supported: false };
for (const kwarg of THINKING_KWARG_VARS) {
const regex = new RegExp(
`(\\{\\{[^{}]*\\b${kwarg}\\b[^{}]*\\}\\}|\\{%[^{}]*\\b${kwarg}\\b[^{}]*%\\})`,
'i'
);
if (regex.test(t)) {
return { supported: true, reason: 'Found: ' + kwarg };
return { reason: 'Found: ' + kwarg, supported: true };
}
}
for (const p of JINJA_THINKING_CONDITIONALS) {
if (p.test(t)) return { supported: true, reason: 'Found: thinking conditional' };
if (p.test(t)) return { reason: 'Found: thinking conditional', supported: true };
}
for (const [s, e] of THINKING_TAG_PATTERNS) {
if (t.includes(s) && (!e || t.includes(e))) {
return { supported: true, reason: 'Found: ' + s + (e ? ' .. ' + e : ' (self)') };
return { reason: 'Found: ' + s + (e ? ' .. ' + e : ' (self)'), supported: true };
}
}
return { supported: false, reason: 'No thinking patterns found' };
return { reason: 'No thinking patterns found', supported: false };
}
+38 -27
View File
@@ -1,16 +1,16 @@
import { toast } from 'svelte-sonner';
import { AttachmentType } from '$lib/enums';
import type {
ClipboardAttachment,
ClipboardMcpPromptAttachment,
ClipboardTextAttachment,
DatabaseMessageExtra,
DatabaseMessageExtraTextFile,
DatabaseMessageExtraLegacyContext,
DatabaseMessageExtraMcpPrompt,
DatabaseMessageExtraMcpResource,
ClipboardTextAttachment,
ClipboardMcpPromptAttachment,
ClipboardAttachment,
DatabaseMessageExtraTextFile,
ParsedClipboardContent
} from '$lib/types';
import { toast } from 'svelte-sonner';
/**
* Copy text to clipboard with toast notification
@@ -30,11 +30,13 @@ export async function copyToClipboard(
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(text);
toast.success(successMessage);
return true;
}
// Fallback for non-secure contexts
const textArea = document.createElement('textarea');
textArea.value = text;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
@@ -44,10 +46,12 @@ export async function copyToClipboard(
textArea.select();
const successful = document.execCommand('copy');
document.body.removeChild(textArea);
if (successful) {
toast.success(successMessage);
return true;
} else {
throw new Error('execCommand failed');
@@ -55,6 +59,7 @@ export async function copyToClipboard(
} catch (error) {
console.error('Failed to copy to clipboard:', error);
toast.error(errorMessage);
return false;
}
}
@@ -127,28 +132,32 @@ export function formatMessageForClipboard(
if (asPlainText) {
const parts = [content];
for (const att of textAttachments) {
parts.push(att.content);
}
return parts.join('\n\n');
}
const clipboardAttachments: ClipboardAttachment[] = textAttachments.map((att) => {
if (att.type === AttachmentType.MCP_PROMPT) {
const mcpAtt = att as DatabaseMessageExtraMcpPrompt;
return {
type: AttachmentType.MCP_PROMPT,
name: mcpAtt.name,
serverName: mcpAtt.serverName,
promptName: mcpAtt.promptName,
arguments: mcpAtt.arguments,
content: mcpAtt.content,
arguments: mcpAtt.arguments
name: mcpAtt.name,
promptName: mcpAtt.promptName,
serverName: mcpAtt.serverName,
type: AttachmentType.MCP_PROMPT
} as ClipboardMcpPromptAttachment;
}
return {
type: AttachmentType.TEXT,
content: att.content,
name: att.name,
content: att.content
type: AttachmentType.TEXT
} as ClipboardTextAttachment;
});
@@ -164,9 +173,9 @@ export function formatMessageForClipboard(
*/
export function parseClipboardContent(clipboardText: string): ParsedClipboardContent {
const defaultResult: ParsedClipboardContent = {
mcpPromptAttachments: [],
message: clipboardText,
textAttachments: [],
mcpPromptAttachments: []
textAttachments: []
};
if (!clipboardText.startsWith('"')) {
@@ -182,16 +191,19 @@ export function parseClipboardContent(clipboardText: string): ParsedClipboardCon
if (escaped) {
escaped = false;
continue;
}
if (char === '\\') {
escaped = true;
continue;
}
if (char === '"') {
stringEndIndex = i;
break;
}
}
@@ -202,45 +214,43 @@ export function parseClipboardContent(clipboardText: string): ParsedClipboardCon
const jsonStringPart = clipboardText.substring(0, stringEndIndex + 1);
const remainingPart = clipboardText.substring(stringEndIndex + 1).trim();
const message = JSON.parse(jsonStringPart) as string;
if (!remainingPart || !remainingPart.startsWith('[')) {
return {
mcpPromptAttachments: [],
message,
textAttachments: [],
mcpPromptAttachments: []
textAttachments: []
};
}
const attachments = JSON.parse(remainingPart) as unknown[];
const validTextAttachments: ClipboardTextAttachment[] = [];
const validMcpPromptAttachments: ClipboardMcpPromptAttachment[] = [];
for (const att of attachments) {
if (isValidMcpPromptAttachment(att)) {
validMcpPromptAttachments.push({
type: AttachmentType.MCP_PROMPT,
name: att.name,
serverName: att.serverName,
promptName: att.promptName,
arguments: att.arguments,
content: att.content,
arguments: att.arguments
name: att.name,
promptName: att.promptName,
serverName: att.serverName,
type: AttachmentType.MCP_PROMPT
});
} else if (isValidTextAttachment(att)) {
validTextAttachments.push({
type: AttachmentType.TEXT,
content: att.content,
name: att.name,
content: att.content
type: AttachmentType.TEXT
});
}
}
return {
mcpPromptAttachments: validMcpPromptAttachments,
message,
textAttachments: validTextAttachments,
mcpPromptAttachments: validMcpPromptAttachments
textAttachments: validTextAttachments
};
} catch {
return defaultResult;
@@ -307,5 +317,6 @@ export function hasClipboardAttachments(clipboardText: string): boolean {
}
const parsed = parseClipboardContent(clipboardText);
return parsed.textAttachments.length > 0 || parsed.mcpPromptAttachments.length > 0;
}
+16 -8
View File
@@ -1,15 +1,15 @@
import hljs from 'highlight.js';
import {
NEWLINE,
DEFAULT_LANGUAGE,
LANG_PATTERN,
AMPERSAND_REGEX,
LT_REGEX,
GT_REGEX,
DEFAULT_LANGUAGE,
FENCE_PATTERN,
GT_REGEX,
LANG_PATTERN,
LT_REGEX,
NEWLINE,
TRIM_LEADING_PADDING_REGEX,
TRIM_TRAILING_PADDING_REGEX
} from '$lib/constants';
import hljs from 'highlight.js';
export interface IncompleteCodeBlock {
language: string;
@@ -41,21 +41,25 @@ export function splitGluedClosingCodeFences(markdown: string): string {
if (!markdown.includes('```')) return markdown;
const lines = markdown.split(NEWLINE);
let inside = false;
let changed = false;
for (let i = 0; i < lines.length; i++) {
const match = FENCE_LINE_REGEX.exec(lines[i]);
if (!match) continue;
if (!inside) {
inside = true;
continue;
}
inside = false;
const trailing = match[2];
if (trailing.includes('`') || !/\s/.test(trailing)) continue;
lines[i] = lines[i].slice(0, lines[i].length - trailing.length);
@@ -106,9 +110,11 @@ export function highlightCode(code: string, language: string, autoDetect = true)
// (e.g., when text after a code block changes but the code itself doesn't).
const cacheKey = `${language}:${autoDetect}:${code}`;
const cached = highlightCache.get(cacheKey);
if (cached) return cached;
const trimmed = trimCodePadding(code);
let result: string;
try {
@@ -129,6 +135,7 @@ export function highlightCode(code: string, language: string, autoDetect = true)
if (highlightCache.size >= HIGHLIGHT_CACHE_MAX_SIZE) {
highlightCache.delete(highlightCache.keys().next().value!);
}
highlightCache.set(cacheKey, result);
return result;
@@ -147,11 +154,13 @@ export function detectIncompleteCodeBlock(markdown: string): IncompleteCodeBlock
// A code block is incomplete if there's an odd number of ``` fences
const fencePattern = new RegExp(FENCE_PATTERN.source, FENCE_PATTERN.flags);
const fences: number[] = [];
let fenceMatch;
while ((fenceMatch = fencePattern.exec(markdown)) !== null) {
// Store the position after the ```
const pos = fenceMatch[0].startsWith(NEWLINE) ? fenceMatch.index + 1 : fenceMatch.index;
fences.push(pos);
}
@@ -164,7 +173,6 @@ export function detectIncompleteCodeBlock(markdown: string): IncompleteCodeBlock
// The last fence is the opening of the incomplete block
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;
@@ -172,8 +180,8 @@ export function detectIncompleteCodeBlock(markdown: string): IncompleteCodeBlock
const code = markdown.slice(codeStartIndex);
return {
language,
code,
language,
openingIndex
};
}
+4 -2
View File
@@ -11,7 +11,7 @@ export function findCommandToken(
const name = spaceIdx === -1 ? rest : rest.slice(0, spaceIdx);
const args = spaceIdx === -1 ? '' : rest.slice(spaceIdx + 1);
return { name, args, end: value.length };
return { args, end: value.length, name };
}
/**
@@ -26,6 +26,8 @@ export interface CommandDismissSnapshot {
export function takeCommandDismissSnapshot(value: string): CommandDismissSnapshot | null {
const token = findCommandToken(value);
if (!token) return null;
return { name: token.name, args: token.args };
return { args: token.args, name: token.name };
}
+20 -9
View File
@@ -27,16 +27,18 @@ export interface DiffLine {
export function computeLineDiff(oldText: string, newText: string): DiffLine[] {
const oldLines = splitLines(oldText);
const newLines = splitLines(newText);
const m = oldLines.length;
const n = newLines.length;
if (m === 0 && n === 0) return [];
if (m === 0) return newLines.map((t, k) => ({ kind: DiffLineKind.ADD, text: t, newLine: k + 1 }));
if (m === 0) return newLines.map((t, k) => ({ kind: DiffLineKind.ADD, newLine: k + 1, text: t }));
if (n === 0)
return oldLines.map((t, k) => ({ kind: DiffLineKind.REMOVE, text: t, oldLine: k + 1 }));
return oldLines.map((t, k) => ({ kind: DiffLineKind.REMOVE, oldLine: k + 1, text: t }));
const lcs: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
if (oldLines[i - 1] === newLines[j - 1]) {
@@ -48,36 +50,39 @@ export function computeLineDiff(oldText: string, newText: string): DiffLine[] {
}
const result: DiffLine[] = [];
let i = m;
let j = n;
while (i > 0 && j > 0) {
if (oldLines[i - 1] === newLines[j - 1]) {
result.push({
kind: DiffLineKind.CONTEXT,
text: oldLines[i - 1],
newLine: j,
oldLine: i,
newLine: j
text: oldLines[i - 1]
});
i--;
j--;
} else if (lcs[i - 1][j] >= lcs[i][j - 1]) {
result.push({ kind: DiffLineKind.REMOVE, text: oldLines[i - 1], oldLine: i });
result.push({ kind: DiffLineKind.REMOVE, oldLine: i, text: oldLines[i - 1] });
i--;
} else {
result.push({ kind: DiffLineKind.ADD, text: newLines[j - 1], newLine: j });
result.push({ kind: DiffLineKind.ADD, newLine: j, text: newLines[j - 1] });
j--;
}
}
while (i > 0) {
result.push({ kind: DiffLineKind.REMOVE, text: oldLines[i - 1], oldLine: i });
result.push({ kind: DiffLineKind.REMOVE, oldLine: i, text: oldLines[i - 1] });
i--;
}
while (j > 0) {
result.push({ kind: DiffLineKind.ADD, text: newLines[j - 1], newLine: j });
result.push({ kind: DiffLineKind.ADD, newLine: j, text: newLines[j - 1] });
j--;
}
result.reverse();
return result;
}
@@ -87,19 +92,25 @@ export function computeLineDiff(oldText: string, newText: string): DiffLine[] {
*/
export function renderUnifiedDiff(lines: DiffLine[]): string {
if (lines.length === 0) return '';
return lines.map((l) => prefixFor(l.kind) + l.text).join('\n');
}
/** Column-1 marker for a `DiffLine`: ` `, `+`, or `-`. */
export function prefixFor(kind: DiffLineKind): string {
if (kind === DiffLineKind.ADD) return '+';
if (kind === DiffLineKind.REMOVE) return '-';
return ' ';
}
function splitLines(text: string): string[] {
if (text === '') return [];
const parts = text.split('\n');
if (parts[parts.length - 1] === '') parts.pop();
return parts.map((l) => (l.endsWith('\r') ? l.slice(0, -1) : l));
}
+2
View File
@@ -27,6 +27,7 @@ export function getConfigValue<T extends SettingsConfigType>(
key: string
): string | number | boolean | undefined {
const value = (config as Record<string, unknown>)[key];
return value as string | number | boolean | undefined;
}
@@ -42,6 +43,7 @@ export function configToParameterRecord<T extends SettingsConfigType>(
for (const key of keys) {
const value = getConfigValue(config, key);
if (value !== undefined) {
record[key] = value;
}
@@ -47,13 +47,13 @@ export type ContentToken =
// Block wrappers browsers insert for newlines; each folds back into a
// single `\n` during serialization.
const BLOCK_TAG_NAMES = new Set(['DIV', 'P']);
// `file://` is required so plain URLs stay as text; `)` terminates only
// when not followed by whitespace or `[` (adjacent badges keep working).
const MENTION_BADGE_RE = fileMentionLinkRe('g');
function badgeSourceLength(name: string, path: string): number {
if (!name || !path) return 0;
return `[${name}](file://${path})`.length;
}
@@ -74,6 +74,7 @@ const CODE_SPAN_RE = /(```[\s\S]*?```)|(`[^`\n]+`)/g;
*/
export function containsCodeSpan(value: string): boolean {
CODE_SPAN_RE.lastIndex = 0;
return CODE_SPAN_RE.test(value);
}
@@ -89,11 +90,14 @@ const CODE_FENCE_RE = /```/g;
*/
export function isOffsetInCodeBlock(source: string, offset: number): boolean {
let inside = false;
CODE_FENCE_RE.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = CODE_FENCE_RE.exec(source)) !== null) {
if (match.index + match[0].length > offset) break;
inside = !inside;
}
@@ -110,10 +114,13 @@ export function isOffsetInCodeBlock(source: string, offset: number): boolean {
*/
export function tokenizeContent(input: string): ContentToken[] {
const tokens: ContentToken[] = [];
let cursor = 0;
CODE_SPAN_RE.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = CODE_SPAN_RE.exec(input)) !== null) {
const start = match.index;
@@ -141,9 +148,11 @@ export function tokenizeContent(input: string): ContentToken[] {
*/
function pushTextAndBadgeTokens(input: string, tokens: ContentToken[]) {
let cursor = 0;
MENTION_BADGE_RE.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = MENTION_BADGE_RE.exec(input)) !== null) {
const [whole, name, path] = match;
const start = match.index;
@@ -185,14 +194,17 @@ export function serializeContent(root: HTMLElement): string {
for (const child of Array.from(parent.childNodes)) {
if (child.nodeType === Node.TEXT_NODE) {
const text = child.textContent ?? '';
if (text.length > 0) {
if (pendingBlockBoundary) {
out += '\n';
pendingBlockBoundary = false;
}
out += text;
first = false;
}
continue;
}
@@ -203,55 +215,69 @@ export function serializeContent(root: HTMLElement): string {
if (el.dataset.mentionBadge === 'true') {
const name = el.dataset.mentionName ?? '';
const path = el.dataset.mentionPath ?? '';
if (name && path) {
if (pendingBlockBoundary) {
out += '\n';
pendingBlockBoundary = false;
}
out += `[${name}](file://${path})`;
first = false;
}
continue;
}
if (el.dataset.codeToken !== undefined) {
const isBlock = el.dataset.codeToken === 'block';
if (isBlock && (pendingBlockBoundary || !first)) out += '\n';
pendingBlockBoundary = false;
walk(el);
first = false;
if (isBlock) pendingBlockBoundary = true;
continue;
}
if (el.tagName === 'BR') {
const isHatch =
isCodeBlockElement(el.previousSibling) || isCodeBlockElement(el.nextSibling);
if (!isHatch && el.nextSibling) {
if (pendingBlockBoundary) {
out += '\n';
pendingBlockBoundary = false;
}
out += '\n';
first = false;
}
continue;
}
if (BLOCK_TAG_NAMES.has(el.tagName)) {
if (pendingBlockBoundary || !first) out += '\n';
pendingBlockBoundary = false;
walk(el);
first = false;
continue;
}
walk(el);
if (pendingBlockBoundary) first = false;
}
};
walk(root);
return out;
}
@@ -265,6 +291,7 @@ export function serializeContent(root: HTMLElement): string {
*/
export function domMatchesTokens(root: HTMLElement, tokens: ContentToken[]): boolean {
const expected = tokens.filter((token) => token.kind !== 'text');
let index = 0;
const walk = (parent: Node): boolean => {
@@ -277,21 +304,28 @@ export function domMatchesTokens(root: HTMLElement, tokens: ContentToken[]): boo
if (!isBadge && !isCode) {
if (!walk(el)) return false;
continue;
}
const token = expected[index++];
if (!token) return false;
if (isBadge) {
if (token.kind !== 'badge') return false;
if (token.name !== (el.dataset.mentionName ?? '')) return false;
if (token.path !== (el.dataset.mentionPath ?? '')) return false;
continue;
}
const codeKind = el.dataset.codeToken === 'block' ? 'codeBlock' : 'inlineCode';
if (token.kind !== codeKind) return false;
if (
(token.kind === 'inlineCode' || token.kind === 'codeBlock') &&
token.text !== (el.textContent ?? '')
@@ -319,6 +353,7 @@ export function rangeToTextOffset(root: HTMLElement, range: Range | null): numbe
// A point is at/before the caret iff it falls inside [root start, caret].
const pre = range.cloneRange();
pre.selectNodeContents(root);
pre.setEnd(range.endContainer, range.endOffset);
const atOrBeforeCaret = (node: Node, offset: number) => pre.comparePoint(node, offset) !== 1;
@@ -338,27 +373,39 @@ export function rangeToTextOffset(root: HTMLElement, range: Range | null): numbe
if (child.nodeType === Node.TEXT_NODE) {
const text = child.textContent ?? '';
if (text.length === 0) continue;
if (pendingPoint) {
const { node, index } = pendingPoint;
const { index, node } = pendingPoint;
pendingPoint = null;
if (!atOrBeforeCaret(node, index)) {
done = true;
return;
}
total += 1;
}
if (!atOrBeforeCaret(child, 0)) {
done = true;
return;
}
if (range.endContainer === child) {
total += range.endOffset;
done = true;
return;
}
total += text.length;
first = false;
continue;
}
@@ -369,52 +416,72 @@ export function rangeToTextOffset(root: HTMLElement, range: Range | null): numbe
const elIndex = Array.prototype.indexOf.call(parentNode.childNodes, el);
if (pendingPoint) {
const { node, index } = pendingPoint;
const { index, node } = pendingPoint;
pendingPoint = null;
if (!atOrBeforeCaret(node, index)) {
done = true;
return;
}
total += 1;
}
if (el.dataset.mentionBadge === 'true') {
const len = badgeSourceLength(el.dataset.mentionName ?? '', el.dataset.mentionPath ?? '');
if (len === 0) continue;
if (!atOrBeforeCaret(parentNode, elIndex + 1)) {
done = true;
return;
}
total += len;
first = false;
continue;
}
if (el.dataset.codeToken !== undefined) {
const isBlock = el.dataset.codeToken === 'block';
if (isBlock && !first) {
if (!atOrBeforeCaret(el, 0)) {
done = true;
return;
}
total += 1;
}
walk(el);
first = false;
if (isBlock) pendingPoint = { node: parentNode, index: elIndex + 1 };
if (isBlock) pendingPoint = { index: elIndex + 1, node: parentNode };
continue;
}
if (el.tagName === 'BR') {
const isHatch =
isCodeBlockElement(el.previousSibling) || isCodeBlockElement(el.nextSibling);
if (isHatch || !el.nextSibling) continue;
if (!atOrBeforeCaret(parentNode, elIndex + 1)) {
done = true;
return;
}
total += 1;
first = false;
continue;
}
@@ -422,22 +489,29 @@ export function rangeToTextOffset(root: HTMLElement, range: Range | null): numbe
if (!first) {
if (!atOrBeforeCaret(el, 0)) {
done = true;
return;
}
total += 1;
}
walk(el);
first = false;
continue;
}
const before = total;
walk(el);
if (total > before) first = false;
}
};
walk(root);
return total;
}
@@ -463,20 +537,25 @@ export function buildFragment(tokens: ContentToken[]): DocumentFragment {
if (tokens[index - 1]?.kind === 'codeBlock' && text.startsWith('\n')) {
text = text.slice(1);
}
if (tokens[index + 1]?.kind === 'codeBlock' && text.endsWith('\n')) {
text = text.slice(0, -1);
}
if (text.length === 0) continue;
fragment.appendChild(document.createTextNode(text));
continue;
}
if (token.kind === 'inlineCode' || token.kind === 'codeBlock') {
const code = document.createElement('code');
code.dataset.codeToken = token.kind === 'codeBlock' ? 'block' : 'inline';
code.textContent = token.text;
fragment.appendChild(code);
continue;
}
@@ -488,6 +567,7 @@ export function buildFragment(tokens: ContentToken[]): DocumentFragment {
}
const badge = document.createElement('span');
badge.dataset.mentionBadge = 'true';
badge.dataset.mentionName = token.name;
badge.dataset.mentionPath = token.path;
@@ -496,6 +576,7 @@ export function buildFragment(tokens: ContentToken[]): DocumentFragment {
badge.contentEditable = 'false';
const svg = document.createElementNS(MENTION_BADGE_SVG_ATTRIBUTES['xmlns'], 'svg');
for (const [attr, value] of Object.entries(MENTION_BADGE_SVG_ATTRIBUTES)) {
svg.setAttribute(attr, value);
}
@@ -505,11 +586,13 @@ export function buildFragment(tokens: ContentToken[]): DocumentFragment {
for (const d of getMentionBadgeIconPaths(token.path)) {
const path = document.createElementNS(MENTION_BADGE_SVG_ATTRIBUTES['xmlns'], 'path');
path.setAttribute('d', d);
svg.appendChild(path);
}
const label = document.createElement('span');
label.classList.add('shrink-0', 'truncate');
label.textContent = getMentionBadgeLabel(
token.name,
@@ -530,7 +613,9 @@ export function buildFragment(tokens: ContentToken[]): DocumentFragment {
// (badge, another block, an existing hatch) or a non-empty text node.
function hasLineBeside(node: Node | null): boolean {
if (!node) return false;
if (node.nodeType === Node.ELEMENT_NODE) return true;
return (node.textContent ?? '') !== '';
}
@@ -561,6 +646,7 @@ export function syncCodeBlockHatches(root: HTMLElement) {
// A `<br>` with no code block around is a real newline (browser
// Shift+Enter shape) and stays.
let prevElement = child.previousSibling;
while (prevElement && prevElement.nodeType !== Node.ELEMENT_NODE) {
prevElement = prevElement.previousSibling;
}
@@ -601,14 +687,17 @@ export function stripBlockBoundaryLineBreaks(root: HTMLElement): boolean {
for (const child of Array.from(root.childNodes)) {
if (child.nodeType !== Node.TEXT_NODE) continue;
if (!isCodeBlockElement(child.previousSibling)) continue;
let text = child.textContent ?? '';
if (!/^\n{2,}$/.test(text)) continue;
text = text.slice(1);
const atBufferEnd = !child.nextSibling || child.nextSibling.nodeName === 'BR';
if (atBufferEnd) {
text = text.slice(0, -1);
}
@@ -634,12 +723,15 @@ export function badgeAwareWordJump(
direction: 'forward' | 'backward'
): number | null {
let masked = '';
const badgeSpans: Array<[number, number]> = [];
for (const token of tokenizeContent(source)) {
const len =
token.kind === 'badge' ? badgeSourceLength(token.name, token.path) : token.text.length;
if (token.kind === 'badge') badgeSpans.push([masked.length, masked.length + len]);
masked += token.kind === 'badge' ? 'a'.repeat(len) : token.text;
}
@@ -649,6 +741,7 @@ export function badgeAwareWordJump(
const spanStartingAt = (index: number) => badgeSpans.find(([start]) => start === index);
const spanEndingAt = (index: number) => badgeSpans.find(([, end]) => end === index);
const n = masked.length;
let i = offset;
if (direction === 'forward') {
@@ -656,24 +749,32 @@ export function badgeAwareWordJump(
if (!(i < n && isWord(i))) {
while (i < n && !isWord(i)) i++;
}
while (i < n && isWord(i)) {
const span = spanStartingAt(i);
if (span) {
i = span[1];
break;
}
i++;
}
} else {
if (!(i > 0 && isWord(i - 1))) {
while (i > 0 && !isWord(i - 1)) i--;
}
while (i > 0 && isWord(i - 1)) {
const span = spanEndingAt(i);
if (span) {
i = span[0];
break;
}
i--;
}
}
@@ -682,6 +783,7 @@ export function badgeAwareWordJump(
const lo = Math.min(offset, i);
const hi = Math.max(offset, i);
return badgeSpans.some(([start, end]) => start < hi && end > lo) ? i : null;
}
@@ -692,7 +794,9 @@ export function badgeAwareWordJump(
*/
export function leadingBadgeEdgeOffset(source: string, caret: number): number | null {
const [first] = tokenizeContent(source);
if (!first || first.kind !== 'badge') return null;
return caret === badgeSourceLength(first.name, first.path) ? 0 : null;
}
@@ -708,6 +812,7 @@ export function leadingBadgeEdgeOffset(source: string, caret: number): number |
*/
export function textOffsetToRange(root: HTMLElement, offset: number): Range {
const range = document.createRange();
let remaining = offset;
let landed = false;
let pendingBlockBoundary = false;
@@ -717,7 +822,6 @@ export function textOffsetToRange(root: HTMLElement, offset: number): Range {
range.setEnd(node, nodeOffset);
landed = true;
};
const walk = (parent: Node) => {
let first = true;
@@ -726,23 +830,32 @@ export function textOffsetToRange(root: HTMLElement, offset: number): Range {
if (child.nodeType === Node.TEXT_NODE) {
const text = child.textContent ?? '';
if (text.length === 0) continue;
if (pendingBlockBoundary) {
// The synthesized separator maps to the near edge of the
// content that follows the block.
pendingBlockBoundary = false;
if (remaining === 0) {
land(child, 0);
return;
}
remaining -= 1;
}
if (remaining <= text.length) {
land(child, remaining);
return;
}
remaining -= text.length;
first = false;
continue;
}
@@ -752,17 +865,23 @@ export function textOffsetToRange(root: HTMLElement, offset: number): Range {
if (el.dataset.mentionBadge === 'true') {
const len = badgeSourceLength(el.dataset.mentionName ?? '', el.dataset.mentionPath ?? '');
if (len === 0) continue;
if (pendingBlockBoundary) {
pendingBlockBoundary = false;
if (remaining === 0) {
range.setStartBefore(el);
range.setEndBefore(el);
landed = true;
return;
}
remaining -= 1;
}
if (remaining <= len) {
if (remaining === 0) {
range.setStartBefore(el);
@@ -771,53 +890,72 @@ export function textOffsetToRange(root: HTMLElement, offset: number): Range {
range.setStartAfter(el);
range.setEndAfter(el);
}
landed = true;
return;
}
remaining -= len;
first = false;
continue;
}
if (el.dataset.codeToken !== undefined) {
const isBlock = el.dataset.codeToken === 'block';
if (isBlock && (pendingBlockBoundary || !first)) {
pendingBlockBoundary = false;
if (remaining === 0) {
range.setStartBefore(el);
range.setEndBefore(el);
landed = true;
return;
}
remaining -= 1;
}
const len = (el.textContent ?? '').length;
if (remaining === 0) {
range.setStartBefore(el);
range.setEndBefore(el);
landed = true;
return;
}
if (remaining === len) {
range.setStartAfter(el);
range.setEndAfter(el);
landed = true;
return;
}
if (remaining < len) {
walk(el);
return;
}
remaining -= len;
if (isBlock) remaining -= 1;
first = false;
continue;
}
if (el.tagName === 'BR') {
const isHatch =
isCodeBlockElement(el.previousSibling) || isCodeBlockElement(el.nextSibling);
if (isHatch) {
// Escape hatch: no source length; offset 0 lands before it
// so text typed there takes its place.
@@ -826,49 +964,66 @@ export function textOffsetToRange(root: HTMLElement, offset: number): Range {
range.setEndBefore(el);
landed = true;
}
continue;
}
if (!el.nextSibling) continue;
if (pendingBlockBoundary) {
pendingBlockBoundary = false;
if (remaining === 0) {
range.setStartBefore(el);
range.setEndBefore(el);
landed = true;
return;
}
remaining -= 1;
}
if (remaining === 0) {
range.setStartBefore(el);
range.setEndBefore(el);
landed = true;
return;
}
remaining -= 1;
first = false;
continue;
}
if (BLOCK_TAG_NAMES.has(el.tagName)) {
if (pendingBlockBoundary || !first) {
pendingBlockBoundary = false;
if (remaining === 0) {
// The boundary newline belongs to the previous line.
range.setStartBefore(el);
range.setEndBefore(el);
landed = true;
return;
}
remaining -= 1;
}
walk(el);
first = false;
continue;
}
const before = remaining;
walk(el);
if (remaining < before) first = false;
}
};
@@ -877,6 +1032,7 @@ export function textOffsetToRange(root: HTMLElement, offset: number): Range {
if (!landed) {
const last = root.lastChild;
if (last && last.nodeName === 'BR') {
range.setStartBefore(last);
range.setEndBefore(last);
@@ -1,14 +1,14 @@
import { convertPDFToImage, convertPDFToText } from './pdf-processing';
import { isSvgMimeType, svgBase64UrlToPngDataURL } from './svg-to-png';
import { isLikelyTextFile, readFileAsText } from './text-files';
import { isWebpMimeType, webpBase64UrlToPngDataURL } from './webp-to-png';
import { FileTypeCategory, AttachmentType, SpecialFileType } from '$lib/enums';
import { SETTINGS_KEYS } from '$lib/constants';
import { config, settingsStore } from '$lib/stores/settings.svelte';
import { AttachmentType, FileTypeCategory, SpecialFileType } from '$lib/enums';
import { modelsStore } from '$lib/stores/models.svelte';
import { config, settingsStore } from '$lib/stores/settings.svelte';
import type { ChatUploadedFile, DatabaseMessageExtra, FileProcessingResult } from '$lib/types';
import { getFileTypeCategory } from '$lib/utils';
import { readFileAsText, isLikelyTextFile } from './text-files';
import { toast } from 'svelte-sonner';
import type { FileProcessingResult, ChatUploadedFile, DatabaseMessageExtra } from '$lib/types';
function readFileAsBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
@@ -18,6 +18,7 @@ function readFileAsBase64(file: File): Promise<string> {
// Extract base64 data without the data URL prefix
const dataUrl = reader.result as string;
const base64 = dataUrl.split(',')[1];
resolve(base64);
};
@@ -37,13 +38,13 @@ export async function parseFilesToMessageExtras(
for (const file of files) {
if (file.type === SpecialFileType.MCP_PROMPT && file.mcpPrompt) {
extras.push({
type: AttachmentType.MCP_PROMPT,
name: file.name,
size: file.size,
serverName: file.mcpPrompt.serverName,
promptName: file.mcpPrompt.promptName,
arguments: file.mcpPrompt.arguments,
content: file.textContent ?? '',
arguments: file.mcpPrompt.arguments
name: file.name,
promptName: file.mcpPrompt.promptName,
serverName: file.mcpPrompt.serverName,
size: file.size,
type: AttachmentType.MCP_PROMPT
});
continue;
@@ -68,10 +69,10 @@ export async function parseFilesToMessageExtras(
}
extras.push({
type: AttachmentType.IMAGE,
base64Url,
name: file.name,
size: file.size,
base64Url
type: AttachmentType.IMAGE
});
}
} else if (getFileTypeCategory(file.type) === FileTypeCategory.AUDIO) {
@@ -80,11 +81,11 @@ export async function parseFilesToMessageExtras(
const base64Data = await readFileAsBase64(file.file);
extras.push({
type: AttachmentType.AUDIO,
base64Data: base64Data,
mimeType: file.type,
name: file.name,
size: file.size,
base64Data: base64Data,
mimeType: file.type
type: AttachmentType.AUDIO
});
} catch (error) {
console.error(`Failed to process audio file ${file.name}:`, error);
@@ -95,11 +96,11 @@ export async function parseFilesToMessageExtras(
const base64Data = await readFileAsBase64(file.file);
extras.push({
type: AttachmentType.VIDEO,
base64Data: base64Data,
mimeType: file.type,
name: file.name,
size: file.size,
base64Data: base64Data,
mimeType: file.type
type: AttachmentType.VIDEO
});
} catch (error) {
console.error(`Failed to process video file ${file.name}:`, error);
@@ -149,13 +150,13 @@ export async function parseFilesToMessageExtras(
);
extras.push({
type: AttachmentType.PDF,
name: file.name,
size: file.size,
base64Data: base64Data,
content: `PDF file with ${images.length} pages`,
images: images,
name: file.name,
processedAsImages: true,
base64Data: base64Data
size: file.size,
type: AttachmentType.PDF
});
} catch (imageError) {
console.warn(
@@ -167,12 +168,12 @@ export async function parseFilesToMessageExtras(
const content = await convertPDFToText(file.file);
extras.push({
type: AttachmentType.PDF,
name: file.name,
size: file.size,
base64Data: base64Data,
content: content,
name: file.name,
processedAsImages: false,
base64Data: base64Data
size: file.size,
type: AttachmentType.PDF
});
}
} else {
@@ -185,12 +186,12 @@ export async function parseFilesToMessageExtras(
});
extras.push({
type: AttachmentType.PDF,
name: file.name,
size: file.size,
base64Data: base64Data,
content: content,
name: file.name,
processedAsImages: false,
base64Data: base64Data
size: file.size,
type: AttachmentType.PDF
});
}
} catch (error) {
@@ -206,10 +207,10 @@ export async function parseFilesToMessageExtras(
emptyFiles.push(file.name);
} else if (isLikelyTextFile(content)) {
extras.push({
type: AttachmentType.TEXT,
content: content,
name: file.name,
size: file.size,
content: content
type: AttachmentType.TEXT
});
} else {
console.warn(`File ${file.name} appears to be binary and will be skipped`);
@@ -220,5 +221,5 @@ export async function parseFilesToMessageExtras(
}
}
return { extras, emptyFiles };
return { emptyFiles, extras };
}
+3
View File
@@ -16,11 +16,13 @@ export function getFileTypeLabel(input: string | undefined): string {
// Handle MIME types (contains '/')
if (input.includes('/')) {
const subtype = input.split('/').pop();
if (subtype) {
// Handle special cases like 'vnd.ms-excel' → 'EXCEL'
if (subtype.includes('.')) {
return subtype.split('.').pop()?.toUpperCase() || 'FILE';
}
return subtype.toUpperCase();
}
}
@@ -28,6 +30,7 @@ export function getFileTypeLabel(input: string | undefined): string {
// Handle file names (contains '.')
if (input.includes('.')) {
const ext = input.split('.').pop();
if (ext) return ext.toUpperCase();
}
+6 -4
View File
@@ -1,9 +1,9 @@
import {
AUDIO_FILE_TYPES,
VIDEO_FILE_TYPES,
IMAGE_FILE_TYPES,
PDF_FILE_TYPES,
TEXT_FILE_TYPES
TEXT_FILE_TYPES,
VIDEO_FILE_TYPES
} from '$lib/constants';
import {
FileExtensionAudio,
@@ -13,9 +13,9 @@ import {
FileTypeCategory,
MimeTypeApplication,
MimeTypeAudio,
MimeTypeVideo,
MimeTypeImage,
MimeTypeText
MimeTypeText,
MimeTypeVideo
} from '$lib/enums';
function normalizeMimeType(mimeType: string): string {
@@ -224,6 +224,7 @@ export function isFileTypeSupported(filename: string, mimeType?: string): boolea
// Images are detected and handled separately for vision models
if (mimeType) {
const category = getFileTypeCategory(mimeType);
if (
category === FileTypeCategory.IMAGE ||
category === FileTypeCategory.AUDIO ||
@@ -235,6 +236,7 @@ export function isFileTypeSupported(filename: string, mimeType?: string): boolea
// Check extension for known types (especially images without MIME)
const extCategory = getFileTypeCategoryByExtension(filename);
if (
extCategory === FileTypeCategory.IMAGE ||
extCategory === FileTypeCategory.AUDIO ||
+7 -5
View File
@@ -1,9 +1,9 @@
import {
MEDIUM_DURATION_THRESHOLD,
MS_PER_SECOND,
SECONDS_PER_MINUTE,
SECONDS_PER_HOUR,
SHORT_DURATION_THRESHOLD,
MEDIUM_DURATION_THRESHOLD
SECONDS_PER_MINUTE,
SHORT_DURATION_THRESHOLD
} from '$lib/constants';
/**
@@ -15,6 +15,7 @@ import {
*/
export function formatFileSize(bytes: number | unknown): string {
if (typeof bytes !== 'number') return 'Unknown';
if (bytes === 0) return '0 Bytes';
const k = 1024;
@@ -70,6 +71,7 @@ export function formatNumber(num: number | unknown): string {
export function formatJsonPretty(jsonString: string): string {
try {
const parsed = JSON.parse(jsonString);
return JSON.stringify(parsed, null, 2);
} catch {
return jsonString;
@@ -84,8 +86,8 @@ export function formatJsonPretty(jsonString: string): string {
*/
export function formatTime(date: Date): string {
return date.toLocaleTimeString('en-US', {
hour12: false,
hour: '2-digit',
hour12: false,
minute: '2-digit',
second: '2-digit'
});
@@ -114,7 +116,6 @@ export function formatPerformanceTime(ms: number): string {
const hours = Math.floor(totalSeconds / SECONDS_PER_HOUR);
const minutes = Math.floor((totalSeconds % SECONDS_PER_HOUR) / SECONDS_PER_MINUTE);
const seconds = Math.floor(totalSeconds % SECONDS_PER_MINUTE);
const parts: string[] = [];
if (hours > 0) {
@@ -149,5 +150,6 @@ export function formatAttachmentText(
extra?: string
): string {
const header = extra ? `${name} (${extra})` : name;
return `\n\n--- ${label}: ${header} ---\n${content}`;
}
+27 -21
View File
@@ -4,22 +4,22 @@
* result instead of re-walking the tree.
*/
import { BuiltInTool, GlobSearchType } from '$lib/enums';
import { ToolsService } from '$lib/services/tools.service';
import { lastPathSegment } from './path-display';
import {
buildGlobSearchArgs,
type GlobEntry,
type GlobSearchArgs,
joinPath,
rankEntries
} from './working-directory';
import {
GLOB_WILDCARD,
PATH_NAV_MAX_DEPTH,
PATH_SEPARATOR,
WINDOWS_SEPARATOR
} from '$lib/constants';
import { lastPathSegment } from './path-display';
import {
buildGlobSearchArgs,
joinPath,
rankEntries,
type GlobEntry,
type GlobSearchArgs
} from './working-directory';
import { BuiltInTool, GlobSearchType } from '$lib/enums';
import { ToolsService } from '$lib/services/tools.service';
const SEARCH_CACHE_TTL_MS = 2000;
@@ -45,13 +45,14 @@ export async function runGlobSearch(
): Promise<GlobSearchResult> {
const key = `${type}\u0000${args.path}\u0000${args.include}\u0000${args.maxDepth}\u0000${limit}`;
const cached = searchCache.get(key);
if (cached && Date.now() - cached.at < SEARCH_CACHE_TTL_MS) {
return { base: cached.base, entries: cached.results };
}
const res = await ToolsService.executeToolRaw(
BuiltInTool.FILE_GLOB_SEARCH,
{ path: args.path, type, include: args.include, max_depth: args.maxDepth, limit },
{ include: args.include, limit, max_depth: args.maxDepth, path: args.path, type },
signal
);
@@ -60,11 +61,13 @@ export async function runGlobSearch(
const base = typeof res.base === 'string' ? res.base : '';
const entries = Array.isArray(res.entries) ? (res.entries as GlobEntry[]) : [];
const now = Date.now();
// prune stale entries so the short-lived cache cannot grow unbounded
for (const [k, v] of searchCache) {
if (now - v.at >= SEARCH_CACHE_TTL_MS) searchCache.delete(k);
}
searchCache.set(key, { results: entries, base, at: now });
searchCache.set(key, { at: now, base, results: entries });
return { base, entries };
}
@@ -93,7 +96,7 @@ export interface GlobSearchChildResult {
}
function toEntryResult(e: GlobEntry, base: string): GlobEntryResult {
return { path: joinPath(base, e.path), name: lastPathSegment(e.path), type: e.type };
return { name: lastPathSegment(e.path), path: joinPath(base, e.path), type: e.type };
}
/**
@@ -110,19 +113,19 @@ export async function runGlobSearchWithChildren(
options: GlobSearchChildOptions = {}
): Promise<GlobSearchChildResult> {
const {
type = GlobSearchType.ALL,
childMaxDepth = PATH_NAV_MAX_DEPTH,
descendOnTrailingSeparator = false,
childMaxDepth = PATH_NAV_MAX_DEPTH
type = GlobSearchType.ALL
} = options;
const args = buildGlobSearchArgs(query, scopePath, searchDepth);
const res = await runGlobSearch(args, type, limit, signal);
if (res.error) return { base: res.base, args, entries: [], error: res.error };
if (res.error) return { args, base: res.base, entries: [], error: res.error };
const ranked = rankEntries(res.entries, args.rankQuery);
const entries = ranked.map((e) => toEntryResult(e, res.base));
const last = args.last;
if (last) {
const wantsDescend = descendOnTrailingSeparator
? query.endsWith(PATH_SEPARATOR) || query.endsWith(WINDOWS_SEPARATOR)
@@ -130,22 +133,25 @@ export async function runGlobSearchWithChildren(
const exact = ranked.find(
(e) => e.type === 'dir' && lastPathSegment(e.path).toLowerCase() === last.toLowerCase()
);
if (wantsDescend && exact) {
const exactDir = joinPath(res.base, exact.path);
const childRes = await runGlobSearch(
{ path: exactDir, include: GLOB_WILDCARD, maxDepth: childMaxDepth, rankQuery: '' },
{ include: GLOB_WILDCARD, maxDepth: childMaxDepth, path: exactDir, rankQuery: '' },
type,
limit,
signal
);
if (!childRes.error) {
const children = childRes.entries
.map((e) => toEntryResult(e, childRes.base))
.sort((a, b) => a.path.localeCompare(b.path));
return { base: res.base, args, entries: [...entries, ...children], exactDir };
return { args, base: res.base, entries: [...entries, ...children], exactDir };
}
}
}
return { base: res.base, args, entries };
return { args, base: res.base, entries };
}
+1
View File
@@ -12,6 +12,7 @@ export function parseHeadersToArray(headersJson: string): { key: string; value:
try {
const parsed = JSON.parse(headersJson);
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
return Object.entries(parsed).map(([key, value]) => ({
key,
+4 -3
View File
@@ -1,5 +1,5 @@
import { MimeTypeImage } from '$lib/enums';
import { HEIC_JPEG_QUALITY } from '$lib/constants/image-size';
import { MimeTypeImage } from '$lib/enums';
// heic requires a relatively large decoder, in order to reduce primary bundle size
// we lazily load this decoder from a CDN when needed, and cache it for future conversions
@@ -32,12 +32,13 @@ export async function heicFileToJpegDataURL(file: File | Blob): Promise<string>
const { heicTo } = await getHeicTo();
const jpegBlob = await heicTo({
blob: file,
type: MimeTypeImage.JPEG,
quality: HEIC_JPEG_QUALITY
quality: HEIC_JPEG_QUALITY,
type: MimeTypeImage.JPEG
});
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(jpegBlob);
+7 -7
View File
@@ -1,13 +1,13 @@
import {
EXIF_SCAN_BYTE_LIMIT,
JPEG_SOI_MARKER,
APP1_MARKER,
SOS_MARKER,
EXIF_SIGNATURE,
TIFF_LITTLE_ENDIAN,
TIFF_MAGIC,
EXIF_ORIENTATION_TAG,
IFD_ENTRY_SIZE
EXIF_SCAN_BYTE_LIMIT,
EXIF_SIGNATURE,
IFD_ENTRY_SIZE,
JPEG_SOI_MARKER,
SOS_MARKER,
TIFF_LITTLE_ENDIAN,
TIFF_MAGIC
} from '$lib/constants/jpeg-exif';
import { MimeTypeImage } from '$lib/enums';
+21 -2
View File
@@ -15,10 +15,10 @@ import {
LATEX_INLINE_CONVERT_REGEXP,
LATEX_INLINE_DELIMITER,
LATEX_INLINE_OPEN,
LATEX_LINEBREAK_REGEXP,
LATEX_MATH_AND_CODE_PATTERN,
LATEX_MHCHEM_CE,
LATEX_MHCHEM_PU,
LATEX_LINEBREAK_REGEXP,
LATEX_NEIGHBOR_CHAR_REGEXP,
LATEX_NON_WHITESPACE_REGEXP,
LATEX_PLACEHOLDER_REGEXP,
@@ -45,6 +45,7 @@ export function maskInlineLaTeX(content: string, latexExpressions: string[]): st
if (!content.includes(LATEX_INLINE_DELIMITER)) {
return content;
}
return content
.split(NEWLINE)
.map((line) => {
@@ -60,6 +61,7 @@ export function maskInlineLaTeX(content: string, latexExpressions: string[]): st
if (openDollarIndex == -1) {
processedLine += line.slice(currentPosition);
break;
}
@@ -68,6 +70,7 @@ export function maskInlineLaTeX(content: string, latexExpressions: string[]): st
if (closeDollarIndex == -1) {
processedLine += line.slice(currentPosition);
break;
}
@@ -107,6 +110,7 @@ export function maskInlineLaTeX(content: string, latexExpressions: string[]): st
// Treat as LaTeX
processedLine += line.slice(currentPosition, openDollarIndex);
const latexContent = line.slice(openDollarIndex, closeDollarIndex + 1);
latexExpressions.push(latexContent);
processedLine += `<<LATEX_${latexExpressions.length - 1}>>`;
currentPosition = closeDollarIndex + 1;
@@ -147,7 +151,6 @@ function escapeMhchem(text: string): string {
}
const doEscapeMhchem = false;
/**
* Preprocesses markdown content to safely handle LaTeX math expressions while protecting
* against false positives (e.g., dollar amounts like $5.99) and ensuring proper rendering.
@@ -179,6 +182,7 @@ export function preprocessLaTeX(content: string): string {
// incomplete code block stays the same across multiple tokens, so the
// full protect/restore pipeline would re-run unnecessarily.
const cached = latexCache.get(content);
if (cached !== undefined) return cached;
// Save original before the function mutates `content` through steps 0-8
@@ -193,7 +197,9 @@ export function preprocessLaTeX(content: string): string {
if (latexCache.size >= LATEX_CACHE_MAX_SIZE) {
latexCache.delete(latexCache.keys().next().value!);
}
latexCache.set(originalContent, content);
return content;
}
@@ -203,12 +209,16 @@ export function preprocessLaTeX(content: string): string {
const lines = content.split(NEWLINE);
const processedLines = lines.map((line, index) => {
const match = line.match(LATEX_BLOCKQUOTE_PREFIX_REGEXP);
if (match) {
blockquoteMarkers.set(index, match[1]);
return line.slice(match[1].length);
}
return line;
});
content = processedLines.join(NEWLINE);
// Step 1: Protect code blocks
@@ -232,7 +242,9 @@ export function preprocessLaTeX(content: string): string {
if (group1.endsWith(LATEX_BACKSLASH)) {
return match; // Backslash before \[, do nothing.
}
const hasSuffix = LATEX_NON_WHITESPACE_REGEXP.test(group3);
let optBreak;
if (hasSuffix) {
@@ -264,15 +276,19 @@ export function preprocessLaTeX(content: string): string {
// Step 4: Restore protected LaTeX expressions (they are valid)
content = content.replace(LATEX_PLACEHOLDER_REGEXP, (_, index) => {
let expr = latexExpressions[parseInt(index)];
const match = expr.match(LATEX_LINEBREAK_REGEXP);
if (match) {
// Katex: The $$-delimiters should be in their own line
// if there are \\-line-breaks.
const formula = match[1];
const prefix = formula.startsWith(NEWLINE) ? '' : NEWLINE;
const suffix = formula.endsWith(NEWLINE) ? '' : NEWLINE;
expr = LATEX_DISPLAY_DELIMITER + prefix + formula + suffix + LATEX_DISPLAY_DELIMITER;
}
return expr;
});
@@ -313,14 +329,17 @@ export function preprocessLaTeX(content: string): string {
const finalLines = content.split(NEWLINE);
const restoredLines = finalLines.map((line, index) => {
const marker = blockquoteMarkers.get(index);
return marker ? marker + line : line;
});
content = restoredLines.join(NEWLINE);
}
if (latexCache.size >= LATEX_CACHE_MAX_SIZE) {
latexCache.delete(latexCache.keys().next().value!);
}
latexCache.set(originalContent, content);
return content;
+40 -32
View File
@@ -1,40 +1,40 @@
import type { MCPServerSettingsEntry, MCPResourceContent, MCPResourceInfo } from '$lib/types';
import {
MCPTransportType,
MCPLogLevel,
UrlProtocol,
MimeTypePrefix,
MimeTypeIncludes,
UriPattern,
MimeTypeText
} from '$lib/enums';
import {
MCP_SERVER_ID_PREFIX,
IMAGE_FILE_EXTENSION_REGEX,
CODE_FILE_EXTENSION_REGEX,
TEXT_FILE_EXTENSION_REGEX,
PROTOCOL_PREFIX_REGEX,
FILE_EXTENSION_REGEX,
DISPLAY_NAME_SEPARATOR_REGEX,
PATH_SEPARATOR,
RESOURCE_TEXT_CONTENT_SEPARATOR,
DEFAULT_RESOURCE_FILENAME,
MCP_SSE_ENDPOINT,
MCP_SSE_ENDPOINT_SLASH,
MCP_SSE_ENDPOINT_QUERY
} from '$lib/constants';
import {
AlertTriangle,
Code,
Database,
File,
FileText,
Image,
Code,
Info,
AlertTriangle,
XCircle
} from '@lucide/svelte';
import type { Component } from 'svelte';
import {
CODE_FILE_EXTENSION_REGEX,
DEFAULT_RESOURCE_FILENAME,
DISPLAY_NAME_SEPARATOR_REGEX,
FILE_EXTENSION_REGEX,
IMAGE_FILE_EXTENSION_REGEX,
MCP_SERVER_ID_PREFIX,
MCP_SSE_ENDPOINT,
MCP_SSE_ENDPOINT_QUERY,
MCP_SSE_ENDPOINT_SLASH,
PATH_SEPARATOR,
PROTOCOL_PREFIX_REGEX,
RESOURCE_TEXT_CONTENT_SEPARATOR,
TEXT_FILE_EXTENSION_REGEX
} from '$lib/constants';
import {
MCPLogLevel,
MCPTransportType,
MimeTypeIncludes,
MimeTypePrefix,
MimeTypeText,
UriPattern,
UrlProtocol
} from '$lib/enums';
import type { MCPResourceContent, MCPResourceInfo, MCPServerSettingsEntry } from '$lib/types';
import type { MimeTypeUnion } from '$lib/types/common';
import type { Component } from 'svelte';
/**
* Detects the MCP transport type from a URL.
@@ -73,6 +73,7 @@ export function parseMcpServerSettings(rawServers: unknown): MCPServerSettingsEn
if (typeof rawServers === 'string') {
const trimmed = rawServers.trim();
if (!trimmed) return [];
try {
@@ -97,12 +98,12 @@ export function parseMcpServerSettings(rawServers: unknown): MCPServerSettingsEn
: `${MCP_SERVER_ID_PREFIX}-${index + 1}`;
return {
id,
enabled: Boolean((entry as { enabled?: unknown })?.enabled),
url,
name: (entry as { name?: string })?.name,
displayName: (entry as { displayName?: string })?.displayName,
enabled: Boolean((entry as { enabled?: unknown })?.enabled),
headers: headers || undefined,
id,
name: (entry as { name?: string })?.name,
url,
useProxy: Boolean((entry as { useProxy?: unknown })?.useProxy)
} satisfies MCPServerSettingsEntry;
});
@@ -161,6 +162,7 @@ export function isImageMimeType(mimeType?: MimeTypeUnion): boolean {
export function parseResourcePath(uri: string): string[] {
try {
const withoutProtocol = uri.replace(PROTOCOL_PREFIX_REGEX, '');
return withoutProtocol.split(PATH_SEPARATOR).filter((p) => p.length > 0);
} catch {
return [uri];
@@ -176,6 +178,7 @@ export function parseResourcePath(uri: string): string[] {
*/
export function getDisplayName(pathPart: string): string {
const withoutExt = pathPart.replace(FILE_EXTENSION_REGEX, '');
return withoutExt
.split(DISPLAY_NAME_SEPARATOR_REGEX)
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
@@ -191,6 +194,7 @@ export function getDisplayName(pathPart: string): string {
export function getResourceDisplayName(resource: MCPResourceInfo): string {
try {
const parts = parseResourcePath(resource.uri);
return parts[parts.length - 1] || resource.name || resource.uri;
} catch {
return resource.name || resource.uri;
@@ -207,6 +211,7 @@ export function getResourceDisplayName(resource: MCPResourceInfo): string {
export function isCodeResource(mimeType?: MimeTypeUnion, uri?: string): boolean {
const mime = mimeType?.toLowerCase() || '';
const u = uri?.toLowerCase() || '';
return (
mime.includes(MimeTypeIncludes.JSON) ||
mime.includes(MimeTypeIncludes.JAVASCRIPT) ||
@@ -225,6 +230,7 @@ export function isCodeResource(mimeType?: MimeTypeUnion, uri?: string): boolean
export function isImageResource(mimeType?: MimeTypeUnion, uri?: string): boolean {
const mime = mimeType?.toLowerCase() || '';
const u = uri?.toLowerCase() || '';
return mime.startsWith(MimeTypePrefix.IMAGE) || IMAGE_FILE_EXTENSION_REGEX.test(u);
}
@@ -271,6 +277,7 @@ export function getResourceIcon(mimeType?: MimeTypeUnion, uri?: string): Compone
*/
export function getResourceTextContent(content: MCPResourceContent[] | null | undefined): string {
if (!content) return '';
return content
.filter((c): c is { uri: string; mimeType?: MimeTypeUnion; text: string } => 'text' in c)
.map((c) => c.text)
@@ -308,6 +315,7 @@ export function downloadResourceContent(
const blob = new Blob([text], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
+7 -2
View File
@@ -1,9 +1,9 @@
import { abbreviateHome, lastPathSegment } from './path-display';
import { FILE_URI_PREFIX } from '$lib/constants';
import {
MENTION_BADGE_FILE_ICON_PATHS,
MENTION_BADGE_FOLDER_ICON_PATHS
} from '$lib/constants/mention-badge';
import { FILE_URI_PREFIX } from '$lib/constants';
import { FileMentionEntryType } from '$lib/enums';
import type { FileMentionEntry } from '$lib/types';
@@ -59,8 +59,11 @@ export function getMentionBadgeLabel(
home?: string | null
): string {
if (!showFullPath) return name;
const decoded = decodeFileLinkPath(path.replace(/\/+$/, ''));
if (!decoded) return name;
return abbreviateHome(decoded, home);
}
@@ -75,6 +78,7 @@ export function buildMentionInsertion(
token: { start: number; end: number }
): { newValue: string; caretOffset: number } | null {
if (token.start < 0 || token.end > value.length || token.start > token.end) return null;
// Strip the entry's directory marker so it is not doubled below.
const cleanedPath = entry.path.replace(/\/+$/, '');
const pathWithSeparator =
@@ -82,5 +86,6 @@ export function buildMentionInsertion(
const basename = lastPathSegment(cleanedPath) || entry.name;
const insertion = `[${basename}](${FILE_URI_PREFIX}${encodeFileLinkPath(pathWithSeparator)}) `;
const newValue = value.slice(0, token.start) + insertion + value.slice(token.end);
return { newValue, caretOffset: token.start + insertion.length };
return { caretOffset: token.start + insertion.length, newValue };
}
+11 -3
View File
@@ -27,29 +27,35 @@ export function findMentionToken(
if (cursor <= 0 || cursor > value.length) return null;
let atIndex = -1;
for (let i = cursor - 1; i >= 0; i--) {
const ch = value[i];
if (ch === '@') {
const prev = i > 0 ? value[i - 1] : '';
if (i === 0 || TOKEN_BOUNDARY_CHARS.has(prev)) {
atIndex = i;
}
break;
}
if (TOKEN_BOUNDARY_CHARS.has(ch)) break;
}
if (atIndex === -1) return null;
let end = atIndex + 1;
while (end < value.length && !TOKEN_BOUNDARY_CHARS.has(value[end])) {
end++;
}
return {
start: atIndex,
end,
query: value.slice(atIndex + 1, end)
query: value.slice(atIndex + 1, end),
start: atIndex
};
}
@@ -68,6 +74,8 @@ export function takeMentionDismissSnapshot(
cursor: number
): MentionDismissSnapshot | null {
const token = findMentionToken(value, cursor);
if (!token) return null;
return { start: token.start, query: token.query };
return { query: token.query, start: token.start };
}
@@ -3,9 +3,9 @@
* Ensures only compatible file types are processed based on model capabilities
*/
import { getFileTypeCategory } from '$lib/utils';
import { FileTypeCategory } from '$lib/enums';
import type { ModalityCapabilities } from '$lib/types';
import { getFileTypeCategory } from '$lib/utils';
/**
* Check if a file type is supported by the given modalities
@@ -72,11 +72,11 @@ export function filterFilesByModalities(
const supportedFiles: File[] = [];
const unsupportedFiles: File[] = [];
const modalityReasons: Record<string, string> = {};
const { hasVision, hasAudio, hasVideo } = capabilities;
const { hasAudio, hasVideo, hasVision } = capabilities;
for (const file of files) {
const category = getFileTypeCategory(file.type);
let isSupported = true;
let reason = '';
@@ -86,6 +86,7 @@ export function filterFilesByModalities(
isSupported = false;
reason = 'Images require a vision-capable model';
}
break;
case FileTypeCategory.AUDIO:
@@ -93,6 +94,7 @@ export function filterFilesByModalities(
isSupported = false;
reason = 'Audio files require an audio-capable model';
}
break;
case FileTypeCategory.VIDEO:
@@ -100,6 +102,7 @@ export function filterFilesByModalities(
isSupported = false;
reason = 'Video files require a video-capable model';
}
break;
case FileTypeCategory.TEXT:
@@ -121,7 +124,7 @@ export function filterFilesByModalities(
}
}
return { supportedFiles, unsupportedFiles, modalityReasons };
return { modalityReasons, supportedFiles, unsupportedFiles };
}
/**
@@ -138,23 +141,28 @@ export function generateModalityErrorMessage(
): string {
if (unsupportedFiles.length === 0) return '';
const { hasVision, hasAudio, hasVideo } = capabilities;
const { hasAudio, hasVideo, hasVision } = capabilities;
let message = '';
if (unsupportedFiles.length === 1) {
const file = unsupportedFiles[0];
const reason = modalityReasons[file.name];
message = `The file "${file.name}" cannot be uploaded: ${reason}.`;
} else {
const fileNames = unsupportedFiles.map((f) => f.name).join(', ');
message = `The following files cannot be uploaded: ${fileNames}.`;
}
// Add helpful information about what is supported
const supportedTypes: string[] = ['text files', 'PDFs'];
if (hasVision) supportedTypes.push('images');
if (hasAudio) supportedTypes.push('audio files');
if (hasVideo) supportedTypes.push('video files');
message += ` This model supports: ${supportedTypes.join(', ')}.`;
@@ -2,8 +2,10 @@ export function parseExecShellCommandError(
toolResultString: string | undefined
): string | undefined {
if (!toolResultString) return undefined;
try {
const parsed: unknown = JSON.parse(toolResultString);
if (
parsed &&
typeof parsed === 'object' &&
@@ -15,5 +17,6 @@ export function parseExecShellCommandError(
} catch {
// Plain-text result = stdout/stderr, no structured error to surface.
}
return undefined;
}
@@ -24,12 +24,13 @@ export function parseExecShellCommandExitStatus(
if (!toolResultString) return undefined;
const match = toolResultString.match(EXIT_CODE_TAIL_REGEX);
if (!match) return undefined;
return {
code: Number.parseInt(match[1], 10),
timedOut: match[0].includes('exit due to timed out'),
rawText: match[0]
rawText: match[0],
timedOut: match[0].includes('exit due to timed out')
};
}
@@ -43,5 +44,6 @@ export function isExitCodeSummaryLine(
status: ExecShellExitStatus | undefined
): boolean {
if (!status) return false;
return lineText.trim() === status.rawText.trim();
}
@@ -7,13 +7,11 @@ const JSON_OBJECT_OPEN = '{';
const JSON_OBJECT_CLOSE = '}';
const JSON_ARRAY_OPEN = '[';
const JSON_ARRAY_CLOSE = ']';
// Trailing punctuation to strip before re-closing a partial object/array.
// Matches an optional trailing comma plus any trailing whitespace; lets
// us re-emit a syntactically-valid JSON document without an orphaned
// comma when the model cut off mid-key.
const TRAILING_JSON_PUNCTUATION_REGEX = /,?\s*$/;
/** Bounded cache for parsePartialJsonArgs results. */
const PARTIAL_JSON_CACHE_MAX_SIZE = 32;
const partialJsonCache = new Map<string, Record<string, unknown> | null>();
@@ -22,6 +20,7 @@ function cacheResult(input: string, result: Record<string, unknown> | null): voi
if (partialJsonCache.size >= PARTIAL_JSON_CACHE_MAX_SIZE) {
partialJsonCache.delete(partialJsonCache.keys().next().value!);
}
partialJsonCache.set(input, result);
}
@@ -32,12 +31,14 @@ function cacheResult(input: string, result: Record<string, unknown> | null): voi
// render during streaming even when toolArgs hasn't changed.
export function parsePartialJsonArgs(toolArgsString: string): Record<string, unknown> | null {
const cached = partialJsonCache.get(toolArgsString);
if (cached !== undefined) return cached;
let result: Record<string, unknown> | null;
try {
const parsed: unknown = JSON.parse(toolArgsString);
result =
parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
@@ -47,6 +48,7 @@ export function parsePartialJsonArgs(toolArgsString: string): Record<string, unk
}
cacheResult(toolArgsString, result);
return result;
}
@@ -54,41 +56,55 @@ export function parsePartialJsonArgs(toolArgsString: string): Record<string, unk
function scanPartialJson(toolArgsString: string): Record<string, unknown> | null {
let inString = false;
let escape = false;
const stack: ('{' | '[')[] = [];
for (let i = 0; i < toolArgsString.length; i++) {
const ch = toolArgsString[i];
if (escape) {
escape = false;
continue;
}
if (ch === JSON_BACKSLASH && inString) {
escape = true;
continue;
}
if (ch === JSON_QUOTE) {
inString = !inString;
continue;
}
if (inString) continue;
if (ch === JSON_OBJECT_OPEN) stack.push(JSON_OBJECT_OPEN);
else if (ch === JSON_OBJECT_CLOSE) {
if (stack.length === 0 || stack[stack.length - 1] !== JSON_OBJECT_OPEN) return null;
stack.pop();
} else if (ch === JSON_ARRAY_OPEN) stack.push(JSON_ARRAY_OPEN);
else if (ch === JSON_ARRAY_CLOSE) {
if (stack.length === 0 || stack[stack.length - 1] !== JSON_ARRAY_OPEN) return null;
stack.pop();
}
}
let completed = toolArgsString;
if (escape) {
// Dangling escape at end of partial JSON: escape the trailing
// backslash as a literal so we can close the string cleanly.
completed += JSON_BACKSLASH;
}
if (inString) completed += JSON_QUOTE;
if (!inString) completed = completed.replace(TRAILING_JSON_PUNCTUATION_REGEX, '');
// Close in reverse nesting order: innermost container first.
@@ -98,6 +114,7 @@ function scanPartialJson(toolArgsString: string): Record<string, unknown> | null
try {
const parsed: unknown = JSON.parse(completed);
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: null;
+19 -5
View File
@@ -1,5 +1,3 @@
import { PATH_SEPARATOR } from '$lib/constants/mcp-resource';
import { TRAILING_SLASHES_REGEX } from '$lib/constants/url';
import {
CWD_CHANGED_PREFIX,
CWD_CLEARED_TEXT,
@@ -8,10 +6,13 @@ import {
HOME_TILDE,
HOME_TILDE_PREFIX
} from '$lib/constants';
import { PATH_SEPARATOR } from '$lib/constants/mcp-resource';
import { TRAILING_SLASHES_REGEX } from '$lib/constants/url';
export function lastPathSegment(p: string): string {
const trimmed = p.replace(TRAILING_SLASHES_REGEX, '');
const idx = trimmed.lastIndexOf(PATH_SEPARATOR);
return idx === -1 ? trimmed : trimmed.slice(idx + 1);
}
@@ -22,10 +23,14 @@ export function abbreviateWorkingDir(
home: string | null | undefined
): string {
if (!path) return '';
if (!home) return lastPathSegment(path);
if (path === home) return HOME_TILDE;
if (path.startsWith(home + PATH_SEPARATOR))
return HOME_TILDE_PREFIX + path.slice(home.length + 1);
return lastPathSegment(path);
}
@@ -33,9 +38,12 @@ export function abbreviateWorkingDir(
// unchanged - used where the full path matters.
export function abbreviateHome(path: string, home: string | null | undefined): string {
if (!home) return path;
if (path === home) return HOME_TILDE;
if (path.startsWith(home + PATH_SEPARATOR))
return HOME_TILDE_PREFIX + path.slice(home.length + 1);
return path;
}
@@ -55,6 +63,7 @@ export interface CwdMessageInfo {
*/
export function formatCwdMessage(cwd: string, home: string | null): string {
const display = abbreviateWorkingDir(cwd, home);
return `${CWD_CHANGED_PREFIX}[${FILE_URI_PREFIX}${cwd}](${display}).`;
}
@@ -65,15 +74,20 @@ export function formatCwdMessage(cwd: string, home: string | null): string {
*/
export function parseCwdMessage(content: string): CwdMessageInfo | null {
const trimmed = content.trim();
if (trimmed === CWD_CLEARED_TEXT) {
return { path: null, display: '' };
return { display: '', path: null };
}
if (trimmed.startsWith(CWD_CHANGED_PREFIX)) {
const rest = trimmed.slice(CWD_CHANGED_PREFIX.length);
// not anchored to the end: guidance may follow the link
const link = rest.match(CWD_LINK_REGEX);
if (link) return { path: link[1], display: link[2] };
return { path: rest, display: rest };
if (link) return { display: link[2], path: link[1] };
return { display: rest, path: rest };
}
return null;
}
+7 -3
View File
@@ -16,6 +16,7 @@ if (browser) {
import('pdfjs-dist/build/pdf.worker.min.mjs?raw')
.then((workerModule) => {
const workerBlob = new Blob([workerModule.default], { type: 'application/javascript' });
pdfjs.GlobalWorkerOptions.workerSrc = URL.createObjectURL(workerBlob);
})
.catch(() => {
@@ -31,6 +32,7 @@ if (browser) {
async function getFileAsBuffer(file: File): Promise<ArrayBuffer> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (event) => {
if (event.target?.result) {
resolve(event.target.result as ArrayBuffer);
@@ -59,7 +61,6 @@ export async function convertPDFToText(file: File): Promise<string> {
const buffer = await getFileAsBuffer(file);
const pdf = await pdfjs.getDocument({ data: buffer }).promise;
const numPages = pdf.numPages;
const textContentPromises: Promise<TextContent>[] = [];
for (let i = 1; i <= numPages; i++) {
@@ -75,6 +76,7 @@ export async function convertPDFToText(file: File): Promise<string> {
return textItems.join('\n');
} catch (error) {
console.error('Error converting PDF to text:', error);
throw new Error(
`Failed to convert PDF to text: ${error instanceof Error ? error.message : 'Unknown error'}`
);
@@ -111,10 +113,11 @@ export async function convertPDFToImage(file: File, scale: number = 1.5): Promis
}
const task = page.render({
canvas: canvas,
canvasContext: ctx,
viewport: viewport,
canvas: canvas
viewport: viewport
});
pages.push(
task.promise.then(() => {
return canvas.toDataURL(MimeTypeImage.PNG);
@@ -125,6 +128,7 @@ export async function convertPDFToImage(file: File, scale: number = 1.5): Promis
return await Promise.all(pages);
} catch (error) {
console.error('Error converting PDF to images:', error);
throw new Error(
`Failed to convert PDF to images: ${error instanceof Error ? error.message : 'Unknown error'}`
);
+1
View File
@@ -4,6 +4,7 @@ export function portalToBody(node: HTMLElement) {
}
const target = document.body;
if (!target) {
return;
}
@@ -1,13 +1,13 @@
import { heicFileToJpegDataURL, isHeicMimeType } from './heic-to-jpeg';
import { convertPDFToText } from './pdf-processing';
import { isSvgMimeType, svgBase64UrlToPngDataURL } from './svg-to-png';
import { isWebpMimeType, webpBase64UrlToPngDataURL } from './webp-to-png';
import { heicFileToJpegDataURL, isHeicMimeType } from './heic-to-jpeg';
import { FileTypeCategory } from '$lib/enums';
import { SETTINGS_KEYS } from '$lib/constants';
import { FileTypeCategory } from '$lib/enums';
import { modelsStore } from '$lib/stores/models.svelte';
import { settingsStore } from '$lib/stores/settings.svelte';
import { toast } from 'svelte-sonner';
import { getFileTypeCategory } from '$lib/utils';
import { convertPDFToText } from './pdf-processing';
import { toast } from 'svelte-sonner';
/**
* Read a file as a data URL (base64 encoded)
@@ -17,6 +17,7 @@ import { convertPDFToText } from './pdf-processing';
function readFileAsDataURL(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(file);
@@ -31,6 +32,7 @@ function readFileAsDataURL(file: File): Promise<string> {
function readFileAsUTF8(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = () => reject(reader.error);
reader.readAsText(file);
@@ -58,11 +60,11 @@ export async function processFilesToChatUploaded(
for (const file of files) {
const id = Date.now().toString() + Math.random().toString(36).substr(2, 9);
const base: ChatUploadedFile = {
file,
id,
name: file.name,
size: file.size,
type: file.type,
file
type: file.type
};
try {
@@ -87,6 +89,7 @@ export async function processFilesToChatUploaded(
preview = await heicFileToJpegDataURL(file);
} catch (err) {
console.error('Failed to convert HEIC to PNG:', err);
continue;
}
}
@@ -96,6 +99,7 @@ export async function processFilesToChatUploaded(
// Extract text content from PDF for preview
try {
const textContent = await convertPDFToText(file);
results.push({ ...base, textContent });
} catch (err) {
console.warn('Failed to extract text from PDF, adding without content:', err);
@@ -107,9 +111,9 @@ export async function processFilesToChatUploaded(
? modelsStore.modelSupportsVision(activeModelId)
: false;
const currentConfig = settingsStore.config;
if (hasVisionSupport && !currentConfig.pdfAsImage) {
toast.info(`You can enable parsing PDF as images with vision models.`, {
duration: 8000,
action: {
label: 'Enable PDF as Images',
onClick: () => {
@@ -118,21 +122,25 @@ export async function processFilesToChatUploaded(
duration: 3000
});
}
}
},
duration: 8000
});
}
} else if (getFileTypeCategory(file.type) === FileTypeCategory.AUDIO) {
// Generate preview URL for audio files
const preview = await readFileAsDataURL(file);
results.push({ ...base, preview });
} else if (getFileTypeCategory(file.type) === FileTypeCategory.VIDEO) {
// Generate preview URL for video files
const preview = await readFileAsDataURL(file);
results.push({ ...base, preview });
} else {
// Fallback: treat unknown files as text
try {
const textContent = await readFileAsUTF8(file);
results.push({ ...base, textContent });
} catch (err) {
console.warn('Failed to read file as text, adding without content:', err);
+2 -1
View File
@@ -21,7 +21,7 @@ export function modelLoadFraction(progress: ModelLoadProgress | null): number {
// The server may emit a progress event before the stage plan is known, so
// `stages` can be absent. Fall back to the raw value in that case.
const { stages = [], current, value } = progress;
const { current, stages = [], value } = progress;
const tailCount = Math.max(stages.length - 1, 0);
const textCeiling = 1 - tailCount * MODEL_LOAD_TAIL_SHARE;
const idx = stages.indexOf(current);
@@ -41,6 +41,7 @@ export function modelLoadProgressText(progress: ModelLoadProgress | null): strin
if (!progress) return null;
const label = modelLoadStageLabel(progress.current);
if (!label) return null;
return `${label} ${Math.round(modelLoadFraction(progress) * 100)}%`;
+1 -1
View File
@@ -1,5 +1,5 @@
import DOMPurify from 'dompurify';
import { SVG_MAX_BYTES, SVG_SANITIZE_CONFIG, SVG_TAG_PREFIX } from '$lib/constants';
import DOMPurify from 'dompurify';
/**
* Sanitizes a raw svg string for safe inline rendering.
+2 -2
View File
@@ -1,8 +1,8 @@
import {
KEY_VALUE_PAIR_KEY_MAX_LENGTH,
KEY_VALUE_PAIR_VALUE_MAX_LENGTH,
KEY_VALUE_PAIR_UNSAFE_KEY_RE,
KEY_VALUE_PAIR_UNSAFE_VALUE_RE
KEY_VALUE_PAIR_UNSAFE_VALUE_RE,
KEY_VALUE_PAIR_VALUE_MAX_LENGTH
} from '$lib/constants';
/**
+30 -11
View File
@@ -26,32 +26,26 @@ export type SearchResult = {
const SEPARATOR_LINE_RE = /^\s*---\s*$/;
const URL_SCHEME_RE = /^https?:\/\//i;
// Match either Unix or Windows line endings so chunking/parsing handles
// payloads written by either scheme without off-by-one mismatches.
const LINE_BREAK_RE = /\r?\n/;
// Sentinel the search-result wire format uses when a field is absent
// (e.g. `Author: N/A`). Treated identically to a missing field so the
// rendered card hides the row either way.
const NOT_AVAILABLE_VALUE = 'N/A';
// Section header that announces the start of the multi-line Highlights
// block. Everything from that line onward (until the next `---`
// separator or end of chunk) is captured verbatim as highlight text
// instead of being re-scanned for `Title:`/`URL:`/... field lines.
const HIGHLIGHTS_SECTION_HEADER = 'Highlights:';
// Field name conventionally used by web-search tools (Exa etc.) as the
// user-supplied query parameter. Extracted so future tool schemas that
// adopt the same convention stay grep-compatible with this parser.
const SEARCH_TOOL_QUERY_FIELD = 'query';
// URL schemes the favicon helper will resolve to a hosted favicon. Any
// other scheme (e.g. data:, blob:) intentionally returns null so the UI
// can fall back to a generic globe icon.
const RESOLVABLE_URL_PROTOCOLS: readonly string[] = ['https:', 'http:'];
// Conventional favicon path served by virtually every web host.
// Appended to the URL origin as a best-effort lookup target; ignore
// 404s at render time.
@@ -83,7 +77,9 @@ const FIELD_PREFIXES: ReadonlyArray<{ key: FieldKey; prefix: string }> = [
function splitChunks(text: string): string[] {
const lines = text.split(LINE_BREAK_RE);
const chunks: string[] = [];
let buffer: string[] = [];
for (const line of lines) {
if (SEPARATOR_LINE_RE.test(line)) {
if (buffer.length > 0) {
@@ -94,7 +90,9 @@ function splitChunks(text: string): string[] {
buffer.push(line);
}
}
if (buffer.length > 0) chunks.push(buffer.join('\n'));
return chunks;
}
@@ -106,36 +104,42 @@ function splitChunks(text: string): string[] {
*/
function parseChunk(chunk: string): SearchResult | null {
const trimmed = chunk.trim();
if (!trimmed) return null;
const lines = chunk.split(LINE_BREAK_RE);
const fields: Record<FieldKey, string | undefined> = {
[FieldKey.TITLE]: undefined,
[FieldKey.URL]: undefined,
[FieldKey.AUTHOR]: undefined,
[FieldKey.PUBLISHED]: undefined,
[FieldKey.AUTHOR]: undefined
[FieldKey.TITLE]: undefined,
[FieldKey.URL]: undefined
};
const highlightLines: string[] = [];
let inHighlights = false;
for (const line of lines) {
if (!inHighlights && line.trim() === HIGHLIGHTS_SECTION_HEADER) {
inHighlights = true;
continue;
}
if (inHighlights) {
highlightLines.push(line);
continue;
}
for (const { key, prefix } of FIELD_PREFIXES) {
if (!line.startsWith(prefix)) continue;
const value = line.slice(prefix.length).trim();
if (value && value !== NOT_AVAILABLE_VALUE) {
fields[key] = value;
}
break;
}
}
@@ -144,14 +148,17 @@ function parseChunk(chunk: string): SearchResult | null {
return null;
const highlights = highlightLines.join('\n').trim();
const result: SearchResult = {
title: fields[FieldKey.TITLE],
url: fields[FieldKey.URL]
};
if (fields[FieldKey.PUBLISHED]) result.published = fields[FieldKey.PUBLISHED];
if (fields[FieldKey.AUTHOR]) result.author = fields[FieldKey.AUTHOR];
if (highlights) result.highlights = highlights;
return result;
}
@@ -170,17 +177,21 @@ export function extractSearchResults(text: string | undefined | null): SearchRes
if (!text) return [];
const cached = searchResultsCache.get(text);
if (cached) return cached;
const results: SearchResult[] = [];
for (const chunk of splitChunks(text)) {
const parsed = parseChunk(chunk);
if (parsed) results.push(parsed);
}
if (searchResultsCache.size >= SEARCH_RESULTS_CACHE_MAX_SIZE) {
searchResultsCache.delete(searchResultsCache.keys().next().value!);
}
searchResultsCache.set(text, results);
return results;
@@ -201,13 +212,17 @@ export function extractSearchQuery(toolArgs: string | undefined | null): string
if (!toolArgs) return '';
const cached = searchQueryCache.get(toolArgs);
if (cached !== undefined) return cached;
let result = '';
try {
const parsed: unknown = JSON.parse(toolArgs);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
const candidate = (parsed as Record<string, unknown>)[SEARCH_TOOL_QUERY_FIELD];
if (typeof candidate === 'string') result = candidate.trim();
}
} catch {
@@ -217,6 +232,7 @@ export function extractSearchQuery(toolArgs: string | undefined | null): string
if (searchQueryCache.size >= SEARCH_QUERY_CACHE_MAX_SIZE) {
searchQueryCache.delete(searchQueryCache.keys().next().value!);
}
searchQueryCache.set(toolArgs, result);
return result;
@@ -231,7 +247,9 @@ export function extractSearchQuery(toolArgs: string | undefined | null): string
export function faviconForUrl(url: string): string | null {
try {
const parsed = new URL(url);
if (!RESOLVABLE_URL_PROTOCOLS.includes(parsed.protocol)) return null;
return `${parsed.protocol}//${parsed.host}${FAVICON_PATH}`;
} catch {
return null;
@@ -255,5 +273,6 @@ export const SUPPORTED_WEB_SEARCH_TOOL_NAMES: readonly string[] = ['web_search_e
*/
export function isWebSearchToolName(toolName: string | undefined | null): boolean {
if (!toolName) return false;
return SUPPORTED_WEB_SEARCH_TOOL_NAMES.includes(toolName);
}
+8
View File
@@ -24,25 +24,33 @@ export class SourceHistory {
push(entry: SourceHistoryEntry, now: number, newGroup = false): void {
if (newGroup || now - this.lastPush >= this.groupWindowMs || this.undoStack.length === 0) {
this.undoStack.push(entry);
if (this.undoStack.length > this.limit) this.undoStack.shift();
}
this.lastPush = now;
this.redoStack = [];
}
undo(current: SourceHistoryEntry): SourceHistoryEntry | null {
const entry = this.undoStack.pop();
if (!entry) return null;
this.redoStack.push(current);
this.lastPush = 0; // the next edit after an undo starts a new group
return entry;
}
redo(current: SourceHistoryEntry): SourceHistoryEntry | null {
const entry = this.redoStack.pop();
if (!entry) return null;
this.undoStack.push(current);
this.lastPush = 0;
return entry;
}
}
+9
View File
@@ -30,9 +30,11 @@ export async function* parseSseJsonStream<T = unknown>(
signal?: AbortSignal
): AsyncGenerator<SseJsonEvent<T>> {
const reader = response.body?.getReader();
if (!reader) return;
const decoder = new TextDecoder();
let buffer = '';
try {
@@ -40,19 +42,26 @@ export async function* parseSseJsonStream<T = unknown>(
if (signal?.aborted) return;
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const records = buffer.split(SSE_RECORD_SEPARATOR);
buffer = records.pop() ?? '';
for (const record of records) {
if (!record) continue;
for (const line of record.split(SSE_LINE_SEPARATOR)) {
if (!line.startsWith(SSE_DATA_PREFIX)) continue;
const payload = line.slice(SSE_DATA_PREFIX.length).trim();
if (payload === SSE_DONE_MARKER) return;
if (!payload) continue;
try {
yield { data: JSON.parse(payload) as T };
} catch {
@@ -8,6 +8,8 @@
*/
export function streamIdentity(conversationId: string, model?: string | null): string {
if (!conversationId) return '';
if (!model) return conversationId;
return `${conversationId}::${model}`;
}
+1
View File
@@ -6,5 +6,6 @@
*/
export function mountSvgShadow(host: HTMLElement, markup: string, style: string): void {
const root = host.shadowRoot ?? host.attachShadow({ mode: 'open' });
root.innerHTML = markup ? `<style>${style}</style>${markup}` : '';
}
+3
View File
@@ -20,6 +20,7 @@ export function svgBase64UrlToPngDataURL(
if (!ctx) {
reject(new Error('Failed to get 2D canvas context.'));
return;
}
@@ -33,6 +34,7 @@ export function svgBase64UrlToPngDataURL(
ctx.fillStyle = backgroundColor;
ctx.fillRect(0, 0, canvas.width, canvas.height);
}
ctx.drawImage(img, 0, 0, targetWidth, targetHeight);
resolve(canvas.toDataURL(MimeTypeImage.PNG));
@@ -46,6 +48,7 @@ export function svgBase64UrlToPngDataURL(
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const errorMessage = `Error converting SVG to PNG: ${message}`;
console.error(errorMessage, error);
reject(new Error(errorMessage));
}
+1 -1
View File
@@ -4,8 +4,8 @@
*/
import { DEFAULT_BINARY_DETECTION_OPTIONS } from '$lib/constants';
import type { BinaryDetectionOptions } from '$lib/types';
import { FileExtensionText } from '$lib/enums';
import type { BinaryDetectionOptions } from '$lib/types';
/**
* Check if a filename indicates a text file based on its extension
+1
View File
@@ -15,6 +15,7 @@ export function getPreviewText(content: string, max = 150): string {
export function generateConversationTitle(content: string, useFirstLine: boolean = false): string {
if (useFirstLine) {
const firstLine = content.split(NEWLINE).find((line) => line.trim().length > 0);
return firstLine ? firstLine.trim() : content.trim();
}
+3
View File
@@ -15,11 +15,14 @@ export function tryParseToolResultObject(
toolResultString: string | undefined
): Record<string, unknown> | null {
if (!toolResultString) return null;
try {
const parsed: unknown = JSON.parse(toolResultString);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
return parsed as Record<string, unknown>;
}
return null;
} catch {
return null;
+4 -3
View File
@@ -1,11 +1,11 @@
import {
LEADING_SLASHES_REGEX,
TEMPLATE_EXPRESSION_REGEX,
URI_SCHEME_SEPARATOR,
URI_TEMPLATE_OPERATORS,
URI_TEMPLATE_SEPARATORS,
VARIABLE_EXPLODE_MODIFIER_REGEX,
VARIABLE_PREFIX_MODIFIER_REGEX,
LEADING_SLASHES_REGEX
VARIABLE_PREFIX_MODIFIER_REGEX
} from '../constants';
/**
@@ -25,6 +25,7 @@ import {
*/
export function normalizeResourceUri(uri: string): string {
const schemeEnd = uri.indexOf(URI_SCHEME_SEPARATOR);
if (schemeEnd === -1) return uri;
const scheme = uri.substring(0, schemeEnd);
@@ -65,6 +66,7 @@ export function extractTemplateVariables(template: string): UriTemplateVariable[
const seen = new Set<string>();
let match;
TEMPLATE_EXPRESSION_REGEX.lastIndex = 0;
while ((match = TEMPLATE_EXPRESSION_REGEX.exec(template)) !== null) {
@@ -117,7 +119,6 @@ export function expandTemplate(template: string, values: Record<string, string>)
.replace(VARIABLE_PREFIX_MODIFIER_REGEX, '')
.trim()
);
const expandedParts = varNames
.map((name: string) => values[name] ?? '')
.filter((v: string) => v !== '');
+1 -1
View File
@@ -28,6 +28,7 @@ function isIpAddress(hostname: string): boolean {
*/
export function extractRootDomain(url: URL): string | null {
const hostname = url.hostname.toLowerCase();
if (!hostname || isIpAddress(hostname)) return null;
const parts = hostname.split('.');
@@ -95,7 +96,6 @@ export function canonicalizeServerUrl(raw: string): string {
try {
const parsed = new URL(trimmed);
const pathname = parsed.pathname.replace(TRAILING_SLASHES_REGEX, '');
// Aggressive: drop the port unconditionally. We only use this for
// equality checks between user-typed URLs and a hard-coded list of
// recommendations, where the port can never carry distinguishing
+3
View File
@@ -20,6 +20,7 @@ export function webpBase64UrlToPngDataURL(
if (!ctx) {
reject(new Error('Failed to get 2D canvas context.'));
return;
}
@@ -33,6 +34,7 @@ export function webpBase64UrlToPngDataURL(
ctx.fillStyle = backgroundColor;
ctx.fillRect(0, 0, canvas.width, canvas.height);
}
ctx.drawImage(img, 0, 0, targetWidth, targetHeight);
resolve(canvas.toDataURL(MimeTypeImage.PNG));
@@ -46,6 +48,7 @@ export function webpBase64UrlToPngDataURL(
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const errorMessage = `Error converting WebP to PNG: ${message}`;
console.error(errorMessage, error);
reject(new Error(errorMessage));
}
+36 -13
View File
@@ -5,8 +5,7 @@
* the last segment); anything else glob-matches home-relative entries.
*/
import { PATH_SEPARATOR } from '$lib/constants/mcp-resource';
import { TRAILING_SLASHES_REGEX } from '$lib/constants/url';
import { lastPathSegment } from './path-display';
import {
DRIVE_PREFIX_REGEX,
DRIVE_ROOT_REGEX,
@@ -20,7 +19,8 @@ import {
UNC_ROOT_REGEX,
WINDOWS_SEPARATOR
} from '$lib/constants';
import { lastPathSegment } from './path-display';
import { PATH_SEPARATOR } from '$lib/constants/mcp-resource';
import { TRAILING_SLASHES_REGEX } from '$lib/constants/url';
export interface GlobEntry {
path: string;
@@ -38,14 +38,19 @@ export interface PathQuery {
*/
function toPosixSeparators(query: string): string {
if (!DRIVE_PREFIX_REGEX.test(query) && !query.startsWith(WINDOWS_SEPARATOR)) return query;
return query.split(WINDOWS_SEPARATOR).join(PATH_SEPARATOR);
}
export function rootPrefixLength(path: string): number {
const unc = path.match(UNC_ROOT_REGEX);
if (unc) return unc[0].length;
const drive = path.match(DRIVE_ROOT_REGEX);
if (drive) return drive[0].length;
return path.startsWith(PATH_SEPARATOR) ? PATH_SEPARATOR.length : 0;
}
@@ -53,6 +58,7 @@ export function rootPrefixLength(path: string): number {
export function splitPathQuery(query: string): PathQuery | null {
const normalized = toPosixSeparators(query);
const rootLength = rootPrefixLength(normalized);
if (rootLength === 0 && !normalized.startsWith(HOME_TILDE)) return null;
// a root keeps its trailing separator so it stays absolute on its own
@@ -60,33 +66,36 @@ export function splitPathQuery(query: string): PathQuery | null {
rootLength > 0
? normalized.slice(0, rootLength).replace(TRAILING_SLASHES_REGEX, '') + PATH_SEPARATOR
: HOME_TILDE;
const rest = normalized
.slice(rootLength > 0 ? rootLength : HOME_TILDE.length)
.replace(LEADING_SLASHES_REGEX, '')
.replace(TRAILING_SLASHES_REGEX, '');
const parentOf = (dirs: string) =>
rootLength > 0 ? root + dirs : HOME_TILDE + PATH_SEPARATOR + dirs;
if (!rest) return { parent: root, last: '' };
if (!rest) return { last: '', parent: root };
const idx = rest.lastIndexOf(PATH_SEPARATOR);
if (idx === -1) return { parent: root, last: rest };
return { parent: parentOf(rest.slice(0, idx)), last: rest.slice(idx + 1) };
if (idx === -1) return { last: rest, parent: root };
return { last: rest.slice(idx + 1), parent: parentOf(rest.slice(0, idx)) };
}
export function buildCaseInsensitiveGlob(query: string): string {
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;
// 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 out += c;
}
return out + GLOB_WILDCARD;
}
@@ -114,7 +123,8 @@ export function buildGlobSearchArgs(
: GLOB_WILDCARD
: buildCaseInsensitiveGlob(query);
const maxDepth = pathQuery ? PATH_NAV_MAX_DEPTH : searchDepth;
return { path, include, maxDepth, rankQuery: pathQuery?.last ?? query, last: pathQuery?.last };
return { include, last: pathQuery?.last, maxDepth, path, rankQuery: pathQuery?.last ?? query };
}
const RANK_EXACT = 0;
@@ -125,9 +135,13 @@ const RANK_OTHER = 3;
function rankScore(path: string, query: string): number {
const name = lastPathSegment(path).toLowerCase();
const q = query.toLowerCase();
if (name === q) return RANK_EXACT;
if (name.startsWith(q)) return RANK_PREFIX;
if (name.includes(q)) return RANK_SUBSTRING;
return RANK_OTHER;
}
@@ -142,24 +156,33 @@ export function rankEntries(entries: GlobEntry[], query: string): GlobEntry[] {
export function joinPath(base: string, rel: string): string {
if (!base) return rel;
return base.replace(TRAILING_SLASHES_REGEX, '') + PATH_SEPARATOR + rel;
}
export function highlightMatch(text: string, query: string): { text: string; match: boolean }[] {
if (!query) return [{ text, match: false }];
if (!query) return [{ match: false, text }];
const segments: { text: string; match: boolean }[] = [];
const lowerText = text.toLowerCase();
const lowerQuery = query.toLowerCase();
let i = 0;
while (i < text.length) {
const idx = lowerText.indexOf(lowerQuery, i);
if (idx < 0) {
segments.push({ text: text.slice(i), match: false });
segments.push({ match: false, text: text.slice(i) });
break;
}
if (idx > i) segments.push({ text: text.slice(i, idx), match: false });
segments.push({ text: text.slice(idx, idx + query.length), match: true });
if (idx > i) segments.push({ match: false, text: text.slice(i, idx) });
segments.push({ match: true, text: text.slice(idx, idx + query.length) });
i = idx + query.length;
}
return segments;
}