diff --git a/tools/server/public/index.html.gz b/tools/server/public/index.html.gz index 4527cb3356..3892773668 100644 Binary files a/tools/server/public/index.html.gz and b/tools/server/public/index.html.gz differ diff --git a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessage.svelte b/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessage.svelte index 2315611361..96ed56a775 100644 --- a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessage.svelte +++ b/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessage.svelte @@ -3,6 +3,7 @@ import { copyToClipboard, isIMEComposing } from '$lib/utils'; import ChatMessageAssistant from './ChatMessageAssistant.svelte'; import ChatMessageUser from './ChatMessageUser.svelte'; + import ChatMessageSystem from './ChatMessageSystem.svelte'; interface Props { class?: string; @@ -140,8 +141,7 @@ } function handleSaveEdit() { - if (message.role === 'user') { - // For user messages, trim to avoid accidental whitespace + if (message.role === 'user' || message.role === 'system') { onEditWithBranching?.(message, editedContent.trim()); } else { // For assistant messages, preserve exact content including trailing whitespace @@ -167,7 +167,28 @@ } -{#if message.role === 'user'} +{#if message.role === 'system'} + +{:else if message.role === 'user'} + import { Check, X } from '@lucide/svelte'; + import { Card } from '$lib/components/ui/card'; + import { Button } from '$lib/components/ui/button'; + import { MarkdownContent } from '$lib/components/app'; + import { INPUT_CLASSES } from '$lib/constants/input-classes'; + import { config } from '$lib/stores/settings.svelte'; + import ChatMessageActions from './ChatMessageActions.svelte'; + + interface Props { + class?: string; + message: DatabaseMessage; + isEditing: boolean; + editedContent: string; + siblingInfo?: ChatMessageSiblingInfo | null; + showDeleteDialog: boolean; + deletionInfo: { + totalCount: number; + userMessages: number; + assistantMessages: number; + messageTypes: string[]; + } | null; + onCancelEdit: () => void; + onSaveEdit: () => void; + onEditKeydown: (event: KeyboardEvent) => void; + onEditedContentChange: (content: string) => void; + onCopy: () => void; + onEdit: () => void; + onDelete: () => void; + onConfirmDelete: () => void; + onNavigateToSibling?: (siblingId: string) => void; + onShowDeleteDialogChange: (show: boolean) => void; + textareaElement?: HTMLTextAreaElement; + } + + let { + class: className = '', + message, + isEditing, + editedContent, + siblingInfo = null, + showDeleteDialog, + deletionInfo, + onCancelEdit, + onSaveEdit, + onEditKeydown, + onEditedContentChange, + onCopy, + onEdit, + onDelete, + onConfirmDelete, + onNavigateToSibling, + onShowDeleteDialogChange, + textareaElement = $bindable() + }: Props = $props(); + + let isMultiline = $state(false); + let messageElement: HTMLElement | undefined = $state(); + let isExpanded = $state(false); + let contentHeight = $state(0); + const MAX_HEIGHT = 200; // pixels + const currentConfig = config(); + + let showExpandButton = $derived(contentHeight > MAX_HEIGHT); + + $effect(() => { + if (!messageElement || !message.content.trim()) return; + + if (message.content.includes('\n')) { + isMultiline = true; + } + + const resizeObserver = new ResizeObserver((entries) => { + for (const entry of entries) { + const element = entry.target as HTMLElement; + const estimatedSingleLineHeight = 24; + + isMultiline = element.offsetHeight > estimatedSingleLineHeight * 1.5; + contentHeight = element.scrollHeight; + } + }); + + resizeObserver.observe(messageElement); + + return () => { + resizeObserver.disconnect(); + }; + }); + + function toggleExpand() { + isExpanded = !isExpanded; + } + + +
+ {#if isEditing} +
+ + +
+ + + +
+
+ {:else} + {#if message.content.trim()} +
+ +
+ {/if} +
+ + {#if isExpanded && showExpandButton} +
+ +
+ {/if} + + + + {/if} + + {#if message.timestamp} +
+ +
+ {/if} + {/if} + diff --git a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageUser.svelte b/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageUser.svelte index 8556cbef5b..3d2b8dd35b 100644 --- a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageUser.svelte +++ b/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageUser.svelte @@ -145,7 +145,7 @@ {#if message.content.trim()} {#if currentConfig.renderUserContentAsMarkdown} diff --git a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessages.svelte b/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessages.svelte index f307f829bc..2e5f57cb61 100644 --- a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessages.svelte +++ b/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessages.svelte @@ -2,6 +2,7 @@ import { ChatMessage } from '$lib/components/app'; import { chatStore } from '$lib/stores/chat.svelte'; import { conversationsStore, activeConversation } from '$lib/stores/conversations.svelte'; + import { config } from '$lib/stores/settings.svelte'; import { getMessageSiblings } from '$lib/utils'; interface Props { @@ -13,6 +14,7 @@ let { class: className, messages = [], onUserAction }: Props = $props(); let allConversationMessages = $state([]); + const currentConfig = config(); function refreshAllMessages() { const conversation = activeConversation(); @@ -40,7 +42,12 @@ return []; } - return messages.map((message) => { + // Filter out system messages if showSystemMessage is false + const filteredMessages = currentConfig.showSystemMessage + ? messages + : messages.filter((msg) => msg.type !== 'system'); + + return filteredMessages.map((message) => { const siblingInfo = getMessageSiblings(allConversationMessages, message.id); return { diff --git a/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettings.svelte b/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettings.svelte index 67df20439c..45640e42a0 100644 --- a/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettings.svelte +++ b/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettings.svelte @@ -36,12 +36,6 @@ title: 'General', icon: Settings, fields: [ - { key: 'apiKey', label: 'API Key', type: 'input' }, - { - key: 'systemMessage', - label: 'System Message (will be disabled if left empty)', - type: 'textarea' - }, { key: 'theme', label: 'Theme', @@ -52,6 +46,12 @@ { value: 'dark', label: 'Dark', icon: Moon } ] }, + { key: 'apiKey', label: 'API Key', type: 'input' }, + { + key: 'systemMessage', + label: 'System Message', + type: 'textarea' + }, { key: 'pasteLongTextToFileLen', label: 'Paste long text to file length', diff --git a/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsFields.svelte b/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsFields.svelte index ef46e2d176..a6f51f47d6 100644 --- a/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsFields.svelte +++ b/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsFields.svelte @@ -95,7 +95,7 @@ {#if field.help || SETTING_CONFIG_INFO[field.key]}

- {field.help || SETTING_CONFIG_INFO[field.key]} + {@html field.help || SETTING_CONFIG_INFO[field.key]}

{/if} {:else if field.type === 'textarea'} @@ -112,13 +112,28 @@ value={String(localConfig[field.key] ?? '')} onchange={(e) => onConfigChange(field.key, e.currentTarget.value)} placeholder={`Default: ${SETTING_CONFIG_DEFAULT[field.key] ?? 'none'}`} - class="min-h-[100px] w-full md:max-w-2xl" + class="min-h-[10rem] w-full md:max-w-2xl" /> + {#if field.help || SETTING_CONFIG_INFO[field.key]}

{field.help || SETTING_CONFIG_INFO[field.key]}

{/if} + + {#if field.key === 'systemMessage'} +
+ onConfigChange('showSystemMessage', Boolean(checked))} + /> + + +
+ {/if} {:else if field.type === 'select'} {@const selectedOption = field.options?.find( (opt: { value: string; label: string; icon?: Component }) => diff --git a/tools/server/webui/src/lib/components/app/index.ts b/tools/server/webui/src/lib/components/app/index.ts index cf4d7495e2..87b24598b7 100644 --- a/tools/server/webui/src/lib/components/app/index.ts +++ b/tools/server/webui/src/lib/components/app/index.ts @@ -19,8 +19,10 @@ export { default as ChatMessage } from './chat/ChatMessages/ChatMessage.svelte'; export { default as ChatMessageActions } from './chat/ChatMessages/ChatMessageActions.svelte'; export { default as ChatMessageBranchingControls } from './chat/ChatMessages/ChatMessageBranchingControls.svelte'; export { default as ChatMessageStatistics } from './chat/ChatMessages/ChatMessageStatistics.svelte'; +export { default as ChatMessageSystem } from './chat/ChatMessages/ChatMessageSystem.svelte'; export { default as ChatMessageThinkingBlock } from './chat/ChatMessages/ChatMessageThinkingBlock.svelte'; export { default as ChatMessages } from './chat/ChatMessages/ChatMessages.svelte'; +export { default as MessageBranchingControls } from './chat/ChatMessages/ChatMessageBranchingControls.svelte'; export { default as ChatScreen } from './chat/ChatScreen/ChatScreen.svelte'; export { default as ChatScreenHeader } from './chat/ChatScreen/ChatScreenHeader.svelte'; diff --git a/tools/server/webui/src/lib/components/app/misc/MarkdownContent.svelte b/tools/server/webui/src/lib/components/app/misc/MarkdownContent.svelte index 99d6e21e13..9c37bde0d4 100644 --- a/tools/server/webui/src/lib/components/app/misc/MarkdownContent.svelte +++ b/tools/server/webui/src/lib/components/app/misc/MarkdownContent.svelte @@ -337,19 +337,23 @@ line-height: 1.75; } + div :global(:is(h1, h2, h3, h4, h5, h6):first-child) { + margin-top: 0; + } + /* Headers with consistent spacing */ div :global(h1) { font-size: 1.875rem; font-weight: 700; - margin: 1.5rem 0 0.75rem 0; line-height: 1.2; + margin: 1.5rem 0 0.75rem 0; } div :global(h2) { font-size: 1.5rem; font-weight: 600; - margin: 1.25rem 0 0.5rem 0; line-height: 1.3; + margin: 1.25rem 0 0.5rem 0; } div :global(h3) { diff --git a/tools/server/webui/src/lib/constants/settings-config.ts b/tools/server/webui/src/lib/constants/settings-config.ts index 1fc35b48c4..3764a2856b 100644 --- a/tools/server/webui/src/lib/constants/settings-config.ts +++ b/tools/server/webui/src/lib/constants/settings-config.ts @@ -3,6 +3,7 @@ export const SETTING_CONFIG_DEFAULT: Record = // Do not use nested objects, keep it single level. Prefix the key if you need to group them. apiKey: '', systemMessage: '', + showSystemMessage: true, theme: 'system', showThoughtInProgress: false, showToolCalls: false, @@ -42,8 +43,9 @@ export const SETTING_CONFIG_DEFAULT: Record = }; export const SETTING_CONFIG_INFO: Record = { - apiKey: 'Set the API Key if you are using --api-key option for the server.', + apiKey: 'Set the API Key if you are using --api-key option for the server.', systemMessage: 'The starting message that defines how model should behave.', + showSystemMessage: 'Display the system message at the top of each conversation.', theme: 'Choose the color theme for the interface. You can choose between System (follows your device settings), Light, or Dark.', pasteLongTextToFileLen: diff --git a/tools/server/webui/src/lib/services/chat.ts b/tools/server/webui/src/lib/services/chat.ts index a6a6812403..c03b764419 100644 --- a/tools/server/webui/src/lib/services/chat.ts +++ b/tools/server/webui/src/lib/services/chat.ts @@ -89,7 +89,6 @@ export class ChatService { custom, timings_per_token, // Config options - systemMessage, disableReasoningFormat } = options; @@ -103,6 +102,7 @@ export class ChatService { } }) .filter((msg) => { + // Filter out empty system messages if (msg.role === 'system') { const content = typeof msg.content === 'string' ? msg.content : ''; @@ -112,10 +112,8 @@ export class ChatService { return true; }); - const processedMessages = ChatService.injectSystemMessage(normalizedMessages, systemMessage); - const requestBody: ApiChatCompletionRequest = { - messages: processedMessages.map((msg: ApiChatMessageData) => ({ + messages: normalizedMessages.map((msg: ApiChatMessageData) => ({ role: msg.role, content: msg.content })), @@ -677,46 +675,6 @@ export class ChatService { // Utilities // ───────────────────────────────────────────────────────────────────────────── - /** - * Injects a system message at the beginning of the conversation if provided. - * Checks for existing system messages to avoid duplication. - * - * @param messages - Array of chat messages to process - * @param systemMessage - Optional system message to inject - * @returns Array of messages with system message injected at the beginning if provided - * @private - */ - private static injectSystemMessage( - messages: ApiChatMessageData[], - systemMessage?: string - ): ApiChatMessageData[] { - const trimmedSystemMessage = systemMessage?.trim(); - - if (!trimmedSystemMessage) { - return messages; - } - - if (messages.length > 0 && messages[0].role === 'system') { - if (messages[0].content !== trimmedSystemMessage) { - const updatedMessages = [...messages]; - updatedMessages[0] = { - role: 'system', - content: trimmedSystemMessage - }; - return updatedMessages; - } - - return messages; - } - - const systemMsg: ApiChatMessageData = { - role: 'system', - content: trimmedSystemMessage - }; - - return [systemMsg, ...messages]; - } - /** * Parses error response and creates appropriate error with context information * @param response - HTTP response object diff --git a/tools/server/webui/src/lib/services/database.ts b/tools/server/webui/src/lib/services/database.ts index 185a598c3b..3b24628cff 100644 --- a/tools/server/webui/src/lib/services/database.ts +++ b/tools/server/webui/src/lib/services/database.ts @@ -166,6 +166,49 @@ export class DatabaseService { 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 { + const trimmedPrompt = systemPrompt.trim(); + if (!trimmedPrompt) { + throw new Error('Cannot create system message with empty content'); + } + + const systemMessage: DatabaseMessage = { + id: uuid(), + convId, + type: 'system', + timestamp: Date.now(), + role: 'system', + content: trimmedPrompt, + parent: parentId, + thinking: '', + children: [] + }; + + await db.messages.add(systemMessage); + + const parentMessage = await db.messages.get(parentId); + if (parentMessage) { + await db.messages.update(parentId, { + children: [...parentMessage.children, systemMessage.id] + }); + } + + return systemMessage; + } + /** * Deletes a conversation and all its messages. * diff --git a/tools/server/webui/src/lib/stores/chat.svelte.ts b/tools/server/webui/src/lib/stores/chat.svelte.ts index f21e291163..dd6b77e71a 100644 --- a/tools/server/webui/src/lib/stores/chat.svelte.ts +++ b/tools/server/webui/src/lib/stores/chat.svelte.ts @@ -624,6 +624,22 @@ class ChatStore { this.clearChatStreaming(currentConv.id); try { + if (isNewConversation) { + const rootId = await DatabaseService.createRootMessage(currentConv.id); + const currentConfig = config(); + const systemPrompt = currentConfig.systemMessage?.toString().trim(); + + if (systemPrompt) { + const systemMessage = await DatabaseService.createSystemMessage( + currentConv.id, + systemPrompt, + rootId + ); + + conversationsStore.addMessageToActive(systemMessage); + } + } + const userMessage = await this.addMessage('user', content, 'text', '-1', extras); if (!userMessage) throw new Error('Failed to add user message'); if (isNewConversation && content) @@ -999,14 +1015,20 @@ class ChatStore { const activeConv = conversationsStore.activeConversation; if (!activeConv || this.isLoading) return; - const result = this.getMessageByIdWithRole(messageId, 'user'); + let result = this.getMessageByIdWithRole(messageId, 'user'); + + if (!result) { + result = this.getMessageByIdWithRole(messageId, 'system'); + } + if (!result) return; const { message: msg } = result; try { const allMessages = await conversationsStore.getConversationMessages(activeConv.id); const rootMessage = allMessages.find((m) => m.type === 'root' && m.parent === null); - const isFirstUserMessage = rootMessage && msg.parent === rootMessage.id; + const isFirstUserMessage = + msg.role === 'user' && rootMessage && msg.parent === rootMessage.id; const parentId = msg.parent || rootMessage?.id; if (!parentId) return; @@ -1037,7 +1059,10 @@ class ChatStore { ); } await conversationsStore.refreshActiveMessages(); - await this.generateResponseForMessage(newMessage.id); + + if (msg.role === 'user') { + await this.generateResponseForMessage(newMessage.id); + } } catch (error) { console.error('Failed to edit message with branching:', error); } diff --git a/tools/server/webui/src/lib/types/chat.d.ts b/tools/server/webui/src/lib/types/chat.d.ts index 0eafb80cbf..0e706b72b6 100644 --- a/tools/server/webui/src/lib/types/chat.d.ts +++ b/tools/server/webui/src/lib/types/chat.d.ts @@ -1,4 +1,4 @@ -export type ChatMessageType = 'root' | 'text' | 'think'; +export type ChatMessageType = 'root' | 'text' | 'think' | 'system'; export type ChatRole = 'user' | 'assistant' | 'system'; export interface ChatUploadedFile {