refactor: Architecture improvements

This commit is contained in:
Aleksander Grygier 2025-11-20 16:34:25 +01:00
parent 55d33a8b8c
commit 8a88576849
13 changed files with 180 additions and 113 deletions

View File

@ -23,8 +23,8 @@ import type {
ApiRouterModelsUnloadResponse
} from '$lib/types/api';
import { ServerMode } from '$lib/enums/server';
import { ServerModelStatus } from '$lib/enums/model';
import { ServerMode, ServerModelStatus } from '$lib/enums/server';
import { ModelModality } from '$lib/enums/model';
import type {
ChatMessageType,
@ -79,8 +79,6 @@ declare global {
ApiRouterModelsListResponse,
ApiRouterModelsUnloadRequest,
ApiRouterModelsUnloadResponse,
ServerMode,
ServerModelStatus,
ChatMessageData,
ChatMessagePromptProgress,
ChatMessageSiblingInfo,
@ -96,6 +94,9 @@ declare global {
DatabaseMessageExtraTextFile,
DatabaseMessageExtraPdfFile,
DatabaseMessageExtraLegacyContext,
ModelModality,
ServerMode,
ServerModelStatus,
SettingsConfigValue,
SettingsFieldConfig,
SettingsConfigType,

View File

@ -1,8 +1,11 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { FileText, Image, Music, FileIcon, Eye } from '@lucide/svelte';
import { FileTypeCategory, MimeTypeApplication } from '$lib/enums/files';
import { ModelModality } from '$lib/enums/model';
import { AttachmentType } from '$lib/enums/attachment';
import type { DatabaseMessageExtra } from '$lib/types/database';
import { convertPDFToImage } from '$lib/utils/pdf-processing';
import { Button } from '$lib/components/ui/button';
import { getFileTypeCategory } from '$lib/utils/file-type';
interface Props {
@ -21,33 +24,36 @@
let displayName = $derived(uploadedFile?.name || attachment?.name || name || 'Unknown File');
let displayPreview = $derived(
uploadedFile?.preview || (attachment?.type === 'imageFile' ? attachment.base64Url : preview)
uploadedFile?.preview ||
(attachment?.type === AttachmentType.IMAGE ? attachment.base64Url : preview)
);
let displayType = $derived(
uploadedFile?.type ||
(attachment?.type === 'imageFile'
uploadedFile
? uploadedFile.type
: attachment?.type === AttachmentType.IMAGE
? 'image'
: attachment?.type === 'textFile'
: attachment?.type === AttachmentType.TEXT
? 'text'
: attachment?.type === 'audioFile'
? attachment.mimeType || 'audio'
: attachment?.type === 'pdfFile'
: attachment?.type === AttachmentType.AUDIO
? attachment.mimeType || ModelModality.AUDIO
: attachment?.type === AttachmentType.PDF
? MimeTypeApplication.PDF
: type || 'unknown')
: type || 'unknown'
);
let displayTextContent = $derived(
uploadedFile?.textContent ||
(attachment?.type === 'textFile'
(attachment?.type === AttachmentType.TEXT
? attachment.content
: attachment?.type === 'pdfFile'
: attachment?.type === AttachmentType.PDF
? attachment.content
: textContent)
);
let isAudio = $derived(
getFileTypeCategory(displayType) === FileTypeCategory.AUDIO || displayType === 'audio'
getFileTypeCategory(displayType) === FileTypeCategory.AUDIO ||
displayType === ModelModality.AUDIO
);
let isImage = $derived(
@ -87,9 +93,9 @@
if (uploadedFile?.file) {
file = uploadedFile.file;
} else if (attachment?.type === 'pdfFile') {
} else if (attachment?.type === AttachmentType.PDF) {
// Check if we have pre-processed images
if (attachment.images && Array.isArray(attachment.images)) {
if (attachment.images && Array.isArray(attachment.images) && attachment.images.length > 0) {
pdfImages = attachment.images;
return;
}
@ -237,7 +243,7 @@
<div class="w-full max-w-md text-center">
<Music class="mx-auto mb-4 h-16 w-16 text-muted-foreground" />
{#if attachment?.type === 'audioFile'}
{#if attachment?.type === AttachmentType.AUDIO}
<audio
controls
class="mb-4 w-full"

View File

@ -2,10 +2,13 @@
import { ChatAttachmentThumbnailImage, ChatAttachmentThumbnailFile } from '$lib/components/app';
import { Button } from '$lib/components/ui/button';
import { ChevronLeft, ChevronRight } from '@lucide/svelte';
import { FileTypeCategory } from '$lib/enums/files';
import { getFileTypeCategory } from '$lib/utils/file-type';
import { FileTypeCategory } from '$lib/enums/files';
import { ModelModality } from '$lib/enums/model';
import { AttachmentType } from '$lib/enums/attachment';
import { DialogChatAttachmentPreview, DialogChatAttachmentsViewAll } from '$lib/components/app';
import type { ChatAttachmentDisplayItem, ChatAttachmentPreviewItem } from '$lib/types/chat';
import type { DatabaseMessageExtra } from '$lib/types/database';
interface Props {
class?: string;
@ -68,7 +71,7 @@
// Add stored attachments (ChatMessage)
for (const [index, attachment] of attachments.entries()) {
if (attachment.type === 'imageFile') {
if (attachment.type === AttachmentType.IMAGE) {
items.push({
id: `attachment-${index}`,
name: attachment.name,
@ -78,7 +81,7 @@
attachment,
attachmentIndex: index
});
} else if (attachment.type === 'textFile') {
} else if (attachment.type === AttachmentType.TEXT) {
items.push({
id: `attachment-${index}`,
name: attachment.name,
@ -88,7 +91,25 @@
attachmentIndex: index,
textContent: attachment.content
});
} else if (attachment.type === 'context') {
} else if (attachment.type === AttachmentType.AUDIO) {
items.push({
id: `attachment-${index}`,
name: attachment.name,
type: attachment.mimeType || ModelModality.AUDIO,
isImage: false,
attachment,
attachmentIndex: index
});
} else if (attachment.type === AttachmentType.PDF) {
items.push({
id: `attachment-${index}`,
name: attachment.name,
type: 'application/pdf',
isImage: false,
attachment,
attachmentIndex: index
});
} else if (attachment.type === AttachmentType.LEGACY_CONTEXT) {
// Legacy format from old webui - treat as text file
items.push({
id: `attachment-${index}`,
@ -99,25 +120,6 @@
attachmentIndex: index,
textContent: attachment.content
});
} else if (attachment.type === 'audioFile') {
items.push({
id: `attachment-${index}`,
name: attachment.name,
type: attachment.mimeType || 'audio',
isImage: false,
attachment,
attachmentIndex: index
});
} else if (attachment.type === 'pdfFile') {
items.push({
id: `attachment-${index}`,
name: attachment.name,
type: 'application/pdf',
isImage: false,
attachment,
attachmentIndex: index,
textContent: attachment.content
});
}
}

View File

@ -5,8 +5,11 @@
DialogChatAttachmentPreview
} from '$lib/components/app';
import { FileTypeCategory } from '$lib/enums/files';
import { ModelModality } from '$lib/enums/model';
import { AttachmentType } from '$lib/enums/attachment';
import { getFileTypeCategory } from '$lib/utils/file-type';
import type { ChatAttachmentDisplayItem, ChatAttachmentPreviewItem } from '$lib/types/chat';
import type { DatabaseMessageExtra } from '$lib/types/database';
interface Props {
uploadedFiles?: ChatUploadedFile[];
@ -52,7 +55,7 @@
}
for (const [index, attachment] of attachments.entries()) {
if (attachment.type === 'imageFile') {
if (attachment.type === AttachmentType.IMAGE) {
items.push({
id: `attachment-${index}`,
name: attachment.name,
@ -62,7 +65,7 @@
attachment,
attachmentIndex: index
});
} else if (attachment.type === 'textFile') {
} else if (attachment.type === AttachmentType.TEXT) {
items.push({
id: `attachment-${index}`,
name: attachment.name,
@ -72,7 +75,25 @@
attachmentIndex: index,
textContent: attachment.content
});
} else if (attachment.type === 'context') {
} else if (attachment.type === AttachmentType.AUDIO) {
items.push({
id: `attachment-${index}`,
name: attachment.name,
type: attachment.mimeType || ModelModality.AUDIO,
isImage: false,
attachment,
attachmentIndex: index
});
} else if (attachment.type === AttachmentType.PDF) {
items.push({
id: `attachment-${index}`,
name: attachment.name,
type: 'application/pdf',
isImage: false,
attachment,
attachmentIndex: index
});
} else if (attachment.type === AttachmentType.LEGACY_CONTEXT) {
// Legacy format from old webui - treat as text file
items.push({
id: `attachment-${index}`,
@ -83,25 +104,6 @@
attachmentIndex: index,
textContent: attachment.content
});
} else if (attachment.type === 'audioFile') {
items.push({
id: `attachment-${index}`,
name: attachment.name,
type: attachment.mimeType || 'audio',
isImage: false,
attachment,
attachmentIndex: index
});
} else if (attachment.type === 'pdfFile') {
items.push({
id: `attachment-${index}`,
name: attachment.name,
type: 'application/pdf',
isImage: false,
attachment,
attachmentIndex: index,
textContent: attachment.content
});
}
}

View File

@ -1,10 +1,14 @@
<script lang="ts">
import * as Dialog from '$lib/components/ui/dialog';
import { ModelModality } from '$lib/enums/model';
import { AttachmentType } from '$lib/enums/attachment';
import type { DatabaseMessageExtra } from '$lib/types/database';
import { ChatAttachmentPreview } from '$lib/components/app';
import { formatFileSize } from '$lib/utils/file-preview';
interface Props {
open: boolean;
onOpenChange?: (open: boolean) => void;
// Either an uploaded file or a stored attachment
uploadedFile?: ChatUploadedFile;
attachment?: DatabaseMessageExtra;
@ -18,6 +22,7 @@
let {
open = $bindable(),
onOpenChange,
uploadedFile,
attachment,
preview,
@ -32,16 +37,17 @@
let displayName = $derived(uploadedFile?.name || attachment?.name || name || 'Unknown File');
let displayType = $derived(
uploadedFile?.type ||
(attachment?.type === 'imageFile'
uploadedFile
? uploadedFile.type
: attachment?.type === AttachmentType.IMAGE
? 'image'
: attachment?.type === 'textFile'
: attachment?.type === AttachmentType.TEXT
? 'text'
: attachment?.type === 'audioFile'
? attachment.mimeType || 'audio'
: attachment?.type === 'pdfFile'
: attachment?.type === AttachmentType.AUDIO
? attachment.mimeType || ModelModality.AUDIO
: attachment?.type === AttachmentType.PDF
? 'application/pdf'
: type || 'unknown')
: type || 'unknown'
);
let displaySize = $derived(uploadedFile?.size || size);
@ -53,7 +59,7 @@
});
</script>
<Dialog.Root bind:open>
<Dialog.Root bind:open {onOpenChange}>
<Dialog.Content class="grid max-h-[90vh] max-w-5xl overflow-hidden sm:w-auto sm:max-w-6xl">
<Dialog.Header>
<Dialog.Title>{displayName}</Dialog.Title>

View File

@ -0,0 +1,10 @@
/**
* Attachment type enum for database message extras
*/
export enum AttachmentType {
AUDIO = 'AUDIO',
IMAGE = 'IMAGE',
PDF = 'PDF',
TEXT = 'TEXT',
LEGACY_CONTEXT = 'context' // Legacy attachment type for backward compatibility
}

View File

@ -1,9 +1,5 @@
/**
* Model status enum - matches tools/server/server-models.h from C++ server
*/
export enum ServerModelStatus {
UNLOADED = 'UNLOADED',
LOADING = 'LOADING',
LOADED = 'LOADED',
FAILED = 'FAILED'
export enum ModelModality {
TEXT = 'TEXT',
AUDIO = 'AUDIO',
VISION = 'VISION'
}

View File

@ -7,3 +7,13 @@ export enum ServerMode {
/** Router mode - server managing multiple model instances */
ROUTER = 'ROUTER'
}
/**
* Model status enum - matches tools/server/server-models.h from C++ server
*/
export enum ServerModelStatus {
UNLOADED = 'UNLOADED',
LOADING = 'LOADING',
LOADED = 'LOADED',
FAILED = 'FAILED'
}

View File

@ -7,8 +7,10 @@ import type {
ApiChatCompletionStreamChunk,
ApiChatCompletionToolCall,
ApiChatCompletionToolCallDelta,
ApiChatMessageData
ApiChatMessageData,
ApiModelListResponse
} from '$lib/types/api';
import { AttachmentType } from '$lib/enums/attachment';
import type {
DatabaseMessage,
DatabaseMessageExtra,
@ -618,7 +620,7 @@ export class ChatService {
const imageFiles = message.extra.filter(
(extra: DatabaseMessageExtra): extra is DatabaseMessageExtraImageFile =>
extra.type === 'imageFile'
extra.type === AttachmentType.IMAGE
);
for (const image of imageFiles) {
@ -630,7 +632,7 @@ export class ChatService {
const textFiles = message.extra.filter(
(extra: DatabaseMessageExtra): extra is DatabaseMessageExtraTextFile =>
extra.type === 'textFile'
extra.type === AttachmentType.TEXT
);
for (const textFile of textFiles) {
@ -643,7 +645,7 @@ export class ChatService {
// Handle legacy 'context' type from old webui (pasted content)
const legacyContextFiles = message.extra.filter(
(extra: DatabaseMessageExtra): extra is DatabaseMessageExtraLegacyContext =>
extra.type === 'context'
extra.type === AttachmentType.LEGACY_CONTEXT
);
for (const legacyContextFile of legacyContextFiles) {
@ -655,7 +657,7 @@ export class ChatService {
const audioFiles = message.extra.filter(
(extra: DatabaseMessageExtra): extra is DatabaseMessageExtraAudioFile =>
extra.type === 'audioFile'
extra.type === AttachmentType.AUDIO
);
for (const audio of audioFiles) {
@ -670,7 +672,7 @@ export class ChatService {
const pdfFiles = message.extra.filter(
(extra: DatabaseMessageExtra): extra is DatabaseMessageExtraPdfFile =>
extra.type === 'pdfFile'
extra.type === AttachmentType.PDF
);
for (const pdfFile of pdfFiles) {
@ -722,6 +724,33 @@ export class ChatService {
}
}
/**
* Get model information from /models endpoint
*/
static async getModels(): Promise<ApiModelListResponse> {
try {
const currentConfig = config();
const apiKey = currentConfig.apiKey?.toString().trim();
const response = await fetch(`./models`, {
headers: {
'Content-Type': 'application/json',
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {})
}
});
if (!response.ok) {
throw new Error(`Failed to fetch models: ${response.status} ${response.statusText}`);
}
const data = await response.json();
return data;
} catch (error) {
console.error('Error fetching models:', error);
throw error;
}
}
/**
* Aborts any ongoing chat completion request.
* Cancels the current request and cleans up the abort controller.

View File

@ -3,6 +3,7 @@ import { SERVER_PROPS_LOCALSTORAGE_KEY } from '$lib/constants/localstorage-keys'
import { ChatService } from '$lib/services/chat';
import { config } from '$lib/stores/settings.svelte';
import { ServerMode } from '$lib/enums/server';
import { ModelModality } from '$lib/enums/model';
import { updateConfig } from '$lib/stores/settings.svelte';
/**
@ -115,10 +116,10 @@ class ServerStore {
get supportedModalities(): string[] {
const modalities: string[] = [];
if (this._serverProps?.modalities?.audio) {
modalities.push('audio');
modalities.push(ModelModality.AUDIO);
}
if (this._serverProps?.modalities?.vision) {
modalities.push('vision');
modalities.push(ModelModality.VISION);
}
return modalities;
}

View File

@ -1,4 +1,5 @@
import type { ChatMessageTimings } from './chat';
import type { ChatMessageTimings, ChatRole, ChatMessageType } from '$lib/types/chat';
import { AttachmentType } from '$lib/enums/attachment';
export interface DatabaseConversation {
currNode: string | null;
@ -8,38 +9,39 @@ export interface DatabaseConversation {
}
export interface DatabaseMessageExtraAudioFile {
type: 'audioFile';
type: AttachmentType.AUDIO;
name: string;
base64Data: string;
mimeType: string;
}
export interface DatabaseMessageExtraImageFile {
type: 'imageFile';
type: AttachmentType.IMAGE;
name: string;
base64Url: string;
}
export interface DatabaseMessageExtraTextFile {
type: 'textFile';
name: string;
content: string;
}
export interface DatabaseMessageExtraPdfFile {
type: 'pdfFile';
name: string;
content: string; // Text content extracted from PDF
images?: string[]; // Optional: PDF pages as base64 images
processedAsImages: boolean; // Whether PDF was processed as images
}
/**
* Legacy format from old webui - pasted content was stored as "context" type
* @deprecated Use DatabaseMessageExtraTextFile instead
*/
export interface DatabaseMessageExtraLegacyContext {
type: 'context';
type: AttachmentType.LEGACY_CONTEXT;
name: string;
content: string;
}
export interface DatabaseMessageExtraPdfFile {
type: AttachmentType.PDF;
base64Data: string;
name: string;
content: string; // Text content extracted from PDF
images?: string[]; // Optional: PDF pages as base64 images
processedAsImages: boolean; // Whether PDF was processed as images
}
export interface DatabaseMessageExtraTextFile {
type: AttachmentType.TEXT;
name: string;
content: string;
}

View File

@ -2,6 +2,7 @@ import { convertPDFToImage, convertPDFToText } from './pdf-processing';
import { isSvgMimeType, svgBase64UrlToPngDataURL } from './svg-to-png';
import { isWebpMimeType, webpBase64UrlToPngDataURL } from './webp-to-png';
import { FileTypeCategory } from '$lib/enums/files';
import { AttachmentType } from '$lib/enums/attachment';
import { config, settingsStore } from '$lib/stores/settings.svelte';
import { supportsVision } from '$lib/stores/server.svelte';
import { getFileTypeCategory } from '$lib/utils/file-type';
@ -56,7 +57,7 @@ export async function parseFilesToMessageExtras(
}
extras.push({
type: 'imageFile',
type: AttachmentType.IMAGE,
name: file.name,
base64Url
});
@ -67,7 +68,7 @@ export async function parseFilesToMessageExtras(
const base64Data = await readFileAsBase64(file.file);
extras.push({
type: 'audioFile',
type: AttachmentType.AUDIO,
name: file.name,
base64Data: base64Data,
mimeType: file.type
@ -117,7 +118,7 @@ export async function parseFilesToMessageExtras(
);
extras.push({
type: 'pdfFile',
type: AttachmentType.PDF,
name: file.name,
content: `PDF file with ${images.length} pages`,
images: images,
@ -134,7 +135,7 @@ export async function parseFilesToMessageExtras(
const content = await convertPDFToText(file.file);
extras.push({
type: 'pdfFile',
type: AttachmentType.PDF,
name: file.name,
content: content,
processedAsImages: false,
@ -151,7 +152,7 @@ export async function parseFilesToMessageExtras(
});
extras.push({
type: 'pdfFile',
type: AttachmentType.PDF,
name: file.name,
content: content,
processedAsImages: false,
@ -171,7 +172,7 @@ export async function parseFilesToMessageExtras(
emptyFiles.push(file.name);
} else if (isLikelyTextFile(content)) {
extras.push({
type: 'textFile',
type: AttachmentType.TEXT,
name: file.name,
content: content
});

View File

@ -158,7 +158,8 @@ export default defineConfig({
proxy: {
'/v1': 'http://localhost:8080',
'/props': 'http://localhost:8080',
'/slots': 'http://localhost:8080'
'/slots': 'http://localhost:8080',
'/models': 'http://localhost:8080'
},
headers: {
'Cross-Origin-Embedder-Policy': 'require-corp',