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 7ade6bc61f333..73d62b5304f66 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 @@ -4,6 +4,7 @@ import { isIMEComposing } from '$lib/utils/is-ime-composing'; import ChatMessageAssistant from './ChatMessageAssistant.svelte'; import ChatMessageUser from './ChatMessageUser.svelte'; + import ChatMessageSystem from './ChatMessageSystem.svelte'; interface Props { class?: string; @@ -110,7 +111,7 @@ } function handleSaveEdit() { - if (message.role === 'user') { + if (message.role === 'user' || message.role === 'system') { onEditWithBranching?.(message, editedContent.trim()); } else { onEditWithReplacement?.(message, editedContent.trim(), shouldBranchAfterEdit); @@ -125,7 +126,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 cc2631b830c3e..a298fc5fd701c 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 @@ -122,7 +122,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 45efeeb436fef..5497943ea5fbb 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 @@ -9,6 +9,7 @@ editAssistantMessage, regenerateMessageWithBranching } from '$lib/stores/chat.svelte'; + import { config } from '$lib/stores/settings.svelte'; import { getMessageSiblings } from '$lib/utils/branching'; interface Props { @@ -20,6 +21,7 @@ let { class: className, messages = [], onUserAction }: Props = $props(); let allConversationMessages = $state([]); + const currentConfig = config(); function refreshAllMessages() { const conversation = activeConversation(); @@ -47,7 +49,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/ChatSettingsDialog.svelte b/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsDialog.svelte index e4672b787ee89..07522959d5194 100644 --- a/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsDialog.svelte +++ b/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsDialog.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: 'showMessageStats', label: 'Show message generation statistics', 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 d17f7e4229af6..2c0cc17ccfdd1 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 @@ -93,7 +93,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'} @@ -106,13 +106,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 a695f99747494..bd29cb0d30e4c 100644 --- a/tools/server/webui/src/lib/components/app/index.ts +++ b/tools/server/webui/src/lib/components/app/index.ts @@ -15,6 +15,7 @@ export { default as ChatFormFileInputInvisible } from './chat/ChatForm/ChatFormF export { default as ChatMessage } from './chat/ChatMessages/ChatMessage.svelte'; export { default as ChatMessages } from './chat/ChatMessages/ChatMessages.svelte'; +export { default as ChatMessageSystem } from './chat/ChatMessages/ChatMessageSystem.svelte'; export { default as ChatMessageThinkingBlock } from './chat/ChatMessages/ChatMessageThinkingBlock.svelte'; export { default as MessageBranchingControls } from './chat/ChatMessages/ChatMessageBranchingControls.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 7e83d30f13216..671d162332ad5 100644 --- a/tools/server/webui/src/lib/components/app/misc/MarkdownContent.svelte +++ b/tools/server/webui/src/lib/components/app/misc/MarkdownContent.svelte @@ -336,19 +336,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 c25f380846cf4..6b7d1618ed7b3 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', showTokensPerSecond: false, showThoughtInProgress: false, @@ -41,8 +42,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 414e060764d7e..4cca0543e3efd 100644 --- a/tools/server/webui/src/lib/services/chat.ts +++ b/tools/server/webui/src/lib/services/chat.ts @@ -103,6 +103,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 +113,8 @@ export class ChatService { return true; }); - const processedMessages = this.injectSystemMessage(normalizedMessages); - const requestBody: ApiChatCompletionRequest = { - messages: processedMessages.map((msg: ApiChatMessageData) => ({ + messages: normalizedMessages.map((msg: ApiChatMessageData) => ({ role: msg.role, content: msg.content })), diff --git a/tools/server/webui/src/lib/stores/chat.svelte.ts b/tools/server/webui/src/lib/stores/chat.svelte.ts index 3f97a89183d82..e6ad01e7318a8 100644 --- a/tools/server/webui/src/lib/stores/chat.svelte.ts +++ b/tools/server/webui/src/lib/stores/chat.svelte.ts @@ -98,6 +98,25 @@ class ChatStore { this.activeConversation = conversation; this.activeMessages = []; + // Create root message + const rootId = await DatabaseStore.createRootMessage(conversation.id); + + // Create system message if system prompt is configured in settings + // This uses the systemMessage from localStorage as a template for new conversations + // Once created, the conversation uses its own system message from IndexedDB + const currentConfig = config(); + const systemPrompt = currentConfig.systemMessage?.toString().trim(); + + if (systemPrompt) { + const systemMessage = await DatabaseStore.createSystemMessage( + conversation.id, + systemPrompt, + rootId + ); + + this.activeMessages.push(systemMessage); + } + slotsService.setActiveConversation(conversation.id); const isConvLoading = this.isConversationLoading(conversation.id); diff --git a/tools/server/webui/src/lib/stores/database.ts b/tools/server/webui/src/lib/stores/database.ts index 6394c5b7eda74..9f49b45462e07 100644 --- a/tools/server/webui/src/lib/stores/database.ts +++ b/tools/server/webui/src/lib/stores/database.ts @@ -161,6 +161,51 @@ export class DatabaseStore { 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 { + // Validate that system prompt is not empty + 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); + + // Update parent's children array + 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/types/chat.d.ts b/tools/server/webui/src/lib/types/chat.d.ts index ee3990b04b976..a7d1efe13189b 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 {