diff --git a/tools/server/webui/package-lock.json b/tools/server/webui/package-lock.json index 4af5e86ab9..9c1c2499cf 100644 --- a/tools/server/webui/package-lock.json +++ b/tools/server/webui/package-lock.json @@ -64,7 +64,7 @@ "svelte": "^5.0.0", "svelte-check": "^4.0.0", "tailwind-merge": "^3.3.1", - "tailwind-variants": "^1.0.0", + "tailwind-variants": "^3.2.2", "tailwindcss": "^4.0.0", "tw-animate-css": "^1.3.5", "typescript": "^5.0.0", @@ -8324,31 +8324,23 @@ } }, "node_modules/tailwind-variants": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/tailwind-variants/-/tailwind-variants-1.0.0.tgz", - "integrity": "sha512-2WSbv4ulEEyuBKomOunut65D8UZwxrHoRfYnxGcQNnHqlSCp2+B7Yz2W+yrNDrxRodOXtGD/1oCcKGNBnUqMqA==", + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/tailwind-variants/-/tailwind-variants-3.2.2.tgz", + "integrity": "sha512-Mi4kHeMTLvKlM98XPnK+7HoBPmf4gygdFmqQPaDivc3DpYS6aIY6KiG/PgThrGvii5YZJqRsPz0aPyhoFzmZgg==", "dev": true, "license": "MIT", - "dependencies": { - "tailwind-merge": "3.0.2" - }, "engines": { "node": ">=16.x", "pnpm": ">=7.x" }, "peerDependencies": { + "tailwind-merge": ">=3.0.0", "tailwindcss": "*" - } - }, - "node_modules/tailwind-variants/node_modules/tailwind-merge": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.0.2.tgz", - "integrity": "sha512-l7z+OYZ7mu3DTqrL88RiKrKIqO3NcpEO8V/Od04bNpvk0kiIFndGEoqfuzvj4yuhRkHKjRkII2z+KS2HfPcSxw==", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/dcastil" + }, + "peerDependenciesMeta": { + "tailwind-merge": { + "optional": true + } } }, "node_modules/tailwindcss": { diff --git a/tools/server/webui/package.json b/tools/server/webui/package.json index 8b88f691a4..987a7239ed 100644 --- a/tools/server/webui/package.json +++ b/tools/server/webui/package.json @@ -66,7 +66,7 @@ "svelte": "^5.0.0", "svelte-check": "^4.0.0", "tailwind-merge": "^3.3.1", - "tailwind-variants": "^1.0.0", + "tailwind-variants": "^3.2.2", "tailwindcss": "^4.0.0", "tw-animate-css": "^1.3.5", "typescript": "^5.0.0", diff --git a/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentPreview.svelte b/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentPreview.svelte index fa01075496..ea8b330d68 100644 --- a/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentPreview.svelte +++ b/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentPreview.svelte @@ -1,9 +1,13 @@ + +
+
{@html highlightedHtml}
+
+ + diff --git a/tools/server/webui/src/lib/components/ui/alert/alert-description.svelte b/tools/server/webui/src/lib/components/ui/alert/alert-description.svelte new file mode 100644 index 0000000000..440d0069d3 --- /dev/null +++ b/tools/server/webui/src/lib/components/ui/alert/alert-description.svelte @@ -0,0 +1,23 @@ + + +
+ {@render children?.()} +
diff --git a/tools/server/webui/src/lib/components/ui/alert/alert-title.svelte b/tools/server/webui/src/lib/components/ui/alert/alert-title.svelte new file mode 100644 index 0000000000..0721aebf12 --- /dev/null +++ b/tools/server/webui/src/lib/components/ui/alert/alert-title.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/tools/server/webui/src/lib/components/ui/alert/alert.svelte b/tools/server/webui/src/lib/components/ui/alert/alert.svelte new file mode 100644 index 0000000000..7d79e4bc0e --- /dev/null +++ b/tools/server/webui/src/lib/components/ui/alert/alert.svelte @@ -0,0 +1,44 @@ + + + + + diff --git a/tools/server/webui/src/lib/components/ui/alert/index.ts b/tools/server/webui/src/lib/components/ui/alert/index.ts new file mode 100644 index 0000000000..5e0f854da6 --- /dev/null +++ b/tools/server/webui/src/lib/components/ui/alert/index.ts @@ -0,0 +1,14 @@ +import Root from './alert.svelte'; +import Description from './alert-description.svelte'; +import Title from './alert-title.svelte'; +export { alertVariants, type AlertVariant } from './alert.svelte'; + +export { + Root, + Description, + Title, + // + Root as Alert, + Description as AlertDescription, + Title as AlertTitle +}; diff --git a/tools/server/webui/src/lib/utils/attachment-display.ts b/tools/server/webui/src/lib/utils/attachment-display.ts index 1fc166a881..15a8a13c0b 100644 --- a/tools/server/webui/src/lib/utils/attachment-display.ts +++ b/tools/server/webui/src/lib/utils/attachment-display.ts @@ -2,13 +2,26 @@ import { FileTypeCategory } from '$lib/enums'; import type { ChatAttachmentDisplayItem } from '$lib/types/chat'; import type { DatabaseMessageExtra } from '$lib/types/database'; import { isImageFile } from '$lib/utils/attachment-type'; -import { getFileTypeCategory } from '$lib/utils/file-type'; +import { getFileTypeCategory, getFileTypeCategoryByExtension } from '$lib/utils/file-type'; export interface AttachmentDisplayItemsOptions { uploadedFiles?: ChatUploadedFile[]; attachments?: DatabaseMessageExtra[]; } +/** + * Gets the file type category from an uploaded file, checking both MIME type and extension + */ +function getUploadedFileCategory(file: ChatUploadedFile): FileTypeCategory | null { + const categoryByMime = getFileTypeCategory(file.type); + + if (categoryByMime) { + return categoryByMime; + } + + return getFileTypeCategoryByExtension(file.name); +} + /** * Creates a unified list of display items from uploaded files and stored attachments. * Items are returned in reverse order (newest first). @@ -26,7 +39,7 @@ export function getAttachmentDisplayItems( name: file.name, size: file.size, preview: file.preview, - isImage: getFileTypeCategory(file.type) === FileTypeCategory.IMAGE, + isImage: getUploadedFileCategory(file) === FileTypeCategory.IMAGE, uploadedFile: file, textContent: file.textContent }); diff --git a/tools/server/webui/src/lib/utils/attachment-type.ts b/tools/server/webui/src/lib/utils/attachment-type.ts index 05a6e24fc9..b8ff33595d 100644 --- a/tools/server/webui/src/lib/utils/attachment-type.ts +++ b/tools/server/webui/src/lib/utils/attachment-type.ts @@ -1,8 +1,24 @@ import { AttachmentType, FileTypeCategory } from '$lib/enums'; -import { getFileTypeCategory } from '$lib/utils/file-type'; -import { getFileTypeLabel } from '$lib/utils/file-preview'; +import { getFileTypeCategory, getFileTypeCategoryByExtension } from '$lib/utils/file-type'; import type { DatabaseMessageExtra } from '$lib/types/database'; +/** + * Gets the file type category from an uploaded file, checking both MIME type and extension + * @param uploadedFile - The uploaded file to check + * @returns The file type category or null if not recognized + */ +function getUploadedFileCategory(uploadedFile: ChatUploadedFile): FileTypeCategory | null { + // First try MIME type + const categoryByMime = getFileTypeCategory(uploadedFile.type); + + if (categoryByMime) { + return categoryByMime; + } + + // Fallback to extension (browsers don't always provide correct MIME types) + return getFileTypeCategoryByExtension(uploadedFile.name); +} + /** * Determines if an attachment or uploaded file is a text file * @param uploadedFile - Optional uploaded file @@ -14,7 +30,7 @@ export function isTextFile( uploadedFile?: ChatUploadedFile ): boolean { if (uploadedFile) { - return getFileTypeCategory(uploadedFile.type) === FileTypeCategory.TEXT; + return getUploadedFileCategory(uploadedFile) === FileTypeCategory.TEXT; } if (attachment) { @@ -37,7 +53,7 @@ export function isImageFile( uploadedFile?: ChatUploadedFile ): boolean { if (uploadedFile) { - return getFileTypeCategory(uploadedFile.type) === FileTypeCategory.IMAGE; + return getUploadedFileCategory(uploadedFile) === FileTypeCategory.IMAGE; } if (attachment) { @@ -58,7 +74,7 @@ export function isPdfFile( uploadedFile?: ChatUploadedFile ): boolean { if (uploadedFile) { - return uploadedFile.type === 'application/pdf'; + return getUploadedFileCategory(uploadedFile) === FileTypeCategory.PDF; } if (attachment) { @@ -79,7 +95,7 @@ export function isAudioFile( uploadedFile?: ChatUploadedFile ): boolean { if (uploadedFile) { - return getFileTypeCategory(uploadedFile.type) === FileTypeCategory.AUDIO; + return getUploadedFileCategory(uploadedFile) === FileTypeCategory.AUDIO; } if (attachment) { @@ -88,38 +104,3 @@ export function isAudioFile( return false; } - -/** - * Gets a human-readable type label for display - * @param uploadedFile - Optional uploaded file - * @param attachment - Optional database attachment - * @returns A formatted type label string - */ -export function getAttachmentTypeLabel( - attachment?: DatabaseMessageExtra, - uploadedFile?: ChatUploadedFile -): string { - if (uploadedFile) { - // For uploaded files, use the file type label utility - return getFileTypeLabel(uploadedFile.type); - } - - if (attachment) { - // For attachments, convert enum to readable format - switch (attachment.type) { - case AttachmentType.IMAGE: - return 'image'; - case AttachmentType.AUDIO: - return 'audio'; - case AttachmentType.PDF: - return 'pdf'; - case AttachmentType.TEXT: - case AttachmentType.LEGACY_CONTEXT: - return 'text'; - default: - return 'unknown'; - } - } - - return 'unknown'; -} diff --git a/tools/server/webui/src/lib/utils/file-type.ts b/tools/server/webui/src/lib/utils/file-type.ts index db38645c6e..f096b463d4 100644 --- a/tools/server/webui/src/lib/utils/file-type.ts +++ b/tools/server/webui/src/lib/utils/file-type.ts @@ -4,42 +4,151 @@ import { PDF_FILE_TYPES, TEXT_FILE_TYPES } from '$lib/constants/supported-file-types'; -import { FileTypeCategory } from '$lib/enums'; +import { + FileExtensionAudio, + FileExtensionImage, + FileExtensionPdf, + FileExtensionText, + FileTypeCategory, + MimeTypeApplication, + MimeTypeAudio, + MimeTypeImage, + MimeTypeText +} from '$lib/enums'; export function getFileTypeCategory(mimeType: string): FileTypeCategory | null { - if ( - Object.values(IMAGE_FILE_TYPES).some((type) => - (type.mimeTypes as readonly string[]).includes(mimeType) - ) - ) { - return FileTypeCategory.IMAGE; - } + switch (mimeType) { + // Images + case MimeTypeImage.JPEG: + case MimeTypeImage.PNG: + case MimeTypeImage.GIF: + case MimeTypeImage.WEBP: + case MimeTypeImage.SVG: + return FileTypeCategory.IMAGE; - if ( - Object.values(AUDIO_FILE_TYPES).some((type) => - (type.mimeTypes as readonly string[]).includes(mimeType) - ) - ) { - return FileTypeCategory.AUDIO; - } + // Audio + case MimeTypeAudio.MP3_MPEG: + case MimeTypeAudio.MP3: + case MimeTypeAudio.MP4: + case MimeTypeAudio.WAV: + case MimeTypeAudio.WEBM: + case MimeTypeAudio.WEBM_OPUS: + return FileTypeCategory.AUDIO; - if ( - Object.values(PDF_FILE_TYPES).some((type) => - (type.mimeTypes as readonly string[]).includes(mimeType) - ) - ) { - return FileTypeCategory.PDF; - } + // PDF + case MimeTypeApplication.PDF: + return FileTypeCategory.PDF; - if ( - Object.values(TEXT_FILE_TYPES).some((type) => - (type.mimeTypes as readonly string[]).includes(mimeType) - ) - ) { - return FileTypeCategory.TEXT; - } + // Text + case MimeTypeText.PLAIN: + case MimeTypeText.MARKDOWN: + case MimeTypeText.ASCIIDOC: + case MimeTypeText.JAVASCRIPT: + case MimeTypeText.JAVASCRIPT_APP: + case MimeTypeText.TYPESCRIPT: + case MimeTypeText.JSX: + case MimeTypeText.TSX: + case MimeTypeText.CSS: + case MimeTypeText.HTML: + case MimeTypeText.JSON: + case MimeTypeText.XML_TEXT: + case MimeTypeText.XML_APP: + case MimeTypeText.YAML_TEXT: + case MimeTypeText.YAML_APP: + case MimeTypeText.CSV: + case MimeTypeText.PYTHON: + case MimeTypeText.JAVA: + case MimeTypeText.CPP_SRC: + case MimeTypeText.C_SRC: + case MimeTypeText.C_HDR: + case MimeTypeText.PHP: + case MimeTypeText.RUBY: + case MimeTypeText.GO: + case MimeTypeText.RUST: + case MimeTypeText.SHELL: + case MimeTypeText.BAT: + case MimeTypeText.SQL: + case MimeTypeText.R: + case MimeTypeText.SCALA: + case MimeTypeText.KOTLIN: + case MimeTypeText.SWIFT: + case MimeTypeText.DART: + case MimeTypeText.VUE: + case MimeTypeText.SVELTE: + case MimeTypeText.LATEX: + case MimeTypeText.BIBTEX: + return FileTypeCategory.TEXT; - return null; + default: + return null; + } +} + +export function getFileTypeCategoryByExtension(filename: string): FileTypeCategory | null { + const extension = filename.toLowerCase().substring(filename.lastIndexOf('.')); + + switch (extension) { + // Images + case FileExtensionImage.JPG: + case FileExtensionImage.JPEG: + case FileExtensionImage.PNG: + case FileExtensionImage.GIF: + case FileExtensionImage.WEBP: + case FileExtensionImage.SVG: + return FileTypeCategory.IMAGE; + + // Audio + case FileExtensionAudio.MP3: + case FileExtensionAudio.WAV: + return FileTypeCategory.AUDIO; + + // PDF + case FileExtensionPdf.PDF: + return FileTypeCategory.PDF; + + // Text + case FileExtensionText.TXT: + case FileExtensionText.MD: + case FileExtensionText.ADOC: + case FileExtensionText.JS: + case FileExtensionText.TS: + case FileExtensionText.JSX: + case FileExtensionText.TSX: + case FileExtensionText.CSS: + case FileExtensionText.HTML: + case FileExtensionText.HTM: + case FileExtensionText.JSON: + case FileExtensionText.XML: + case FileExtensionText.YAML: + case FileExtensionText.YML: + case FileExtensionText.CSV: + case FileExtensionText.LOG: + case FileExtensionText.PY: + case FileExtensionText.JAVA: + case FileExtensionText.CPP: + case FileExtensionText.C: + case FileExtensionText.H: + case FileExtensionText.PHP: + case FileExtensionText.RB: + case FileExtensionText.GO: + case FileExtensionText.RS: + case FileExtensionText.SH: + case FileExtensionText.BAT: + case FileExtensionText.SQL: + case FileExtensionText.R: + case FileExtensionText.SCALA: + case FileExtensionText.KT: + case FileExtensionText.SWIFT: + case FileExtensionText.DART: + case FileExtensionText.VUE: + case FileExtensionText.SVELTE: + case FileExtensionText.TEX: + case FileExtensionText.BIB: + return FileTypeCategory.TEXT; + + default: + return null; + } } export function getFileTypeByExtension(filename: string): string | null { diff --git a/tools/server/webui/src/lib/utils/process-uploaded-files.ts b/tools/server/webui/src/lib/utils/process-uploaded-files.ts index 372b433502..43383b467a 100644 --- a/tools/server/webui/src/lib/utils/process-uploaded-files.ts +++ b/tools/server/webui/src/lib/utils/process-uploaded-files.ts @@ -6,6 +6,7 @@ import { getFileTypeCategory } from '$lib/utils/file-type'; import { modelsStore } from '$lib/stores/models.svelte'; import { settingsStore } from '$lib/stores/settings.svelte'; import { toast } from 'svelte-sonner'; +import { convertPDFToText } from '$lib/utils/pdf-processing'; /** * Read a file as a data URL (base64 encoded) @@ -95,8 +96,14 @@ export async function processFilesToChatUploaded( results.push(base); } } else if (getFileTypeCategory(file.type) === FileTypeCategory.PDF) { - // PDFs handled later when building extras; keep metadata only - results.push(base); + // 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); + results.push(base); + } // Show suggestion toast if vision model is available but PDF as image is disabled const hasVisionSupport = activeModelId diff --git a/tools/server/webui/src/lib/utils/syntax-highlight-language.ts b/tools/server/webui/src/lib/utils/syntax-highlight-language.ts new file mode 100644 index 0000000000..538429182e --- /dev/null +++ b/tools/server/webui/src/lib/utils/syntax-highlight-language.ts @@ -0,0 +1,145 @@ +/** + * Maps file extensions to highlight.js language identifiers + */ +export function getLanguageFromFilename(filename: string): string { + const extension = filename.toLowerCase().substring(filename.lastIndexOf('.')); + + switch (extension) { + // JavaScript / TypeScript + case '.js': + case '.mjs': + case '.cjs': + return 'javascript'; + case '.ts': + case '.mts': + case '.cts': + return 'typescript'; + case '.jsx': + return 'javascript'; + case '.tsx': + return 'typescript'; + + // Web + case '.html': + case '.htm': + return 'html'; + case '.css': + return 'css'; + case '.scss': + return 'scss'; + case '.less': + return 'less'; + case '.vue': + return 'html'; + case '.svelte': + return 'html'; + + // Data formats + case '.json': + return 'json'; + case '.xml': + return 'xml'; + case '.yaml': + case '.yml': + return 'yaml'; + case '.toml': + return 'ini'; + case '.csv': + return 'plaintext'; + + // Programming languages + case '.py': + return 'python'; + case '.java': + return 'java'; + case '.kt': + case '.kts': + return 'kotlin'; + case '.scala': + return 'scala'; + case '.cpp': + case '.cc': + case '.cxx': + case '.c++': + return 'cpp'; + case '.c': + return 'c'; + case '.h': + case '.hpp': + return 'cpp'; + case '.cs': + return 'csharp'; + case '.go': + return 'go'; + case '.rs': + return 'rust'; + case '.rb': + return 'ruby'; + case '.php': + return 'php'; + case '.swift': + return 'swift'; + case '.dart': + return 'dart'; + case '.r': + return 'r'; + case '.lua': + return 'lua'; + case '.pl': + case '.pm': + return 'perl'; + + // Shell + case '.sh': + case '.bash': + case '.zsh': + return 'bash'; + case '.bat': + case '.cmd': + return 'dos'; + case '.ps1': + return 'powershell'; + + // Database + case '.sql': + return 'sql'; + + // Markup / Documentation + case '.md': + case '.markdown': + return 'markdown'; + case '.tex': + case '.latex': + return 'latex'; + case '.adoc': + case '.asciidoc': + return 'asciidoc'; + + // Config + case '.ini': + case '.cfg': + case '.conf': + return 'ini'; + case '.dockerfile': + return 'dockerfile'; + case '.nginx': + return 'nginx'; + + // Other + case '.graphql': + case '.gql': + return 'graphql'; + case '.proto': + return 'protobuf'; + case '.diff': + case '.patch': + return 'diff'; + case '.log': + return 'plaintext'; + case '.txt': + return 'plaintext'; + + default: + return 'plaintext'; + } +}