ui: Restructure repo to use tools/ui folder and ui / UI / llama-ui / LLAMA_UI naming (#23064)
* webui: Move static build output from `tools/server/public` to `build/ui` directory * refactor: Move to `tools/ui` * refactor: rename CMake variables and preprocessor defines - Rename LLAMA_BUILD_WEBUI -> LLAMA_BUILD_UI (old kept as deprecated) - Rename LLAMA_USE_PREBUILT_WEBUI -> LLAMA_USE_PREBUILT_UI (old kept as deprecated) - Backward compat: old vars auto-forward to new ones with DEPRECATION warning - Rename internal vars: WEBUI_SOURCE -> UI_SOURCE, WEBUI_SOURCE_DIR -> UI_SOURCE_DIR, etc. - Rename HF bucket: LLAMA_WEBUI_HF_BUCKET -> LLAMA_UI_HF_BUCKET - Emit both LLAMA_BUILD_WEBUI and LLAMA_BUILD_UI preprocessor defines - Emit both LLAMA_WEBUI_DEFAULT_ENABLED and LLAMA_UI_DEFAULT_ENABLED * refactor: rename CLI flags (--webui -> --ui) with backward compat - Add --ui/--no-ui (old --webui/--no-webui kept as deprecated aliases) - Add --ui-config (old --webui-config kept as deprecated alias) - Add --ui-config-file (old --webui-config-file kept as deprecated alias) - Add --ui-mcp-proxy/--no-ui-mcp-proxy (old --webui-mcp-proxy kept as deprecated) - Add new env vars: LLAMA_ARG_UI, LLAMA_ARG_UI_CONFIG, LLAMA_ARG_UI_CONFIG_FILE, LLAMA_ARG_UI_MCP_PROXY - C++ struct fields: params.ui, params.ui_config_json, params.ui_mcp_proxy added alongside old fields - Backward compat: old fields synced to new ones in g_params_to_internals * refactor: update C++ server internals with backward compat - Rename json_webui_settings -> json_ui_settings (both kept in server_context_meta) - Rename params.webui usage -> params.ui (both synced, old still works) - JSON API emits both "ui"/"ui_settings" and "webui"/"webui_settings" keys - Server routes use params.ui_mcp_proxy || params.webui_mcp_proxy - Preprocessor guards use #if defined(LLAMA_BUILD_UI) || defined(LLAMA_BUILD_WEBUI) * refactor: rename CI/CD workflows, artifacts, and build script - Rename webui-build.yml -> ui-build.yml; artifact webui-build -> ui-build - Rename webui-publish.yml -> ui-publish.yml; var HF_BUCKET_WEBUI_STATIC_OUTPUT -> HF_BUCKET_UI_STATIC_OUTPUT - Rename server-webui.yml -> server-ui.yml; job webui-build/checks -> ui-build/checks - Update server.yml: job/artifact refs webui-build -> ui-build - Update release.yml: all webui-build/publish refs -> ui-build/publish; HF_TOKEN_WEBUI_STATIC_OUTPUT -> HF_TOKEN_UI_STATIC_OUTPUT - Update server-self-hosted.yml: webui-build -> ui-build - Update build-self-hosted.yml: HF_WEBUI_VERSION -> HF_UI_VERSION - Rename webui-download.cmake -> ui-download.cmake (internal refs updated) - Update labeler.yml: server/webui -> server/ui path label * docs: update CODEOWNERS and server README docs - Update CODEOWNERS: team ggml-org/llama-webui -> ggml-org/llama-ui, path /tools/server/webui/ -> /tools/ui/ - Update server README.md: CLI tables show --ui flags with deprecated --webui aliases - Update server README-dev.md: "WebUI" -> "UI", paths updated to tools/ui/ * fix: Small fixes for UI build * fix: CMake.txt syntax * chore: Formatting * fix: `.editorconfig` for llama-ui * chore: Formatting * refactor: Use `APP_NAME` in Error route * refactor: Cleanup * refactor: Single migration service * make llama-ui a linkable target * fix: UI Build output * fix: Missing change * fix: separate llama-ui npm build output into build/tools/ui/dist subfolder + use cmake npm build instead of downloading ui-build.yml artifacts in CI * refactor: UI workflows cleanup --------- Co-authored-by: Xuan Son Nguyen <son@huggingface.co>
This commit is contained in:
co-authored by
Xuan Son Nguyen
parent
49d1701bd2
commit
59778f0196
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,515 @@
|
||||
import Dexie, { type EntityTable } from 'dexie';
|
||||
import { findDescendantMessages, uuid, filterByLeafNodeId } from '$lib/utils';
|
||||
import { IDXDB_TABLES, IDXDB_STORES, STORAGE_APP_NAME } from '$lib/constants';
|
||||
import { MessageRole } from '$lib/enums';
|
||||
import type { McpServerOverride } from '$lib/types/database';
|
||||
|
||||
class LlamaUiDatabase extends Dexie {
|
||||
[IDXDB_TABLES.conversations]!: EntityTable<DatabaseConversation, string>;
|
||||
[IDXDB_TABLES.messages]!: EntityTable<DatabaseMessage, string>;
|
||||
|
||||
constructor() {
|
||||
super(STORAGE_APP_NAME);
|
||||
|
||||
this.version(1).stores(IDXDB_STORES);
|
||||
}
|
||||
}
|
||||
|
||||
const db = new LlamaUiDatabase();
|
||||
|
||||
export class DatabaseService {
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Conversations
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* 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[IDXDB_TABLES.conversations].add(conversation);
|
||||
return conversation;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Messages
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* 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[IDXDB_TABLES.conversations], db[IDXDB_TABLES.messages]],
|
||||
async () => {
|
||||
// Handle null parent (root message case)
|
||||
if (parentId !== null) {
|
||||
const parentMessage = await db[IDXDB_TABLES.messages].get(parentId);
|
||||
if (!parentMessage) {
|
||||
throw new Error(`Parent message ${parentId} not found`);
|
||||
}
|
||||
}
|
||||
|
||||
const newMessage: DatabaseMessage = {
|
||||
...message,
|
||||
id: uuid(),
|
||||
parent: parentId,
|
||||
toolCalls: message.toolCalls ?? '',
|
||||
children: []
|
||||
};
|
||||
|
||||
await db[IDXDB_TABLES.messages].add(newMessage);
|
||||
|
||||
// Update parent's children array if parent exists
|
||||
if (parentId !== null) {
|
||||
const parentMessage = await db[IDXDB_TABLES.messages].get(parentId);
|
||||
if (parentMessage) {
|
||||
await db[IDXDB_TABLES.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: MessageRole.SYSTEM,
|
||||
content: '',
|
||||
parent: null,
|
||||
toolCalls: '',
|
||||
children: []
|
||||
};
|
||||
|
||||
await db[IDXDB_TABLES.messages].add(rootMessage);
|
||||
return rootMessage.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a system prompt message for a conversation.
|
||||
*
|
||||
* @param convId - Conversation ID
|
||||
* @param systemPrompt - The system prompt content (must be non-empty)
|
||||
* @param parentId - Parent message ID (typically the root message)
|
||||
* @returns The created system message
|
||||
* @throws Error if systemPrompt is empty
|
||||
*/
|
||||
static async createSystemMessage(
|
||||
convId: string,
|
||||
systemPrompt: string,
|
||||
parentId: string
|
||||
): Promise<DatabaseMessage> {
|
||||
const trimmedPrompt = systemPrompt.trim();
|
||||
if (!trimmedPrompt) {
|
||||
throw new Error('Cannot create system message with empty content');
|
||||
}
|
||||
|
||||
const systemMessage: DatabaseMessage = {
|
||||
id: uuid(),
|
||||
convId,
|
||||
type: MessageRole.SYSTEM,
|
||||
timestamp: Date.now(),
|
||||
role: MessageRole.SYSTEM,
|
||||
content: trimmedPrompt,
|
||||
parent: parentId,
|
||||
children: []
|
||||
};
|
||||
|
||||
await db[IDXDB_TABLES.messages].add(systemMessage);
|
||||
|
||||
const parentMessage = await db[IDXDB_TABLES.messages].get(parentId);
|
||||
if (parentMessage) {
|
||||
await db[IDXDB_TABLES.messages].update(parentId, {
|
||||
children: [...parentMessage.children, systemMessage.id]
|
||||
});
|
||||
}
|
||||
|
||||
return systemMessage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a conversation and all its messages.
|
||||
*
|
||||
* @param id - Conversation ID
|
||||
*/
|
||||
static async deleteConversation(
|
||||
id: string,
|
||||
options?: { deleteWithForks?: boolean }
|
||||
): Promise<void> {
|
||||
await db.transaction(
|
||||
'rw',
|
||||
[db[IDXDB_TABLES.conversations], db[IDXDB_TABLES.messages]],
|
||||
async () => {
|
||||
if (options?.deleteWithForks) {
|
||||
// Recursively collect all descendant IDs
|
||||
const idsToDelete: string[] = [];
|
||||
const queue = [id];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const parentId = queue.pop()!;
|
||||
const children = await db[IDXDB_TABLES.conversations]
|
||||
.filter((c) => c.forkedFromConversationId === parentId)
|
||||
.toArray();
|
||||
|
||||
for (const child of children) {
|
||||
idsToDelete.push(child.id);
|
||||
queue.push(child.id);
|
||||
}
|
||||
}
|
||||
|
||||
for (const forkId of idsToDelete) {
|
||||
await db[IDXDB_TABLES.conversations].delete(forkId);
|
||||
await db[IDXDB_TABLES.messages].where('convId').equals(forkId).delete();
|
||||
}
|
||||
} else {
|
||||
// Reparent direct children to deleted conv's parent
|
||||
const conv = await db[IDXDB_TABLES.conversations].get(id);
|
||||
const newParent = conv?.forkedFromConversationId;
|
||||
const directChildren = await db[IDXDB_TABLES.conversations]
|
||||
.filter((c) => c.forkedFromConversationId === id)
|
||||
.toArray();
|
||||
|
||||
for (const child of directChildren) {
|
||||
await db[IDXDB_TABLES.conversations].update(child.id, {
|
||||
forkedFromConversationId: newParent ?? undefined
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await db[IDXDB_TABLES.conversations].delete(id);
|
||||
await db[IDXDB_TABLES.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[IDXDB_TABLES.messages], async () => {
|
||||
const message = await db[IDXDB_TABLES.messages].get(messageId);
|
||||
if (!message) return;
|
||||
|
||||
// Remove this message from its parent's children array
|
||||
if (message.parent) {
|
||||
const parent = await db[IDXDB_TABLES.messages].get(message.parent);
|
||||
if (parent) {
|
||||
parent.children = parent.children.filter((childId: string) => childId !== messageId);
|
||||
await db[IDXDB_TABLES.messages].put(parent);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete the message
|
||||
await db[IDXDB_TABLES.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[IDXDB_TABLES.messages], async () => {
|
||||
// Get all messages in the conversation to find descendants
|
||||
const allMessages = await db[IDXDB_TABLES.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[IDXDB_TABLES.messages].get(messageId);
|
||||
if (message && message.parent) {
|
||||
const parent = await db[IDXDB_TABLES.messages].get(message.parent);
|
||||
if (parent) {
|
||||
parent.children = parent.children.filter((childId: string) => childId !== messageId);
|
||||
await db[IDXDB_TABLES.messages].put(parent);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete all messages in the branch
|
||||
await db[IDXDB_TABLES.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[IDXDB_TABLES.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[IDXDB_TABLES.conversations].get(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[IDXDB_TABLES.messages].where('convId').equals(convId).sortBy('timestamp');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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[IDXDB_TABLES.conversations].update(id, {
|
||||
...updates,
|
||||
lastModified: Date.now()
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Navigation
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* 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[IDXDB_TABLES.messages].update(id, updates);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Import
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Imports multiple conversations and their messages.
|
||||
* Skips conversations that already exist.
|
||||
*
|
||||
* @param data - Array of { conv, messages } objects
|
||||
*/
|
||||
static async importConversations(
|
||||
data: { conv: DatabaseConversation; messages: DatabaseMessage[] }[]
|
||||
): Promise<{ imported: number; skipped: number }> {
|
||||
let importedCount = 0;
|
||||
let skippedCount = 0;
|
||||
|
||||
return await db.transaction(
|
||||
'rw',
|
||||
[db[IDXDB_TABLES.conversations], db[IDXDB_TABLES.messages]],
|
||||
async () => {
|
||||
for (const item of data) {
|
||||
const { conv, messages } = item;
|
||||
|
||||
const existing = await db[IDXDB_TABLES.conversations].get(conv.id);
|
||||
if (existing) {
|
||||
console.warn(`Conversation "${conv.name}" already exists, skipping...`);
|
||||
skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
await db[IDXDB_TABLES.conversations].add(conv);
|
||||
for (const msg of messages) {
|
||||
await db[IDXDB_TABLES.messages].put(msg);
|
||||
}
|
||||
|
||||
importedCount++;
|
||||
}
|
||||
|
||||
return { imported: importedCount, skipped: skippedCount };
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Forking
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Forks a conversation at a specific message, creating a new conversation
|
||||
* containing all messages from the root up to (and including) the target message.
|
||||
*
|
||||
* @param sourceConvId - The source conversation ID
|
||||
* @param atMessageId - The message ID to fork at (the new conversation ends here)
|
||||
* @param options - Fork options (name and whether to include attachments)
|
||||
* @returns The newly created conversation
|
||||
*/
|
||||
static async forkConversation(
|
||||
sourceConvId: string,
|
||||
atMessageId: string,
|
||||
options: { name: string; includeAttachments: boolean }
|
||||
): Promise<DatabaseConversation> {
|
||||
return await db.transaction(
|
||||
'rw',
|
||||
[db[IDXDB_TABLES.conversations], db[IDXDB_TABLES.messages]],
|
||||
async () => {
|
||||
const sourceConv = await db[IDXDB_TABLES.conversations].get(sourceConvId);
|
||||
if (!sourceConv) {
|
||||
throw new Error(`Source conversation ${sourceConvId} not found`);
|
||||
}
|
||||
|
||||
const allMessages = await db[IDXDB_TABLES.messages]
|
||||
.where('convId')
|
||||
.equals(sourceConvId)
|
||||
.toArray();
|
||||
|
||||
const pathMessages = filterByLeafNodeId(
|
||||
allMessages,
|
||||
atMessageId,
|
||||
true
|
||||
) as DatabaseMessage[];
|
||||
if (pathMessages.length === 0) {
|
||||
throw new Error(`Could not resolve message path to ${atMessageId}`);
|
||||
}
|
||||
|
||||
const idMap = new Map<string, string>();
|
||||
|
||||
for (const msg of pathMessages) {
|
||||
idMap.set(msg.id, uuid());
|
||||
}
|
||||
|
||||
const newConvId = uuid();
|
||||
const clonedMessages: DatabaseMessage[] = pathMessages.map((msg) => {
|
||||
const newId = idMap.get(msg.id)!;
|
||||
const newParent = msg.parent ? (idMap.get(msg.parent) ?? null) : null;
|
||||
const newChildren = msg.children
|
||||
.filter((childId: string) => idMap.has(childId))
|
||||
.map((childId: string) => idMap.get(childId)!);
|
||||
|
||||
return {
|
||||
...msg,
|
||||
id: newId,
|
||||
convId: newConvId,
|
||||
parent: newParent,
|
||||
children: newChildren,
|
||||
extra: options.includeAttachments ? msg.extra : undefined
|
||||
};
|
||||
});
|
||||
|
||||
const lastClonedMessage = clonedMessages[clonedMessages.length - 1];
|
||||
const newConv: DatabaseConversation = {
|
||||
id: newConvId,
|
||||
name: options.name,
|
||||
lastModified: Date.now(),
|
||||
currNode: lastClonedMessage.id,
|
||||
forkedFromConversationId: sourceConvId,
|
||||
mcpServerOverrides: sourceConv.mcpServerOverrides
|
||||
? sourceConv.mcpServerOverrides.map((o: McpServerOverride) => ({
|
||||
serverId: o.serverId,
|
||||
enabled: o.enabled
|
||||
}))
|
||||
: undefined
|
||||
};
|
||||
|
||||
await db[IDXDB_TABLES.conversations].add(newConv);
|
||||
|
||||
for (const msg of clonedMessages) {
|
||||
await db[IDXDB_TABLES.messages].add(msg);
|
||||
}
|
||||
|
||||
return newConv;
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
/**
|
||||
*
|
||||
* SERVICES
|
||||
*
|
||||
* Stateless service layer for API communication and data operations.
|
||||
* Services handle protocol-level concerns (HTTP, WebSocket, MCP, IndexedDB)
|
||||
* without managing reactive state — that responsibility belongs to stores.
|
||||
*
|
||||
* **Design Principles:**
|
||||
* - All methods are static — no instance state
|
||||
* - Pure I/O operations (network requests, database queries)
|
||||
* - No Svelte runes or reactive primitives
|
||||
* - Error handling at the protocol level; business-level error handling in stores
|
||||
*
|
||||
* **Architecture (bottom to top):**
|
||||
* - **Services** (this layer): Stateless protocol communication
|
||||
* - **Stores**: Reactive state management consuming services
|
||||
* - **Components**: UI consuming stores
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* **ChatService** - Chat Completions API communication layer
|
||||
*
|
||||
* Handles direct communication with the llama-server's `/v1/chat/completions` endpoint.
|
||||
* Provides streaming and non-streaming response parsing, message format conversion
|
||||
* (DatabaseMessage → API format), and request lifecycle management.
|
||||
*
|
||||
* **Terminology - Chat vs Conversation:**
|
||||
* - **Chat**: The active interaction space with the Chat Completions API. Ephemeral and
|
||||
* runtime-focused — sending messages, receiving streaming responses, managing request lifecycles.
|
||||
* - **Conversation**: The persistent database entity storing all messages and metadata.
|
||||
* Managed by conversationsStore, conversations persist across sessions.
|
||||
*
|
||||
* **Architecture & Relationships:**
|
||||
* - **ChatService** (this class): Stateless API communication layer
|
||||
* - Handles HTTP requests/responses with the llama-server
|
||||
* - Manages streaming and non-streaming response parsing
|
||||
* - Converts database messages to API format (multimodal, tool calls)
|
||||
* - Handles error translation with user-friendly messages
|
||||
*
|
||||
* - **chatStore**: Primary consumer — uses ChatService for all AI model communication
|
||||
* - **agenticStore**: Uses ChatService for multi-turn agentic loop streaming
|
||||
* - **conversationsStore**: Provides message context for API requests
|
||||
*
|
||||
* **Key Responsibilities:**
|
||||
* - Streaming response handling with real-time content/reasoning/tool-call callbacks
|
||||
* - Non-streaming response parsing with complete response extraction
|
||||
* - Database message to API format conversion (attachments, tool calls, multimodal)
|
||||
* - Tool call delta merging for incremental streaming aggregation
|
||||
* - Request parameter assembly (sampling, penalties, custom params)
|
||||
* - File attachment processing (images, PDFs, audio, text, MCP prompts/resources)
|
||||
* - Reasoning content stripping from prompt history to avoid KV cache pollution
|
||||
* - Error translation (network, timeout, server errors → user-friendly messages)
|
||||
*
|
||||
* @see chatStore in stores/chat.svelte.ts — primary consumer for chat state management
|
||||
* @see agenticStore in stores/agentic.svelte.ts — uses ChatService for agentic loop streaming
|
||||
* @see conversationsStore in stores/conversations.svelte.ts — provides message context
|
||||
*/
|
||||
export { ChatService } from './chat.service';
|
||||
|
||||
/**
|
||||
* **DatabaseService** - IndexedDB persistence layer via Dexie ORM
|
||||
*
|
||||
* Provides stateless data access for conversations and messages using IndexedDB.
|
||||
* Handles all low-level storage operations including branching tree structures,
|
||||
* cascade deletions, and transaction safety for multi-table operations.
|
||||
*
|
||||
* **Architecture & Relationships (bottom to top):**
|
||||
* - **DatabaseService** (this class): Stateless IndexedDB operations
|
||||
* - Lowest layer — direct Dexie/IndexedDB communication
|
||||
* - Pure CRUD operations without business logic
|
||||
* - Handles branching tree structure (parent-child relationships)
|
||||
* - Provides transaction safety for multi-table operations
|
||||
*
|
||||
* - **conversationsStore**: Reactive state management layer
|
||||
* - Uses DatabaseService for all persistence operations
|
||||
* - Manages conversation list, active conversation, and messages in memory
|
||||
*
|
||||
* - **chatStore**: Active AI interaction management
|
||||
* - Uses conversationsStore for conversation context
|
||||
* - Directly uses DatabaseService for message CRUD during streaming
|
||||
*
|
||||
* **Key Responsibilities:**
|
||||
* - Conversation CRUD (create, read, update, delete)
|
||||
* - Message CRUD with branching support (parent-child relationships)
|
||||
* - Root message and system prompt creation
|
||||
* - Cascade deletion of message branches (descendants)
|
||||
* - Transaction-safe multi-table operations
|
||||
* - Conversation import with duplicate detection
|
||||
*
|
||||
* **Database Schema:**
|
||||
* - `conversations`: id, lastModified, currNode, name
|
||||
* - `messages`: id, convId, type, role, timestamp, parent, children
|
||||
*
|
||||
* **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.
|
||||
*
|
||||
* @see conversationsStore in stores/conversations.svelte.ts — reactive layer on top of DatabaseService
|
||||
* @see chatStore in stores/chat.svelte.ts — uses DatabaseService directly for message CRUD during streaming
|
||||
*/
|
||||
export { DatabaseService } from './database.service';
|
||||
|
||||
/**
|
||||
* **ModelsService** - Model management API communication
|
||||
*
|
||||
* Handles communication with model-related endpoints for both MODEL (single model)
|
||||
* and ROUTER (multi-model) server modes. Provides model listing, loading/unloading,
|
||||
* and status checking without managing any model state.
|
||||
*
|
||||
* **Architecture & Relationships:**
|
||||
* - **ModelsService** (this class): Stateless HTTP communication
|
||||
* - Sends requests to model endpoints
|
||||
* - Parses and returns typed API responses
|
||||
* - Provides model status utility methods
|
||||
*
|
||||
* - **modelsStore**: Primary consumer — manages reactive model state
|
||||
* - Calls ModelsService for all model API operations
|
||||
* - Handles polling, caching, and state updates
|
||||
*
|
||||
* **Key Responsibilities:**
|
||||
* - List available models via OpenAI-compatible `/v1/models` endpoint
|
||||
* - Load/unload models via `/models/load` and `/models/unload` (ROUTER mode)
|
||||
* - Model status queries (loaded, loading)
|
||||
*
|
||||
* **Server Mode Behavior:**
|
||||
* - **MODEL mode**: Only `list()` is relevant — single model always loaded
|
||||
* - **ROUTER mode**: Full lifecycle — `list()`, `listRouter()`, `load()`, `unload()`
|
||||
*
|
||||
* **Endpoints:**
|
||||
* - `GET /v1/models` — OpenAI-compatible model list (both modes)
|
||||
* - `POST /models/load` — Load a model (ROUTER mode only)
|
||||
* - `POST /models/unload` — Unload a model (ROUTER mode only)
|
||||
*
|
||||
* @see modelsStore in stores/models.svelte.ts — primary consumer for reactive model state
|
||||
*/
|
||||
export { ModelsService } from './models.service';
|
||||
|
||||
/**
|
||||
* **PropsService** - Server properties and capabilities retrieval
|
||||
*
|
||||
* Fetches server configuration, model information, and capabilities from the `/props`
|
||||
* endpoint. Supports both global server props and per-model props (ROUTER mode).
|
||||
*
|
||||
* **Architecture & Relationships:**
|
||||
* - **PropsService** (this class): Stateless HTTP communication
|
||||
* - Fetches server properties from `/props` endpoint
|
||||
* - Handles authentication and request parameters
|
||||
* - Returns typed `ApiLlamaCppServerProps` responses
|
||||
*
|
||||
* - **serverStore**: Consumes global server properties (role detection, connection state)
|
||||
* - **modelsStore**: Consumes per-model properties (modalities, context size)
|
||||
* - **settingsStore**: Syncs default generation parameters from props response
|
||||
*
|
||||
* **Key Responsibilities:**
|
||||
* - Fetch global server properties (default generation settings, modalities)
|
||||
* - Fetch per-model properties in ROUTER mode via `?model=<id>` parameter
|
||||
* - Handle autoload control to prevent unintended model loading
|
||||
*
|
||||
* **API Behavior:**
|
||||
* - `GET /props` → Global server props (MODEL mode: includes modalities)
|
||||
* - `GET /props?model=<id>` → Per-model props (ROUTER mode: model-specific modalities)
|
||||
* - `&autoload=false` → Prevents model auto-loading when querying props
|
||||
*
|
||||
* @see serverStore in stores/server.svelte.ts — consumes global server props
|
||||
* @see modelsStore in stores/models.svelte.ts — consumes per-model props for modalities
|
||||
* @see settingsStore in stores/settings.svelte.ts — syncs default generation params from props
|
||||
*/
|
||||
export { PropsService } from './props.service';
|
||||
|
||||
/**
|
||||
* **ParameterSyncService** - Server defaults and user settings synchronization
|
||||
*
|
||||
* Manages the complex logic of merging server-provided default parameters with
|
||||
* user-configured overrides. Ensures the UI reflects the actual server state
|
||||
* while preserving user customizations. Tracks parameter sources (server default
|
||||
* vs user override) for display in the settings UI.
|
||||
*
|
||||
* **Architecture & Relationships:**
|
||||
* - **ParameterSyncService** (this class): Stateless sync logic
|
||||
* - Pure functions for parameter extraction, merging, and diffing
|
||||
* - No side effects — receives data in, returns data out
|
||||
* - Handles floating-point precision normalization
|
||||
*
|
||||
* - **settingsStore**: Primary consumer — calls sync methods during:
|
||||
* - Initial load (`syncWithServerDefaults`)
|
||||
* - Settings reset (`forceSyncWithServerDefaults`)
|
||||
* - Parameter info queries (`getParameterInfo`)
|
||||
*
|
||||
* - **PropsService**: Provides raw server props that feed into extraction
|
||||
*
|
||||
* **Key Responsibilities:**
|
||||
* - Extract syncable parameters from server `/props` response
|
||||
* - Merge server defaults with user overrides (user wins)
|
||||
* - Track parameter source (Custom vs Default) for UI badges
|
||||
* - Validate server parameter values by type (number, string, boolean)
|
||||
* - Create diffs between current settings and server defaults
|
||||
* - Floating-point precision normalization for consistent comparisons
|
||||
*
|
||||
* **Parameter Source Priority:**
|
||||
* 1. **User Override** (Custom badge) — explicitly set by user in settings
|
||||
* 2. **Server Default** (Default badge) — from `/props` endpoint
|
||||
* 3. **App Default** — hardcoded fallback when server props unavailable
|
||||
*
|
||||
* **Exports:**
|
||||
* - `ParameterSyncService` class — static methods for sync logic
|
||||
* - `SYNCABLE_PARAMETERS` — mapping of UI setting keys to server parameter keys
|
||||
*
|
||||
* @see settingsStore in stores/settings.svelte.ts — primary consumer for settings sync
|
||||
* @see SettingsChatParameterSourceIndicator — displays parameter source badges in UI
|
||||
*/
|
||||
export { ParameterSyncService } from './parameter-sync.service';
|
||||
|
||||
/**
|
||||
* **MCPService** - Low-level MCP protocol communication layer
|
||||
*
|
||||
* Implements the client-side MCP (Model Context Protocol) SDK operations for connecting
|
||||
* to MCP servers, discovering capabilities, and executing protocol operations.
|
||||
* Supports multiple transport types: WebSocket, StreamableHTTP, and SSE (legacy fallback).
|
||||
*
|
||||
* **Architecture & Relationships:**
|
||||
* - **MCPService** (this class): Stateless protocol communication
|
||||
* - Creates and manages transport connections (WebSocket, StreamableHTTP, SSE)
|
||||
* - Wraps MCP SDK client operations with error handling
|
||||
* - Formats tool results and extracts server info
|
||||
* - Provides abort signal support for cancellable operations
|
||||
*
|
||||
* - **mcpStore**: Reactive business logic facade
|
||||
* - Uses MCPService for all protocol-level operations
|
||||
* - Manages connection lifecycle, health checks, reconnection
|
||||
* - Handles tool name conflict resolution and server coordination
|
||||
*
|
||||
* - **mcpResourceStore**: Reactive resource state
|
||||
* - Receives resource data fetched via MCPService
|
||||
* - Manages resource caching, subscriptions, and attachments
|
||||
*
|
||||
* - **agenticStore**: Agentic loop orchestration
|
||||
* - Executes tool calls via mcpStore → MCPService chain
|
||||
*
|
||||
* **Key Responsibilities:**
|
||||
* - Transport creation with automatic fallback (StreamableHTTP → SSE)
|
||||
* - Server connection with detailed phase tracking and progress callbacks
|
||||
* - Tool discovery (`listTools`) and execution (`callTool`) with abort support
|
||||
* - Prompt listing (`listPrompts`) and retrieval (`getPrompt`) with arguments
|
||||
* - Resource operations: list, read, subscribe/unsubscribe, template support
|
||||
* - Completion suggestions for prompt arguments and resource URI templates
|
||||
* - CORS proxy routing via llama-server for cross-origin MCP servers
|
||||
* - Tool result formatting (text, images, embedded resources)
|
||||
*
|
||||
* **Transport Hierarchy:**
|
||||
* 1. **WebSocket** — bidirectional, no CORS proxy support
|
||||
* 2. **StreamableHTTP** — modern HTTP-based, supports CORS proxy
|
||||
* 3. **SSE** — legacy fallback, supports CORS proxy
|
||||
*
|
||||
* @see mcpStore in stores/mcp.svelte.ts — reactive business logic facade on top of MCPService
|
||||
* @see mcpResourceStore in stores/mcp-resources.svelte.ts — reactive resource state management
|
||||
* @see agenticStore in stores/agentic.svelte.ts — uses MCPService (via mcpStore) for tool execution
|
||||
* @see MCP Protocol Specification: https://modelcontextprotocol.io/specification/2025-06-18
|
||||
*/
|
||||
export { MCPService } from './mcp.service';
|
||||
|
||||
/**
|
||||
* **RouterService** — Dynamic route URL construction utility
|
||||
*
|
||||
* Stateless utility for building dynamic route URLs from ROUTES base paths.
|
||||
* Static routes (START, NEW_CHAT, MCP_SERVERS) live in ROUTES constants;
|
||||
* dynamic routes (CHAT, SETTINGS) are constructed here by appending parameters.
|
||||
*
|
||||
* **Architecture & Relationships:**
|
||||
* - **RouterService** (this class): Stateless URL construction
|
||||
* - Builds dynamic route URLs from ROUTES base paths
|
||||
* - No side effects — receives route parameters, returns route strings
|
||||
*
|
||||
* - **ROUTES constant** (constants/routes.ts): Static route base paths
|
||||
* - **All components/stores**: Call RouterService for dynamic route URLs
|
||||
*
|
||||
* **Key Responsibilities:**
|
||||
* - Build chat URLs for specific conversations: `RouterService.chat(id)` → `#/chat/:id`
|
||||
* - Build settings URLs for sections: `RouterService.settings(section)` → `#/settings/:section`
|
||||
*
|
||||
* @see ROUTES in constants/routes.ts — static route base paths
|
||||
*/
|
||||
export { RouterService } from './router.service';
|
||||
|
||||
/**
|
||||
* **MigrationService** — Unified data migration hook
|
||||
*
|
||||
* Centralizes all data migrations (localStorage, IndexedDB, legacy formats) into a single
|
||||
* initialization point. All migrations are NON-DESTRUCTIVE - legacy data is preserved
|
||||
* for downgrade compatibility (no rollback needed).
|
||||
*
|
||||
* **Current Migrations:**
|
||||
* 1. **localStorage prefix**: Copy LlamaCppWebui.* → LlamaUi.* (both preserved)
|
||||
* 2. **IndexedDB database**: Copy LlamacppWebui → LlamaUi (both preserved)
|
||||
* 3. **Legacy message format**: Marker-based → Structured format
|
||||
* 4. **Theme key**: Copy standalone `theme` → config object (both preserved)
|
||||
*
|
||||
* **Usage:**
|
||||
* ```typescript
|
||||
* import { MigrationService } from '$lib/services';
|
||||
*
|
||||
* // Run all migrations on app startup (non-destructive)
|
||||
* await MigrationService.runAllMigrations();
|
||||
*
|
||||
* // Check migration status
|
||||
* const state = MigrationService.getState();
|
||||
* ```
|
||||
*
|
||||
* @see migration.service.ts — full implementation (non-destructive)
|
||||
*/
|
||||
export { MigrationService } from './migration.service';
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,524 @@
|
||||
/**
|
||||
* Migration Service - Unified data migration hook
|
||||
*
|
||||
* Centralizes all data migrations (localStorage, IndexedDB, legacy formats) into a single
|
||||
* initialization point. Each migration copies data to new format WITHOUT deleting the old.
|
||||
*
|
||||
* **Architecture:**
|
||||
* - Migrations are defined as objects with `id` and `run()` methods
|
||||
* - Migration state is tracked in localStorage to avoid re-running
|
||||
* - `runAllMigrations()` should be called once at app startup
|
||||
* - All migrations are NON-DESTRUCTIVE - legacy data is preserved for downgrade compatibility
|
||||
*
|
||||
* **Current Migrations:**
|
||||
* 1. localStorage prefix: Copy LlamaCppWebui.* → LlamaUi.* (both preserved)
|
||||
* 2. IndexedDB database: Copy LlamacppWebui → LlamaUi (both preserved)
|
||||
* 3. Legacy message format: Transform in-place (preserves structure, migrates markers)
|
||||
* 4. Theme key: Copy standalone `theme` → config object (both preserved)
|
||||
*/
|
||||
|
||||
import Dexie from 'dexie';
|
||||
import {
|
||||
STORAGE_APP_NAME,
|
||||
DB_APP_NAME_DEPRECATED,
|
||||
CONFIG_LOCALSTORAGE_KEY,
|
||||
IDXDB_TABLES,
|
||||
IDXDB_STORES,
|
||||
NEW_TO_DEPRECATED_MAP
|
||||
} from '$lib/constants';
|
||||
import { LEGACY_AGENTIC_REGEX, LEGACY_REASONING_TAGS } from '$lib/constants/agentic';
|
||||
import { SETTINGS_KEYS } from '$lib/constants/settings-registry';
|
||||
import { MessageRole } from '$lib/enums';
|
||||
|
||||
// Types
|
||||
|
||||
interface Migration {
|
||||
/** Unique identifier for this migration */
|
||||
id: string;
|
||||
/** Human-readable description */
|
||||
description: string;
|
||||
/** Run the migration forward (non-destructive - copies, doesn't delete) */
|
||||
run(): Promise<void>;
|
||||
}
|
||||
|
||||
interface MigrationState {
|
||||
completed: string[];
|
||||
failed: string[];
|
||||
lastRun: string;
|
||||
}
|
||||
|
||||
// Constants
|
||||
|
||||
const MIGRATION_STATE_KEY = `${STORAGE_APP_NAME}.migration-state`;
|
||||
const MIGRATION_STATE_VERSION = 1;
|
||||
|
||||
// State Management
|
||||
|
||||
function getMigrationState(): MigrationState {
|
||||
try {
|
||||
const raw = localStorage.getItem(MIGRATION_STATE_KEY);
|
||||
if (!raw) return { completed: [], failed: [], lastRun: '' };
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed.version !== MIGRATION_STATE_VERSION) {
|
||||
return { completed: [], failed: [], lastRun: '' };
|
||||
}
|
||||
return {
|
||||
completed: parsed.completed ?? [],
|
||||
failed: parsed.failed ?? [],
|
||||
lastRun: parsed.lastRun ?? ''
|
||||
};
|
||||
} catch {
|
||||
return { completed: [], failed: [], lastRun: '' };
|
||||
}
|
||||
}
|
||||
|
||||
function saveMigrationState(state: MigrationState): void {
|
||||
localStorage.setItem(
|
||||
MIGRATION_STATE_KEY,
|
||||
JSON.stringify({
|
||||
version: MIGRATION_STATE_VERSION,
|
||||
...state,
|
||||
lastRun: new Date().toISOString()
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function isMigrationCompleted(id: string): boolean {
|
||||
const state = getMigrationState();
|
||||
return state.completed.includes(id);
|
||||
}
|
||||
|
||||
function markMigrationCompleted(id: string): void {
|
||||
const state = getMigrationState();
|
||||
if (!state.completed.includes(id)) {
|
||||
state.completed.push(id);
|
||||
}
|
||||
state.failed = state.failed.filter((f) => f !== id);
|
||||
saveMigrationState(state);
|
||||
}
|
||||
|
||||
function markMigrationFailed(id: string): void {
|
||||
const state = getMigrationState();
|
||||
if (!state.failed.includes(id)) {
|
||||
state.failed.push(id);
|
||||
}
|
||||
saveMigrationState(state);
|
||||
}
|
||||
|
||||
// Migration 1: LocalStorage Key Prefix (Non-Destructive)
|
||||
|
||||
const LOCALSTORAGE_MIGRATION_ID = 'localstorage-prefix-v1';
|
||||
|
||||
const localStorageMigration: Migration = {
|
||||
id: LOCALSTORAGE_MIGRATION_ID,
|
||||
description: 'Copy localStorage keys from LlamaCppWebui to LlamaUi prefix (non-destructive)',
|
||||
|
||||
async run(): Promise<void> {
|
||||
// Non-destructive: copy to new key, but KEEP the old key
|
||||
for (const [newKey, deprecatedKey] of Object.entries(NEW_TO_DEPRECATED_MAP)) {
|
||||
// Only migrate if new key doesn't already exist
|
||||
const newValue = localStorage.getItem(newKey);
|
||||
if (newValue !== null) {
|
||||
console.log(`[Migration] localStorage: ${newKey} already exists, skipping`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const oldValue = localStorage.getItem(deprecatedKey);
|
||||
if (oldValue !== null) {
|
||||
localStorage.setItem(newKey, oldValue);
|
||||
// Keep old key for downgrade compatibility - DO NOT DELETE
|
||||
console.log(
|
||||
`[Migration] localStorage: copied ${deprecatedKey} → ${newKey} (preserved old)`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Migration 2: IndexedDB Database Name (Non-Destructive)
|
||||
|
||||
const IDXDB_MIGRATION_ID = 'idxdb-database-v1';
|
||||
|
||||
const idxdbMigration: Migration = {
|
||||
id: IDXDB_MIGRATION_ID,
|
||||
description: 'Copy IndexedDB from LlamacppWebui to LlamaUi database (non-destructive)',
|
||||
|
||||
async run(): Promise<void> {
|
||||
const oldDbNames = await Dexie.getDatabaseNames();
|
||||
if (!oldDbNames.includes(DB_APP_NAME_DEPRECATED)) {
|
||||
console.log('[Migration] IndexedDB: no old database found, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if new database already has data
|
||||
const newDb = new Dexie(STORAGE_APP_NAME);
|
||||
newDb.version(1).stores(IDXDB_STORES);
|
||||
const existingConvs = await newDb.table(IDXDB_TABLES.conversations).count();
|
||||
if (existingConvs > 0) {
|
||||
console.log('[Migration] IndexedDB: new database already has data, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[Migration] IndexedDB: copying from', DB_APP_NAME_DEPRECATED);
|
||||
|
||||
const oldDb = new Dexie(DB_APP_NAME_DEPRECATED);
|
||||
oldDb.version(1).stores(IDXDB_STORES);
|
||||
|
||||
const conversations = await oldDb.table(IDXDB_TABLES.conversations).toArray();
|
||||
const messages = await oldDb.table(IDXDB_TABLES.messages).toArray();
|
||||
|
||||
if (conversations.length > 0) {
|
||||
await newDb.table(IDXDB_TABLES.conversations).bulkAdd(conversations);
|
||||
console.log(`[Migration] IndexedDB: copied ${conversations.length} conversations`);
|
||||
}
|
||||
if (messages.length > 0) {
|
||||
await newDb.table(IDXDB_TABLES.messages).bulkAdd(messages);
|
||||
console.log(`[Migration] IndexedDB: copied ${messages.length} messages`);
|
||||
}
|
||||
|
||||
// Non-destructive: DO NOT delete old database - keep for downgrade compatibility
|
||||
console.log('[Migration] IndexedDB: preserved old database for downgrade compatibility');
|
||||
}
|
||||
};
|
||||
|
||||
// Migration 3: Legacy Message Format
|
||||
|
||||
const LEGACY_MESSAGE_MIGRATION_ID = 'legacy-message-format-v2';
|
||||
|
||||
interface ParsedTurn {
|
||||
textBefore: string;
|
||||
toolCalls: Array<{ name: string; args: string; result: string }>;
|
||||
}
|
||||
|
||||
function parseLegacyToolCalls(content: string): ParsedTurn[] {
|
||||
const turns: ParsedTurn[] = [];
|
||||
const regex = new RegExp(LEGACY_AGENTIC_REGEX.COMPLETED_TOOL_CALL.source, 'g');
|
||||
|
||||
let lastIndex = 0;
|
||||
let currentTurn: ParsedTurn = { textBefore: '', toolCalls: [] };
|
||||
let match;
|
||||
|
||||
while ((match = regex.exec(content)) !== null) {
|
||||
const textBefore = content.slice(lastIndex, match.index).trim();
|
||||
|
||||
if (textBefore && currentTurn.toolCalls.length > 0) {
|
||||
turns.push(currentTurn);
|
||||
currentTurn = { textBefore, toolCalls: [] };
|
||||
} else if (textBefore && currentTurn.toolCalls.length === 0) {
|
||||
currentTurn.textBefore = textBefore;
|
||||
}
|
||||
|
||||
currentTurn.toolCalls.push({
|
||||
name: match[1],
|
||||
args: match[2],
|
||||
result: match[3].replace(/^\n+|\n+$/g, '')
|
||||
});
|
||||
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
|
||||
const remainingText = content.slice(lastIndex).trim();
|
||||
|
||||
if (currentTurn.toolCalls.length > 0) {
|
||||
turns.push(currentTurn);
|
||||
}
|
||||
|
||||
if (remainingText) {
|
||||
const cleanRemaining = remainingText
|
||||
.replace(LEGACY_AGENTIC_REGEX.AGENTIC_TOOL_CALL_OPEN, '')
|
||||
.trim();
|
||||
if (cleanRemaining) {
|
||||
turns.push({ textBefore: cleanRemaining, toolCalls: [] });
|
||||
}
|
||||
}
|
||||
|
||||
if (turns.length === 0) {
|
||||
turns.push({ textBefore: content.trim(), toolCalls: [] });
|
||||
}
|
||||
|
||||
return turns;
|
||||
}
|
||||
|
||||
function extractLegacyReasoning(content: string): { reasoning: string; cleanContent: string } {
|
||||
let reasoning = '';
|
||||
let cleanContent = content;
|
||||
|
||||
const re = new RegExp(LEGACY_AGENTIC_REGEX.REASONING_EXTRACT.source, 'g');
|
||||
let match;
|
||||
while ((match = re.exec(content)) !== null) {
|
||||
reasoning += match[1];
|
||||
}
|
||||
|
||||
cleanContent = cleanContent
|
||||
.replace(new RegExp(LEGACY_AGENTIC_REGEX.REASONING_BLOCK.source, 'g'), '')
|
||||
.replace(LEGACY_AGENTIC_REGEX.REASONING_OPEN, '');
|
||||
|
||||
return { reasoning, cleanContent };
|
||||
}
|
||||
|
||||
function hasLegacyMarkers(content: string): boolean {
|
||||
return LEGACY_AGENTIC_REGEX.HAS_LEGACY_MARKERS.test(content);
|
||||
}
|
||||
|
||||
let DatabaseService: typeof import('./database.service').DatabaseService | null = null;
|
||||
|
||||
async function getDatabaseService() {
|
||||
if (!DatabaseService) {
|
||||
const module = await import('./database.service');
|
||||
DatabaseService = module.DatabaseService;
|
||||
}
|
||||
return DatabaseService;
|
||||
}
|
||||
|
||||
const legacyMessageMigration: Migration = {
|
||||
id: LEGACY_MESSAGE_MIGRATION_ID,
|
||||
description: 'Migrate legacy marker-based messages to structured format',
|
||||
|
||||
async run(): Promise<void> {
|
||||
const db = await getDatabaseService();
|
||||
const conversations = await db.getAllConversations();
|
||||
let migratedCount = 0;
|
||||
|
||||
for (const conv of conversations) {
|
||||
const allMessages = await db.getConversationMessages(conv.id);
|
||||
|
||||
for (const message of allMessages) {
|
||||
if (message.role !== MessageRole.ASSISTANT) {
|
||||
if (message.content?.includes(LEGACY_REASONING_TAGS.START)) {
|
||||
const { reasoning, cleanContent } = extractLegacyReasoning(message.content);
|
||||
await db.updateMessage(message.id, {
|
||||
content: cleanContent.trim(),
|
||||
reasoningContent: reasoning || undefined
|
||||
});
|
||||
migratedCount++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!hasLegacyMarkers(message.content ?? '')) continue;
|
||||
|
||||
const { reasoning, cleanContent } = extractLegacyReasoning(message.content);
|
||||
const turns = parseLegacyToolCalls(cleanContent);
|
||||
|
||||
let existingToolCalls: Array<{
|
||||
id: string;
|
||||
function?: { name: string; arguments: string };
|
||||
}> = [];
|
||||
if (message.toolCalls) {
|
||||
try {
|
||||
existingToolCalls = JSON.parse(message.toolCalls);
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
const firstTurn = turns[0];
|
||||
if (!firstTurn) continue;
|
||||
|
||||
const firstTurnToolCalls = firstTurn.toolCalls.map((tc, i) => {
|
||||
const existing =
|
||||
existingToolCalls.find((e) => e.function?.name === tc.name) || existingToolCalls[i];
|
||||
return {
|
||||
id: existing?.id || `legacy_tool_${i}`,
|
||||
type: 'function' as const,
|
||||
function: { name: tc.name, arguments: tc.args }
|
||||
};
|
||||
});
|
||||
|
||||
await db.updateMessage(message.id, {
|
||||
content: firstTurn.textBefore,
|
||||
reasoningContent: reasoning || undefined,
|
||||
toolCalls: firstTurnToolCalls.length > 0 ? JSON.stringify(firstTurnToolCalls) : ''
|
||||
});
|
||||
|
||||
let currentParentId = message.id;
|
||||
let toolCallIdCounter = existingToolCalls.length;
|
||||
|
||||
for (let i = 0; i < firstTurn.toolCalls.length; i++) {
|
||||
const tc = firstTurn.toolCalls[i];
|
||||
const toolCallId = firstTurnToolCalls[i]?.id || `legacy_tool_${i}`;
|
||||
|
||||
const toolMsg = await db.createMessageBranch(
|
||||
{
|
||||
convId: conv.id,
|
||||
type: 'text',
|
||||
role: MessageRole.TOOL,
|
||||
content: tc.result,
|
||||
toolCallId,
|
||||
timestamp: message.timestamp + i + 1,
|
||||
toolCalls: '',
|
||||
children: []
|
||||
},
|
||||
currentParentId
|
||||
);
|
||||
currentParentId = toolMsg.id;
|
||||
}
|
||||
|
||||
for (let turnIdx = 1; turnIdx < turns.length; turnIdx++) {
|
||||
const turn = turns[turnIdx];
|
||||
|
||||
const turnToolCalls = turn.toolCalls.map((tc, i) => {
|
||||
const idx = toolCallIdCounter + i;
|
||||
const existing = existingToolCalls[idx];
|
||||
return {
|
||||
id: existing?.id || `legacy_tool_${idx}`,
|
||||
type: 'function' as const,
|
||||
function: { name: tc.name, arguments: tc.args }
|
||||
};
|
||||
});
|
||||
toolCallIdCounter += turn.toolCalls.length;
|
||||
|
||||
const assistantMsg = await db.createMessageBranch(
|
||||
{
|
||||
convId: conv.id,
|
||||
type: 'text',
|
||||
role: MessageRole.ASSISTANT,
|
||||
content: turn.textBefore,
|
||||
timestamp: message.timestamp + turnIdx * 100,
|
||||
toolCalls: turnToolCalls.length > 0 ? JSON.stringify(turnToolCalls) : '',
|
||||
children: [],
|
||||
model: message.model
|
||||
},
|
||||
currentParentId
|
||||
);
|
||||
currentParentId = assistantMsg.id;
|
||||
|
||||
for (let i = 0; i < turn.toolCalls.length; i++) {
|
||||
const tc = turn.toolCalls[i];
|
||||
const toolCallId = turnToolCalls[i]?.id || `legacy_tool_${toolCallIdCounter + i}`;
|
||||
|
||||
const toolMsg = await db.createMessageBranch(
|
||||
{
|
||||
convId: conv.id,
|
||||
type: 'text',
|
||||
role: MessageRole.TOOL,
|
||||
content: tc.result,
|
||||
toolCallId,
|
||||
timestamp: message.timestamp + turnIdx * 100 + i + 1,
|
||||
toolCalls: '',
|
||||
children: []
|
||||
},
|
||||
currentParentId
|
||||
);
|
||||
currentParentId = toolMsg.id;
|
||||
}
|
||||
}
|
||||
|
||||
if (message.children.length > 0 && currentParentId !== message.id) {
|
||||
for (const childId of message.children) {
|
||||
const child = allMessages.find((m) => m.id === childId);
|
||||
if (!child) continue;
|
||||
if (child.role !== MessageRole.TOOL) {
|
||||
await db.updateMessage(childId, { parent: currentParentId });
|
||||
}
|
||||
}
|
||||
await db.updateMessage(message.id, { children: [] });
|
||||
}
|
||||
|
||||
migratedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[Migration] Legacy messages: migrated ${migratedCount} messages`);
|
||||
}
|
||||
};
|
||||
|
||||
// Migration 4: Theme Key (Non-Destructive)
|
||||
|
||||
const THEME_MIGRATION_ID = 'theme-key-v1';
|
||||
|
||||
const themeMigration: Migration = {
|
||||
id: THEME_MIGRATION_ID,
|
||||
description: 'Copy standalone theme key to config object (non-destructive)',
|
||||
|
||||
async run(): Promise<void> {
|
||||
const legacyTheme = localStorage.getItem('theme');
|
||||
if (legacyTheme === null) {
|
||||
console.log('[Migration] Theme: no legacy theme key found, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if config already has theme
|
||||
const configRaw = localStorage.getItem(CONFIG_LOCALSTORAGE_KEY);
|
||||
const config = configRaw ? JSON.parse(configRaw) : {};
|
||||
|
||||
if (SETTINGS_KEYS.THEME in config) {
|
||||
console.log('[Migration] Theme: config already has theme, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
config[SETTINGS_KEYS.THEME] = legacyTheme;
|
||||
localStorage.setItem(CONFIG_LOCALSTORAGE_KEY, JSON.stringify(config));
|
||||
|
||||
// Non-destructive: DO NOT delete legacy theme key - keep for downgrade compatibility
|
||||
console.log(`[Migration] Theme: copied standalone theme to config (preserved old key)`);
|
||||
}
|
||||
};
|
||||
|
||||
// Migration Registry & Runner
|
||||
|
||||
const migrations: Migration[] = [
|
||||
localStorageMigration,
|
||||
idxdbMigration,
|
||||
legacyMessageMigration,
|
||||
themeMigration
|
||||
];
|
||||
|
||||
export const MigrationService = {
|
||||
/**
|
||||
* Get all registered migrations
|
||||
*/
|
||||
getMigrations(): Migration[] {
|
||||
return [...migrations];
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if a specific migration has been completed
|
||||
*/
|
||||
isCompleted(id: string): boolean {
|
||||
return isMigrationCompleted(id);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get current migration state
|
||||
*/
|
||||
getState(): MigrationState {
|
||||
return getMigrationState();
|
||||
},
|
||||
|
||||
/**
|
||||
* Reset migration state (use with caution - migrations will run again)
|
||||
*/
|
||||
resetState(): void {
|
||||
localStorage.removeItem(MIGRATION_STATE_KEY);
|
||||
console.log('[Migration] State reset - all migrations will run again');
|
||||
},
|
||||
|
||||
/**
|
||||
* Run all pending migrations (non-destructive - preserves legacy data)
|
||||
* Should be called once at app initialization
|
||||
*/
|
||||
async runAllMigrations(): Promise<void> {
|
||||
const state = getMigrationState();
|
||||
console.log('[Migration] Starting migration run, state:', state);
|
||||
|
||||
for (const migration of migrations) {
|
||||
if (isMigrationCompleted(migration.id)) {
|
||||
console.log(`[Migration] ${migration.id}: already completed, skipping`);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`[Migration] ${migration.id}: running...`);
|
||||
await migration.run();
|
||||
markMigrationCompleted(migration.id);
|
||||
console.log(`[Migration] ${migration.id}: completed successfully`);
|
||||
} catch (error) {
|
||||
console.error(`[Migration] ${migration.id}: failed`, error);
|
||||
markMigrationFailed(migration.id);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[Migration] All migrations complete');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,228 @@
|
||||
import { ServerModelStatus } from '$lib/enums';
|
||||
import { apiFetch, apiPost } from '$lib/utils';
|
||||
import type { ParsedModelId } from '$lib/types/models';
|
||||
import {
|
||||
MODEL_QUANTIZATION_SEGMENT_RE,
|
||||
MODEL_CUSTOM_QUANTIZATION_PREFIX_RE,
|
||||
MODEL_PARAMS_RE,
|
||||
MODEL_ACTIVATED_PARAMS_RE,
|
||||
MODEL_IGNORED_SEGMENTS,
|
||||
MODEL_ID_NOT_FOUND,
|
||||
MODEL_ID_ORG_SEPARATOR,
|
||||
MODEL_ID_SEGMENT_SEPARATOR,
|
||||
MODEL_ID_QUANTIZATION_SEPARATOR,
|
||||
API_MODELS
|
||||
} from '$lib/constants';
|
||||
|
||||
export class ModelsService {
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Listing
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Fetch list of models from OpenAI-compatible endpoint.
|
||||
* Works in both MODEL and ROUTER modes.
|
||||
*
|
||||
* @returns List of available models with basic metadata
|
||||
*/
|
||||
static async list(): Promise<ApiModelListResponse> {
|
||||
return apiFetch<ApiModelListResponse>(API_MODELS.LIST);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch list of all models with detailed metadata (ROUTER mode).
|
||||
* Returns models with load status, paths, and other metadata
|
||||
* beyond what the OpenAI-compatible endpoint provides.
|
||||
*
|
||||
* @returns List of models with detailed status and configuration info
|
||||
*/
|
||||
static async listRouter(): Promise<ApiRouterModelsListResponse> {
|
||||
return apiFetch<ApiRouterModelsListResponse>(API_MODELS.LIST);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Load/Unload
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Load a model (ROUTER mode only).
|
||||
* Sends POST request to `/models/load`. Note: the endpoint returns success
|
||||
* before loading completes — use polling to await actual load status.
|
||||
*
|
||||
* @param modelId - Model identifier to load
|
||||
* @param extraArgs - Optional additional arguments to pass to the model instance
|
||||
* @returns Load response from the server
|
||||
*/
|
||||
static async load(modelId: string, extraArgs?: string[]): Promise<ApiRouterModelsLoadResponse> {
|
||||
const payload: { model: string; extra_args?: string[] } = { model: modelId };
|
||||
if (extraArgs && extraArgs.length > 0) {
|
||||
payload.extra_args = extraArgs;
|
||||
}
|
||||
|
||||
return apiPost<ApiRouterModelsLoadResponse>(API_MODELS.LOAD, payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unload a model (ROUTER mode only).
|
||||
* Sends POST request to `/models/unload`. Note: the endpoint returns success
|
||||
* before unloading completes — use polling to await actual unload status.
|
||||
*
|
||||
* @param modelId - Model identifier to unload
|
||||
* @returns Unload response from the server
|
||||
*/
|
||||
static async unload(modelId: string): Promise<ApiRouterModelsUnloadResponse> {
|
||||
return apiPost<ApiRouterModelsUnloadResponse>(API_MODELS.UNLOAD, { model: modelId });
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Status
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Check if a model is loaded based on its metadata.
|
||||
*
|
||||
* @param model - Model data entry from the API response
|
||||
* @returns True if the model status is LOADED
|
||||
*/
|
||||
static isModelLoaded(model: ApiModelDataEntry): boolean {
|
||||
return model.status.value === ServerModelStatus.LOADED;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a model is currently loading.
|
||||
*
|
||||
* @param model - Model data entry from the API response
|
||||
* @returns True if the model status is LOADING
|
||||
*/
|
||||
static isModelLoading(model: ApiModelDataEntry): boolean {
|
||||
return model.status.value === ServerModelStatus.LOADING;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Parsing
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Parse a model ID string into its structured components.
|
||||
*
|
||||
* Handles conventions like:
|
||||
* `<org>/<ModelName>-<Parameters>(-<ActivatedParameters>)(-<Tags>)(-<Quantization>):<Quantization>`
|
||||
* `<ModelName>.<Quantization>` (dot-separated quantization, e.g. `model.Q4_K_M`)
|
||||
*
|
||||
* @param modelId - Raw model identifier string
|
||||
* @returns Structured {@link ParsedModelId} with all detected fields
|
||||
*/
|
||||
static parseModelId(modelId: string): ParsedModelId {
|
||||
const result: ParsedModelId = {
|
||||
raw: modelId,
|
||||
orgName: null,
|
||||
modelName: null,
|
||||
params: null,
|
||||
activatedParams: null,
|
||||
quantization: null,
|
||||
tags: []
|
||||
};
|
||||
|
||||
// 1. Extract colon-separated quantization (e.g. `model:Q4_K_M`)
|
||||
const colonIdx = modelId.indexOf(MODEL_ID_QUANTIZATION_SEPARATOR);
|
||||
let modelPath: string;
|
||||
|
||||
if (colonIdx !== MODEL_ID_NOT_FOUND) {
|
||||
result.quantization = modelId.slice(colonIdx + 1) || null;
|
||||
modelPath = modelId.slice(0, colonIdx);
|
||||
} else {
|
||||
modelPath = modelId;
|
||||
}
|
||||
|
||||
// 2. Extract org name (e.g. `org/model` -> org = "org")
|
||||
const slashIdx = modelPath.indexOf(MODEL_ID_ORG_SEPARATOR);
|
||||
let modelStr: string;
|
||||
|
||||
if (slashIdx !== MODEL_ID_NOT_FOUND) {
|
||||
result.orgName = modelPath.slice(0, slashIdx);
|
||||
modelStr = modelPath.slice(slashIdx + 1);
|
||||
} else {
|
||||
modelStr = modelPath;
|
||||
}
|
||||
|
||||
// 3. Handle dot-separated quantization (e.g. `model-name.Q4_K_M`)
|
||||
const dotIdx = modelStr.lastIndexOf('.');
|
||||
|
||||
if (dotIdx !== MODEL_ID_NOT_FOUND && !result.quantization) {
|
||||
const afterDot = modelStr.slice(dotIdx + 1);
|
||||
|
||||
if (MODEL_QUANTIZATION_SEGMENT_RE.test(afterDot)) {
|
||||
result.quantization = afterDot;
|
||||
modelStr = modelStr.slice(0, dotIdx);
|
||||
}
|
||||
}
|
||||
|
||||
const segments = modelStr.split(MODEL_ID_SEGMENT_SEPARATOR);
|
||||
|
||||
// 4. Detect trailing quantization from dash-separated segments
|
||||
// Handle UD-prefixed quantization (e.g. `UD-Q8_K_XL`) and
|
||||
// standalone quantization (e.g. `Q4_K_M`, `BF16`, `F16`, `MXFP4`)
|
||||
if (!result.quantization && segments.length > 1) {
|
||||
const last = segments[segments.length - 1];
|
||||
const secondLast = segments.length > 2 ? segments[segments.length - 2] : null;
|
||||
|
||||
if (MODEL_QUANTIZATION_SEGMENT_RE.test(last)) {
|
||||
if (secondLast && MODEL_CUSTOM_QUANTIZATION_PREFIX_RE.test(secondLast)) {
|
||||
result.quantization = `${secondLast}-${last}`;
|
||||
segments.splice(segments.length - 2, 2);
|
||||
} else {
|
||||
result.quantization = last;
|
||||
segments.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Find params and activated params
|
||||
let paramsIdx = MODEL_ID_NOT_FOUND;
|
||||
let activatedParamsIdx = MODEL_ID_NOT_FOUND;
|
||||
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
const seg = segments[i];
|
||||
|
||||
if (paramsIdx === MODEL_ID_NOT_FOUND && MODEL_PARAMS_RE.test(seg)) {
|
||||
paramsIdx = i;
|
||||
result.params = seg.toUpperCase();
|
||||
} else if (paramsIdx !== MODEL_ID_NOT_FOUND && MODEL_ACTIVATED_PARAMS_RE.test(seg)) {
|
||||
activatedParamsIdx = i;
|
||||
result.activatedParams = seg.toUpperCase();
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Model name = segments before params; tags = remaining segments after params
|
||||
const pivotIdx = paramsIdx !== MODEL_ID_NOT_FOUND ? paramsIdx : segments.length;
|
||||
|
||||
result.modelName = segments.slice(0, pivotIdx).join(MODEL_ID_SEGMENT_SEPARATOR) || null;
|
||||
|
||||
if (paramsIdx !== MODEL_ID_NOT_FOUND) {
|
||||
result.tags = segments.slice(paramsIdx + 1).filter((_, relIdx) => {
|
||||
const absIdx = paramsIdx + 1 + relIdx;
|
||||
if (absIdx === activatedParamsIdx) return false;
|
||||
|
||||
return !MODEL_IGNORED_SEGMENTS.has(segments[absIdx].toUpperCase());
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { ParameterSyncService } from './parameter-sync.service';
|
||||
import { ColorMode } from '$lib/enums';
|
||||
|
||||
describe('ParameterSyncService', () => {
|
||||
describe('roundFloatingPoint', () => {
|
||||
it('should fix JavaScript floating-point precision issues', () => {
|
||||
// Test the specific values from the screenshot
|
||||
const mockServerParams = {
|
||||
top_p: 0.949999988079071,
|
||||
min_p: 0.009999999776482582,
|
||||
temperature: 0.800000011920929,
|
||||
top_k: 40,
|
||||
samplers: ['top_k', 'typ_p', 'top_p', 'min_p', 'temperature']
|
||||
};
|
||||
|
||||
const result = ParameterSyncService.extractServerDefaults({
|
||||
...mockServerParams,
|
||||
// Add other required fields to match the API type
|
||||
n_predict: 512,
|
||||
seed: -1,
|
||||
dynatemp_range: 0.0,
|
||||
dynatemp_exponent: 1.0,
|
||||
xtc_probability: 0.0,
|
||||
xtc_threshold: 0.1,
|
||||
typ_p: 1.0,
|
||||
repeat_last_n: 64,
|
||||
repeat_penalty: 1.0,
|
||||
presence_penalty: 0.0,
|
||||
frequency_penalty: 0.0,
|
||||
dry_multiplier: 0.0,
|
||||
dry_base: 1.75,
|
||||
dry_allowed_length: 2,
|
||||
dry_penalty_last_n: -1,
|
||||
mirostat: 0,
|
||||
mirostat_tau: 5.0,
|
||||
mirostat_eta: 0.1,
|
||||
stop: [],
|
||||
max_tokens: -1,
|
||||
n_keep: 0,
|
||||
n_discard: 0,
|
||||
ignore_eos: false,
|
||||
stream: true,
|
||||
logit_bias: [],
|
||||
n_probs: 0,
|
||||
min_keep: 0,
|
||||
grammar: '',
|
||||
grammar_lazy: false,
|
||||
grammar_triggers: [],
|
||||
preserved_tokens: [],
|
||||
chat_format: '',
|
||||
reasoning_format: '',
|
||||
reasoning_in_content: false,
|
||||
generation_prompt: '',
|
||||
'speculative.n_max': 0,
|
||||
'speculative.n_min': 0,
|
||||
'speculative.p_min': 0.0,
|
||||
timings_per_token: false,
|
||||
post_sampling_probs: false,
|
||||
lora: [],
|
||||
top_n_sigma: 0.0,
|
||||
dry_sequence_breakers: []
|
||||
} as ApiLlamaCppServerProps['default_generation_settings']['params']);
|
||||
|
||||
// Check that the problematic floating-point values are rounded correctly
|
||||
expect(result.top_p).toBe(0.95);
|
||||
expect(result.min_p).toBe(0.01);
|
||||
expect(result.temperature).toBe(0.8);
|
||||
expect(result.top_k).toBe(40); // Integer should remain unchanged
|
||||
expect(result.samplers).toBe('top_k;typ_p;top_p;min_p;temperature');
|
||||
});
|
||||
|
||||
it('should preserve non-numeric values', () => {
|
||||
const mockServerParams = {
|
||||
samplers: ['top_k', 'temperature'],
|
||||
max_tokens: -1,
|
||||
temperature: 0.7
|
||||
};
|
||||
|
||||
const result = ParameterSyncService.extractServerDefaults({
|
||||
...mockServerParams,
|
||||
// Minimal required fields
|
||||
n_predict: 512,
|
||||
seed: -1,
|
||||
dynatemp_range: 0.0,
|
||||
dynatemp_exponent: 1.0,
|
||||
top_k: 40,
|
||||
top_p: 0.95,
|
||||
min_p: 0.05,
|
||||
xtc_probability: 0.0,
|
||||
xtc_threshold: 0.1,
|
||||
typ_p: 1.0,
|
||||
repeat_last_n: 64,
|
||||
repeat_penalty: 1.0,
|
||||
presence_penalty: 0.0,
|
||||
frequency_penalty: 0.0,
|
||||
dry_multiplier: 0.0,
|
||||
dry_base: 1.75,
|
||||
dry_allowed_length: 2,
|
||||
dry_penalty_last_n: -1,
|
||||
mirostat: 0,
|
||||
mirostat_tau: 5.0,
|
||||
mirostat_eta: 0.1,
|
||||
stop: [],
|
||||
n_keep: 0,
|
||||
n_discard: 0,
|
||||
ignore_eos: false,
|
||||
stream: true,
|
||||
logit_bias: [],
|
||||
n_probs: 0,
|
||||
min_keep: 0,
|
||||
grammar: '',
|
||||
grammar_lazy: false,
|
||||
grammar_triggers: [],
|
||||
preserved_tokens: [],
|
||||
chat_format: '',
|
||||
reasoning_format: '',
|
||||
reasoning_in_content: false,
|
||||
generation_prompt: '',
|
||||
'speculative.n_max': 0,
|
||||
'speculative.n_min': 0,
|
||||
'speculative.p_min': 0.0,
|
||||
timings_per_token: false,
|
||||
post_sampling_probs: false,
|
||||
lora: [],
|
||||
top_n_sigma: 0.0,
|
||||
dry_sequence_breakers: []
|
||||
} as ApiLlamaCppServerProps['default_generation_settings']['params']);
|
||||
|
||||
expect(result.samplers).toBe('top_k;temperature');
|
||||
expect(result.max_tokens).toBe(-1);
|
||||
expect(result.temperature).toBe(0.7);
|
||||
});
|
||||
|
||||
it('should merge ui settings from props when provided', () => {
|
||||
const result = ParameterSyncService.extractServerDefaults(null, {
|
||||
pasteLongTextToFileLen: 0,
|
||||
pdfAsImage: true,
|
||||
renderUserContentAsMarkdown: false,
|
||||
theme: ColorMode.DARK
|
||||
});
|
||||
|
||||
expect(result.pasteLongTextToFileLen).toBe(0);
|
||||
expect(result.pdfAsImage).toBe(true);
|
||||
expect(result.renderUserContentAsMarkdown).toBe(false);
|
||||
expect(result.theme).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,232 @@
|
||||
import { normalizeFloatingPoint } from '$lib/utils';
|
||||
import { SETTINGS_KEYS, SYNCABLE_PARAMETERS } from '$lib/constants';
|
||||
import type { ParameterRecord, ParameterInfo, ParameterValue } from '$lib/types';
|
||||
import { SyncableParameterType, ParameterSource } from '$lib/enums';
|
||||
|
||||
export class ParameterSyncService {
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Extraction
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Round floating-point numbers to avoid JavaScript precision issues.
|
||||
* E.g., 0.1 + 0.2 = 0.30000000000000004 → 0.3
|
||||
*
|
||||
* @param value - Parameter value to normalize
|
||||
* @returns Precision-normalized value
|
||||
*/
|
||||
private static roundFloatingPoint(value: ParameterValue): ParameterValue {
|
||||
return normalizeFloatingPoint(value) as ParameterValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract server default parameters that can be synced from `/props` response.
|
||||
* Handles both generation settings parameters and UI-specific settings.
|
||||
* Converts samplers array to semicolon-delimited string for UI display.
|
||||
*
|
||||
* @param serverParams - Raw generation settings from server `/props` endpoint
|
||||
* @param uiSettings - Optional UI-specific settings from server
|
||||
* @returns Record of extracted parameter key-value pairs with normalized precision
|
||||
*/
|
||||
static extractServerDefaults(
|
||||
serverParams: ApiLlamaCppServerProps['default_generation_settings']['params'] | null,
|
||||
uiSettings?: Record<string, string | number | boolean>
|
||||
): ParameterRecord {
|
||||
const extracted: ParameterRecord = {};
|
||||
|
||||
if (serverParams) {
|
||||
for (const param of SYNCABLE_PARAMETERS) {
|
||||
if (param.canSync && param.serverKey in serverParams) {
|
||||
const value = (serverParams as unknown as Record<string, ParameterValue>)[
|
||||
param.serverKey
|
||||
];
|
||||
if (value !== undefined) {
|
||||
// Apply precision rounding to avoid JavaScript floating-point issues
|
||||
extracted[param.key] = this.roundFloatingPoint(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle samplers array conversion to string
|
||||
if (serverParams.samplers && Array.isArray(serverParams.samplers)) {
|
||||
extracted[SETTINGS_KEYS.SAMPLERS] = serverParams.samplers.join(';');
|
||||
}
|
||||
}
|
||||
|
||||
if (uiSettings) {
|
||||
for (const param of SYNCABLE_PARAMETERS) {
|
||||
if (param.canSync && param.serverKey in uiSettings) {
|
||||
const value = uiSettings[param.serverKey];
|
||||
|
||||
if (value !== undefined) {
|
||||
extracted[param.key] = this.roundFloatingPoint(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return extracted;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Merging
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Merge server defaults with current user settings.
|
||||
* User overrides always take priority — only parameters not in `userOverrides`
|
||||
* set will be updated from server defaults.
|
||||
*
|
||||
* @param currentSettings - Current parameter values in the settings store
|
||||
* @param serverDefaults - Default values extracted from server props
|
||||
* @param userOverrides - Set of parameter keys explicitly overridden by the user
|
||||
* @returns Merged parameter record with user overrides preserved
|
||||
*/
|
||||
static mergeWithServerDefaults(
|
||||
currentSettings: ParameterRecord,
|
||||
serverDefaults: ParameterRecord,
|
||||
userOverrides: Set<string> = new Set()
|
||||
): ParameterRecord {
|
||||
const merged = { ...currentSettings };
|
||||
|
||||
for (const [key, serverValue] of Object.entries(serverDefaults)) {
|
||||
// Only update if user hasn't explicitly overridden this parameter
|
||||
if (!userOverrides.has(key)) {
|
||||
merged[key] = this.roundFloatingPoint(serverValue);
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Info
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Get parameter information including source and values.
|
||||
* Used by SettingsChatParameterSourceIndicator to display the correct badge
|
||||
* (Custom vs Default) for each parameter in the settings UI.
|
||||
*
|
||||
* @param key - The parameter key to get info for
|
||||
* @param currentValue - The current value of the parameter
|
||||
* @param propsDefaults - Server default values from `/props`
|
||||
* @param userOverrides - Set of parameter keys explicitly overridden by the user
|
||||
* @returns Parameter info with source, server default, and user override values
|
||||
*/
|
||||
static getParameterInfo(
|
||||
key: string,
|
||||
currentValue: ParameterValue,
|
||||
propsDefaults: ParameterRecord,
|
||||
userOverrides: Set<string>
|
||||
): ParameterInfo {
|
||||
const hasPropsDefault = propsDefaults[key] !== undefined;
|
||||
const isUserOverride = userOverrides.has(key);
|
||||
|
||||
// Simple logic: either using default (from props) or custom (user override)
|
||||
const source = isUserOverride ? ParameterSource.CUSTOM : ParameterSource.DEFAULT;
|
||||
|
||||
return {
|
||||
value: currentValue,
|
||||
source,
|
||||
serverDefault: hasPropsDefault ? propsDefaults[key] : undefined, // Keep same field name for compatibility
|
||||
userOverride: isUserOverride ? currentValue : undefined
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a parameter can be synced from server.
|
||||
*
|
||||
* @param key - The parameter key to check
|
||||
* @returns True if the parameter is in the syncable parameters list
|
||||
*/
|
||||
static canSyncParameter(key: string): boolean {
|
||||
return SYNCABLE_PARAMETERS.some((param) => param.key === key && param.canSync);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all syncable parameter keys.
|
||||
*
|
||||
* @returns Array of parameter keys that can be synced from server
|
||||
*/
|
||||
static getSyncableParameterKeys(): string[] {
|
||||
return SYNCABLE_PARAMETERS.filter((param) => param.canSync).map((param) => param.key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a server parameter value against its expected type.
|
||||
*
|
||||
* @param key - The parameter key to validate
|
||||
* @param value - The value to validate
|
||||
* @returns True if value matches the expected type for this parameter
|
||||
*/
|
||||
static validateServerParameter(key: string, value: ParameterValue): boolean {
|
||||
const param = SYNCABLE_PARAMETERS.find((p) => p.key === key);
|
||||
if (!param) return false;
|
||||
|
||||
switch (param.type) {
|
||||
case SyncableParameterType.NUMBER:
|
||||
return typeof value === 'number' && !isNaN(value);
|
||||
case SyncableParameterType.STRING:
|
||||
return typeof value === 'string';
|
||||
case SyncableParameterType.BOOLEAN:
|
||||
return typeof value === 'boolean';
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Diff
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Create a diff between current settings and server defaults.
|
||||
* Shows which parameters differ from server values, useful for debugging
|
||||
* and for the "Reset to defaults" functionality.
|
||||
*
|
||||
* @param currentSettings - Current parameter values in the settings store
|
||||
* @param serverDefaults - Default values extracted from server props
|
||||
* @returns Record of parameter diffs with current value, server value, and whether they differ
|
||||
*/
|
||||
static createParameterDiff(
|
||||
currentSettings: ParameterRecord,
|
||||
serverDefaults: ParameterRecord
|
||||
): Record<string, { current: ParameterValue; server: ParameterValue; differs: boolean }> {
|
||||
const diff: Record<
|
||||
string,
|
||||
{ current: ParameterValue; server: ParameterValue; differs: boolean }
|
||||
> = {};
|
||||
|
||||
for (const key of this.getSyncableParameterKeys()) {
|
||||
const currentValue = currentSettings[key];
|
||||
const serverValue = serverDefaults[key];
|
||||
|
||||
if (serverValue !== undefined) {
|
||||
diff[key] = {
|
||||
current: currentValue,
|
||||
server: serverValue,
|
||||
differs: currentValue !== serverValue
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return diff;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { apiFetchWithParams } from '$lib/utils';
|
||||
|
||||
export class PropsService {
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Fetching
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Fetches global server properties from the `/props` endpoint.
|
||||
* In MODEL mode, returns modalities for the single loaded model.
|
||||
* In ROUTER mode, returns server-wide settings without model-specific modalities.
|
||||
*
|
||||
* @param autoload - If false, prevents automatic model loading (default: false)
|
||||
* @returns Server properties including default generation settings and capabilities
|
||||
* @throws {Error} If the request fails or returns invalid data
|
||||
*/
|
||||
static async fetch(autoload = false): Promise<ApiLlamaCppServerProps> {
|
||||
const params: Record<string, string> = {};
|
||||
if (!autoload) {
|
||||
params.autoload = 'false';
|
||||
}
|
||||
|
||||
return apiFetchWithParams<ApiLlamaCppServerProps>('./props', params, { authOnly: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches server properties for a specific model (ROUTER mode only).
|
||||
* Required in ROUTER mode because global `/props` does not include per-model modalities.
|
||||
*
|
||||
* @param modelId - The model ID to fetch properties for
|
||||
* @param autoload - If false, prevents automatic model loading (default: false)
|
||||
* @returns Server properties specific to the requested model
|
||||
* @throws {Error} If the request fails, model not found, or model not loaded
|
||||
*/
|
||||
static async fetchForModel(modelId: string, autoload = false): Promise<ApiLlamaCppServerProps> {
|
||||
const params: Record<string, string> = { model: modelId };
|
||||
if (!autoload) {
|
||||
params.autoload = 'false';
|
||||
}
|
||||
|
||||
return apiFetchWithParams<ApiLlamaCppServerProps>('./props', params, { authOnly: true });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { ROUTES } from '$lib/constants/routes';
|
||||
|
||||
export class RouterService {
|
||||
static chat(id: string): string {
|
||||
return `${ROUTES.CHAT}/${id}`;
|
||||
}
|
||||
|
||||
static settings(section: string): string {
|
||||
return `${ROUTES.SETTINGS}/${section}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { apiFetch } from '$lib/utils';
|
||||
import { API_TOOLS } from '$lib/constants';
|
||||
import { ToolResponseField } from '$lib/enums';
|
||||
import type { ToolExecutionResult, ServerBuiltinToolInfo } from '$lib/types';
|
||||
|
||||
export class ToolsService {
|
||||
/**
|
||||
* Fetch the list of built-in tools from the server.
|
||||
*
|
||||
* @returns Array of tool definitions in OpenAI-compatible format
|
||||
*/
|
||||
static async list(): Promise<ServerBuiltinToolInfo[]> {
|
||||
return apiFetch<ServerBuiltinToolInfo[]>(API_TOOLS.LIST);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a built-in tool on the server.
|
||||
*/
|
||||
static async executeTool(
|
||||
toolName: string,
|
||||
params: Record<string, unknown>,
|
||||
signal?: AbortSignal
|
||||
): Promise<ToolExecutionResult> {
|
||||
const result = await apiFetch<Record<string, unknown>>(API_TOOLS.EXECUTE, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ tool: toolName, params }),
|
||||
signal
|
||||
});
|
||||
|
||||
if (ToolResponseField.ERROR in result) {
|
||||
return { content: String(result[ToolResponseField.ERROR]), isError: true };
|
||||
}
|
||||
|
||||
if (ToolResponseField.PLAIN_TEXT in result) {
|
||||
return { content: String(result[ToolResponseField.PLAIN_TEXT]), isError: false };
|
||||
}
|
||||
|
||||
return { content: JSON.stringify(result), isError: false };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user