ui: Linting & Formatting scripts (#26819)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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'}`
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,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.
|
||||
|
||||
@@ -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';
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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}` : '';
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 !== '');
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user