SvelteKit-based WebUI (#14839)

This commit is contained in:
Aleksander Grygier
2025-09-17 19:29:13 +02:00
committed by GitHub
parent 8f8f2274ee
commit a7a98e0fff
288 changed files with 25749 additions and 11502 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,349 @@
import Dexie, { type EntityTable } from 'dexie';
import { filterByLeafNodeId, findDescendantMessages } from '$lib/utils/branching';
class LlamacppDatabase extends Dexie {
conversations!: EntityTable<DatabaseConversation, string>;
messages!: EntityTable<DatabaseMessage, string>;
constructor() {
super('LlamacppWebui');
this.version(1).stores({
conversations: 'id, lastModified, currNode, name',
messages: 'id, convId, type, role, timestamp, parent, children'
});
}
}
const db = new LlamacppDatabase();
/**
* DatabaseStore - Persistent data layer for conversation and message management
*
* This service provides a comprehensive data access layer built on IndexedDB using Dexie.
* It handles all persistent storage operations for conversations, messages, and application settings
* with support for complex conversation branching and message threading.
*
* **Architecture & Relationships:**
* - **DatabaseStore** (this class): Stateless data persistence layer
* - Manages IndexedDB operations through Dexie ORM
* - Handles conversation and message CRUD operations
* - Supports complex branching with parent-child relationships
* - Provides transaction safety for multi-table operations
*
* - **ChatStore**: Primary consumer for conversation state management
* - Uses DatabaseStore for all persistence operations
* - Coordinates UI state with database state
* - Handles conversation lifecycle and message branching
*
* **Key Features:**
* - **Conversation Management**: Create, read, update, delete conversations
* - **Message Branching**: Support for tree-like conversation structures
* - **Transaction Safety**: Atomic operations for data consistency
* - **Path Resolution**: Navigate conversation branches and find leaf nodes
* - **Cascading Deletion**: Remove entire conversation branches
*
* **Database Schema:**
* - `conversations`: Conversation metadata with current node tracking
* - `messages`: Individual messages with parent-child relationships
*
* **Branching Model:**
* Messages form a tree structure where each message can have multiple children,
* enabling conversation branching and alternative response paths. The conversation's
* `currNode` tracks the currently active branch endpoint.
*/
import { v4 as uuid } from 'uuid';
export class DatabaseStore {
/**
* Adds a new message to the database.
*
* @param message - Message to add (without id)
* @returns The created message
*/
static async addMessage(message: Omit<DatabaseMessage, 'id'>): Promise<DatabaseMessage> {
const newMessage: DatabaseMessage = {
...message,
id: uuid()
};
await db.messages.add(newMessage);
return newMessage;
}
/**
* Creates a new conversation.
*
* @param name - Name of the conversation
* @returns The created conversation
*/
static async createConversation(name: string): Promise<DatabaseConversation> {
const conversation: DatabaseConversation = {
id: uuid(),
name,
lastModified: Date.now(),
currNode: ''
};
await db.conversations.add(conversation);
return conversation;
}
/**
* Creates a new message branch by adding a message and updating parent/child relationships.
* Also updates the conversation's currNode to point to the new message.
*
* @param message - Message to add (without id)
* @param parentId - Parent message ID to attach to
* @returns The created message
*/
static async createMessageBranch(
message: Omit<DatabaseMessage, 'id'>,
parentId: string | null
): Promise<DatabaseMessage> {
return await db.transaction('rw', [db.conversations, db.messages], async () => {
// Handle null parent (root message case)
if (parentId !== null) {
const parentMessage = await db.messages.get(parentId);
if (!parentMessage) {
throw new Error(`Parent message ${parentId} not found`);
}
}
const newMessage: DatabaseMessage = {
...message,
id: uuid(),
parent: parentId,
children: []
};
await db.messages.add(newMessage);
// Update parent's children array if parent exists
if (parentId !== null) {
const parentMessage = await db.messages.get(parentId);
if (parentMessage) {
await db.messages.update(parentId, {
children: [...parentMessage.children, newMessage.id]
});
}
}
await this.updateConversation(message.convId, {
currNode: newMessage.id
});
return newMessage;
});
}
/**
* Creates a root message for a new conversation.
* Root messages are not displayed but serve as the tree root for branching.
*
* @param convId - Conversation ID
* @returns The created root message
*/
static async createRootMessage(convId: string): Promise<string> {
const rootMessage: DatabaseMessage = {
id: uuid(),
convId,
type: 'root',
timestamp: Date.now(),
role: 'system',
content: '',
parent: null,
thinking: '',
children: []
};
await db.messages.add(rootMessage);
return rootMessage.id;
}
/**
* Deletes a conversation and all its messages.
*
* @param id - Conversation ID
*/
static async deleteConversation(id: string): Promise<void> {
await db.transaction('rw', [db.conversations, db.messages], async () => {
await db.conversations.delete(id);
await db.messages.where('convId').equals(id).delete();
});
}
/**
* Deletes a message and removes it from its parent's children array.
*
* @param messageId - ID of the message to delete
*/
static async deleteMessage(messageId: string): Promise<void> {
await db.transaction('rw', db.messages, async () => {
const message = await db.messages.get(messageId);
if (!message) return;
// Remove this message from its parent's children array
if (message.parent) {
const parent = await db.messages.get(message.parent);
if (parent) {
parent.children = parent.children.filter((childId: string) => childId !== messageId);
await db.messages.put(parent);
}
}
// Delete the message
await db.messages.delete(messageId);
});
}
/**
* Deletes a message and all its descendant messages (cascading deletion).
* This removes the entire branch starting from the specified message.
*
* @param conversationId - ID of the conversation containing the message
* @param messageId - ID of the root message to delete (along with all descendants)
* @returns Array of all deleted message IDs
*/
static async deleteMessageCascading(
conversationId: string,
messageId: string
): Promise<string[]> {
return await db.transaction('rw', db.messages, async () => {
// Get all messages in the conversation to find descendants
const allMessages = await db.messages.where('convId').equals(conversationId).toArray();
// Find all descendant messages
const descendants = findDescendantMessages(allMessages, messageId);
const allToDelete = [messageId, ...descendants];
// Get the message to delete for parent cleanup
const message = await db.messages.get(messageId);
if (message && message.parent) {
const parent = await db.messages.get(message.parent);
if (parent) {
parent.children = parent.children.filter((childId: string) => childId !== messageId);
await db.messages.put(parent);
}
}
// Delete all messages in the branch
await db.messages.bulkDelete(allToDelete);
return allToDelete;
});
}
/**
* Gets all conversations, sorted by last modified time (newest first).
*
* @returns Array of conversations
*/
static async getAllConversations(): Promise<DatabaseConversation[]> {
return await db.conversations.orderBy('lastModified').reverse().toArray();
}
/**
* Gets a conversation by ID.
*
* @param id - Conversation ID
* @returns The conversation if found, otherwise undefined
*/
static async getConversation(id: string): Promise<DatabaseConversation | undefined> {
return await db.conversations.get(id);
}
/**
* Gets all leaf nodes (messages with no children) in a conversation.
* Useful for finding all possible conversation endpoints.
*
* @param convId - Conversation ID
* @returns Array of leaf node message IDs
*/
static async getConversationLeafNodes(convId: string): Promise<string[]> {
const allMessages = await this.getConversationMessages(convId);
return allMessages.filter((msg) => msg.children.length === 0).map((msg) => msg.id);
}
/**
* Gets all messages in a conversation, sorted by timestamp (oldest first).
*
* @param convId - Conversation ID
* @returns Array of messages in the conversation
*/
static async getConversationMessages(convId: string): Promise<DatabaseMessage[]> {
return await db.messages.where('convId').equals(convId).sortBy('timestamp');
}
/**
* Gets the conversation path from root to the current leaf node.
* Uses the conversation's currNode to determine the active branch.
*
* @param convId - Conversation ID
* @returns Array of messages in the current conversation path
*/
static async getConversationPath(convId: string): Promise<DatabaseMessage[]> {
const conversation = await this.getConversation(convId);
if (!conversation) {
return [];
}
const allMessages = await this.getConversationMessages(convId);
if (allMessages.length === 0) {
return [];
}
// If no currNode is set, use the latest message as leaf
const leafNodeId =
conversation.currNode ||
allMessages.reduce((latest, msg) => (msg.timestamp > latest.timestamp ? msg : latest)).id;
return filterByLeafNodeId(allMessages, leafNodeId, false) as DatabaseMessage[];
}
/**
* Updates a conversation.
*
* @param id - Conversation ID
* @param updates - Partial updates to apply
* @returns Promise that resolves when the conversation is updated
*/
static async updateConversation(
id: string,
updates: Partial<Omit<DatabaseConversation, 'id'>>
): Promise<void> {
await db.conversations.update(id, {
...updates,
lastModified: Date.now()
});
}
/**
* Updates the conversation's current node (active branch).
* This determines which conversation path is currently being viewed.
*
* @param convId - Conversation ID
* @param nodeId - Message ID to set as current node
*/
static async updateCurrentNode(convId: string, nodeId: string): Promise<void> {
await this.updateConversation(convId, {
currNode: nodeId
});
}
/**
* Updates a message.
*
* @param id - Message ID
* @param updates - Partial updates to apply
* @returns Promise that resolves when the message is updated
*/
static async updateMessage(
id: string,
updates: Partial<Omit<DatabaseMessage, 'id'>>
): Promise<void> {
await db.messages.update(id, updates);
}
}

View File

@@ -0,0 +1,184 @@
import { ChatService } from '$lib/services/chat';
import { config } from '$lib/stores/settings.svelte';
/**
* ServerStore - Server state management and capability detection
*
* This store manages communication with the llama.cpp server to retrieve and maintain
* server properties, model information, and capability detection. It provides reactive
* state for server connectivity, model capabilities, and endpoint availability.
*
* **Architecture & Relationships:**
* - **ServerStore** (this class): Server state and capability management
* - Fetches and caches server properties from `/props` endpoint
* - Detects model capabilities (vision, audio support)
* - Tests endpoint availability (slots endpoint)
* - Provides reactive server state for UI components
*
* - **ChatService**: Uses server properties for request validation
* - **SlotsService**: Depends on slots endpoint availability detection
* - **UI Components**: Subscribe to server state for capability-based rendering
*
* **Key Features:**
* - **Server Properties**: Model path, context size, build information
* - **Capability Detection**: Vision and audio modality support
* - **Endpoint Testing**: Slots endpoint availability checking
* - **Error Handling**: User-friendly error messages for connection issues
* - **Reactive State**: Svelte 5 runes for automatic UI updates
* - **State Management**: Loading states and error recovery
*
* **Server Capabilities Detected:**
* - Model name extraction from file path
* - Vision support (multimodal image processing)
* - Audio support (speech processing)
* - Slots endpoint availability (for processing state monitoring)
* - Context window size and token limits
*/
class ServerStore {
private _serverProps = $state<ApiLlamaCppServerProps | null>(null);
private _loading = $state(false);
private _error = $state<string | null>(null);
private _slotsEndpointAvailable = $state<boolean | null>(null);
get serverProps(): ApiLlamaCppServerProps | null {
return this._serverProps;
}
get loading(): boolean {
return this._loading;
}
get error(): string | null {
return this._error;
}
get modelName(): string | null {
if (!this._serverProps?.model_path) return null;
return this._serverProps.model_path.split(/(\\|\/)/).pop() || null;
}
get supportedModalities(): string[] {
const modalities: string[] = [];
if (this._serverProps?.modalities?.audio) {
modalities.push('audio');
}
if (this._serverProps?.modalities?.vision) {
modalities.push('vision');
}
return modalities;
}
get supportsVision(): boolean {
return this._serverProps?.modalities?.vision ?? false;
}
get supportsAudio(): boolean {
return this._serverProps?.modalities?.audio ?? false;
}
get slotsEndpointAvailable(): boolean | null {
return this._slotsEndpointAvailable;
}
/**
* Check if slots endpoint is available based on server properties and endpoint support
*/
private async checkSlotsEndpointAvailability(): Promise<void> {
if (!this._serverProps) {
this._slotsEndpointAvailable = false;
return;
}
if (this._serverProps.total_slots <= 0) {
this._slotsEndpointAvailable = false;
return;
}
try {
const currentConfig = config();
const apiKey = currentConfig.apiKey?.toString().trim();
const response = await fetch('/slots', {
headers: {
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {})
}
});
if (response.status === 501) {
console.info('Slots endpoint not implemented - server started without --slots flag');
this._slotsEndpointAvailable = false;
return;
}
this._slotsEndpointAvailable = true;
} catch (error) {
console.warn('Unable to test slots endpoint availability:', error);
this._slotsEndpointAvailable = false;
}
}
/**
* Fetches server properties from the server
*/
async fetchServerProps(): Promise<void> {
this._loading = true;
this._error = null;
try {
console.log('Fetching server properties...');
const props = await ChatService.getServerProps();
this._serverProps = props;
console.log('Server properties loaded:', props);
// Check slots endpoint availability after server props are loaded
await this.checkSlotsEndpointAvailability();
} catch (error) {
let errorMessage = 'Failed to connect to server';
if (error instanceof Error) {
// Handle specific error types with user-friendly messages
if (error.name === 'TypeError' && error.message.includes('fetch')) {
errorMessage = 'Server is not running or unreachable';
} else if (error.message.includes('ECONNREFUSED')) {
errorMessage = 'Connection refused - server may be offline';
} else if (error.message.includes('ENOTFOUND')) {
errorMessage = 'Server not found - check server address';
} else if (error.message.includes('ETIMEDOUT')) {
errorMessage = 'Connection timeout - server may be overloaded';
} else if (error.message.includes('500')) {
errorMessage = 'Server error - check server logs';
} else if (error.message.includes('404')) {
errorMessage = 'Server endpoint not found';
} else if (error.message.includes('403') || error.message.includes('401')) {
errorMessage = 'Access denied';
}
}
this._error = errorMessage;
console.error('Error fetching server properties:', error);
} finally {
this._loading = false;
}
}
/**
* Clears the server state
*/
clear(): void {
this._serverProps = null;
this._error = null;
this._loading = false;
this._slotsEndpointAvailable = null;
}
}
export const serverStore = new ServerStore();
export const serverProps = () => serverStore.serverProps;
export const serverLoading = () => serverStore.loading;
export const serverError = () => serverStore.error;
export const modelName = () => serverStore.modelName;
export const supportedModalities = () => serverStore.supportedModalities;
export const supportsVision = () => serverStore.supportsVision;
export const supportsAudio = () => serverStore.supportsAudio;
export const slotsEndpointAvailable = () => serverStore.slotsEndpointAvailable;

View File

@@ -0,0 +1,206 @@
/**
* SettingsStore - Application configuration and theme management
*
* This store manages all application settings including AI model parameters, UI preferences,
* and theme configuration. It provides persistent storage through localStorage with reactive
* state management using Svelte 5 runes.
*
* **Architecture & Relationships:**
* - **SettingsStore** (this class): Configuration state management
* - Manages AI model parameters (temperature, max tokens, etc.)
* - Handles theme switching and persistence
* - Provides localStorage synchronization
* - Offers reactive configuration access
*
* - **ChatService**: Reads model parameters for API requests
* - **UI Components**: Subscribe to theme and configuration changes
*
* **Key Features:**
* - **Model Parameters**: Temperature, max tokens, top-p, top-k, repeat penalty
* - **Theme Management**: Auto, light, dark theme switching
* - **Persistence**: Automatic localStorage synchronization
* - **Reactive State**: Svelte 5 runes for automatic UI updates
* - **Default Handling**: Graceful fallback to defaults for missing settings
* - **Batch Updates**: Efficient multi-setting updates
* - **Reset Functionality**: Restore defaults for individual or all settings
*
* **Configuration Categories:**
* - Generation parameters (temperature, tokens, sampling)
* - UI preferences (theme, display options)
* - System settings (model selection, prompts)
* - Advanced options (seed, penalties, context handling)
*/
import { browser } from '$app/environment';
import { SETTING_CONFIG_DEFAULT } from '$lib/constants/settings-config';
class SettingsStore {
config = $state<SettingsConfigType>({ ...SETTING_CONFIG_DEFAULT });
theme = $state<string>('auto');
isInitialized = $state(false);
constructor() {
if (browser) {
this.initialize();
}
}
/**
* Initialize the settings store by loading from localStorage
*/
initialize() {
try {
this.loadConfig();
this.loadTheme();
this.isInitialized = true;
} catch (error) {
console.error('Failed to initialize settings store:', error);
}
}
/**
* Load configuration from localStorage
* Returns default values for missing keys to prevent breaking changes
*/
private loadConfig() {
if (!browser) return;
try {
const savedVal = JSON.parse(localStorage.getItem('config') || '{}');
// Merge with defaults to prevent breaking changes
this.config = {
...SETTING_CONFIG_DEFAULT,
...savedVal
};
} catch (error) {
console.warn('Failed to parse config from localStorage, using defaults:', error);
this.config = { ...SETTING_CONFIG_DEFAULT };
}
}
/**
* Load theme from localStorage
*/
private loadTheme() {
if (!browser) return;
this.theme = localStorage.getItem('theme') || 'auto';
}
/**
* Update a specific configuration setting
* @param key - The configuration key to update
* @param value - The new value for the configuration key
*/
updateConfig<K extends keyof SettingsConfigType>(key: K, value: SettingsConfigType[K]) {
this.config[key] = value;
this.saveConfig();
}
/**
* Update multiple configuration settings at once
* @param updates - Object containing the configuration updates
*/
updateMultipleConfig(updates: Partial<SettingsConfigType>) {
Object.assign(this.config, updates);
this.saveConfig();
}
/**
* Save the current configuration to localStorage
*/
private saveConfig() {
if (!browser) return;
try {
localStorage.setItem('config', JSON.stringify(this.config));
} catch (error) {
console.error('Failed to save config to localStorage:', error);
}
}
/**
* Update the theme setting
* @param newTheme - The new theme value
*/
updateTheme(newTheme: string) {
this.theme = newTheme;
this.saveTheme();
}
/**
* Save the current theme to localStorage
*/
private saveTheme() {
if (!browser) return;
try {
if (this.theme === 'auto') {
localStorage.removeItem('theme');
} else {
localStorage.setItem('theme', this.theme);
}
} catch (error) {
console.error('Failed to save theme to localStorage:', error);
}
}
/**
* Reset configuration to defaults
*/
resetConfig() {
this.config = { ...SETTING_CONFIG_DEFAULT };
this.saveConfig();
}
/**
* Reset theme to auto
*/
resetTheme() {
this.theme = 'auto';
this.saveTheme();
}
/**
* Reset all settings to defaults
*/
resetAll() {
this.resetConfig();
this.resetTheme();
}
/**
* Get a specific configuration value
* @param key - The configuration key to get
* @returns The configuration value
*/
getConfig<K extends keyof SettingsConfigType>(key: K): SettingsConfigType[K] {
return this.config[key];
}
/**
* Get the entire configuration object
* @returns The complete configuration object
*/
getAllConfig(): SettingsConfigType {
return { ...this.config };
}
}
// Create and export the settings store instance
export const settingsStore = new SettingsStore();
// Export reactive getters for easy access in components
export const config = () => settingsStore.config;
export const theme = () => settingsStore.theme;
export const isInitialized = () => settingsStore.isInitialized;
// Export bound methods for easy access
export const updateConfig = settingsStore.updateConfig.bind(settingsStore);
export const updateMultipleConfig = settingsStore.updateMultipleConfig.bind(settingsStore);
export const updateTheme = settingsStore.updateTheme.bind(settingsStore);
export const resetConfig = settingsStore.resetConfig.bind(settingsStore);
export const resetTheme = settingsStore.resetTheme.bind(settingsStore);
export const resetAll = settingsStore.resetAll.bind(settingsStore);
export const getConfig = settingsStore.getConfig.bind(settingsStore);
export const getAllConfig = settingsStore.getAllConfig.bind(settingsStore);