webui: Per-conversation system message with UI displaying, edition & branching (#17275)

* feat: Per-conversation system message with optional display in UI, edition and branching (WIP)

* chore: update webui build output
This commit is contained in:
Aleksander Grygier 2025-12-06 13:19:05 +01:00 committed by GitHub
parent 7b43f55753
commit 21f24f27a9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 357 additions and 64 deletions

Binary file not shown.

View File

@ -3,6 +3,7 @@
import { copyToClipboard, isIMEComposing } from '$lib/utils'; import { copyToClipboard, isIMEComposing } from '$lib/utils';
import ChatMessageAssistant from './ChatMessageAssistant.svelte'; import ChatMessageAssistant from './ChatMessageAssistant.svelte';
import ChatMessageUser from './ChatMessageUser.svelte'; import ChatMessageUser from './ChatMessageUser.svelte';
import ChatMessageSystem from './ChatMessageSystem.svelte';
interface Props { interface Props {
class?: string; class?: string;
@ -140,8 +141,7 @@
} }
function handleSaveEdit() { function handleSaveEdit() {
if (message.role === 'user') { if (message.role === 'user' || message.role === 'system') {
// For user messages, trim to avoid accidental whitespace
onEditWithBranching?.(message, editedContent.trim()); onEditWithBranching?.(message, editedContent.trim());
} else { } else {
// For assistant messages, preserve exact content including trailing whitespace // For assistant messages, preserve exact content including trailing whitespace
@ -167,7 +167,28 @@
} }
</script> </script>
{#if message.role === 'user'} {#if message.role === 'system'}
<ChatMessageSystem
bind:textareaElement
class={className}
{deletionInfo}
{editedContent}
{isEditing}
{message}
onCancelEdit={handleCancelEdit}
onConfirmDelete={handleConfirmDelete}
onCopy={handleCopy}
onDelete={handleDelete}
onEdit={handleEdit}
onEditKeydown={handleEditKeydown}
onEditedContentChange={handleEditedContentChange}
{onNavigateToSibling}
onSaveEdit={handleSaveEdit}
onShowDeleteDialogChange={handleShowDeleteDialogChange}
{showDeleteDialog}
{siblingInfo}
/>
{:else if message.role === 'user'}
<ChatMessageUser <ChatMessageUser
bind:textareaElement bind:textareaElement
class={className} class={className}

View File

@ -0,0 +1,216 @@
<script lang="ts">
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;
}
</script>
<div
aria-label="System message with actions"
class="group flex flex-col items-end gap-3 md:gap-2 {className}"
role="group"
>
{#if isEditing}
<div class="w-full max-w-[80%]">
<textarea
bind:this={textareaElement}
bind:value={editedContent}
class="min-h-[60px] w-full resize-none rounded-2xl px-3 py-2 text-sm {INPUT_CLASSES}"
onkeydown={onEditKeydown}
oninput={(e) => onEditedContentChange(e.currentTarget.value)}
placeholder="Edit system message..."
></textarea>
<div class="mt-2 flex justify-end gap-2">
<Button class="h-8 px-3" onclick={onCancelEdit} size="sm" variant="outline">
<X class="mr-1 h-3 w-3" />
Cancel
</Button>
<Button class="h-8 px-3" onclick={onSaveEdit} disabled={!editedContent.trim()} size="sm">
<Check class="mr-1 h-3 w-3" />
Send
</Button>
</div>
</div>
{:else}
{#if message.content.trim()}
<div class="relative max-w-[80%]">
<button
class="group/expand w-full text-left {!isExpanded && showExpandButton
? 'cursor-pointer'
: 'cursor-auto'}"
onclick={showExpandButton && !isExpanded ? toggleExpand : undefined}
type="button"
>
<Card
class="rounded-[1.125rem] !border-2 !border-dashed !border-border/50 bg-muted px-3.75 py-1.5 data-[multiline]:py-2.5"
data-multiline={isMultiline ? '' : undefined}
style="border: 2px dashed hsl(var(--border));"
>
<div
class="relative overflow-hidden transition-all duration-300 {isExpanded
? 'cursor-text select-text'
: 'select-none'}"
style={!isExpanded && showExpandButton
? `max-height: ${MAX_HEIGHT}px;`
: 'max-height: none;'}
>
{#if currentConfig.renderUserContentAsMarkdown}
<div bind:this={messageElement} class="text-md {isExpanded ? 'cursor-text' : ''}">
<MarkdownContent class="markdown-system-content" content={message.content} />
</div>
{:else}
<span
bind:this={messageElement}
class="text-md whitespace-pre-wrap {isExpanded ? 'cursor-text' : ''}"
>
{message.content}
</span>
{/if}
{#if !isExpanded && showExpandButton}
<div
class="pointer-events-none absolute right-0 bottom-0 left-0 h-48 bg-gradient-to-t from-muted to-transparent"
></div>
<div
class="pointer-events-none absolute right-0 bottom-4 left-0 flex justify-center opacity-0 transition-opacity group-hover/expand:opacity-100"
>
<Button
class="rounded-full px-4 py-1.5 text-xs shadow-md"
size="sm"
variant="outline"
>
Show full system message
</Button>
</div>
{/if}
</div>
{#if isExpanded && showExpandButton}
<div class="mb-2 flex justify-center">
<Button
class="rounded-full px-4 py-1.5 text-xs"
onclick={(e) => {
e.stopPropagation();
toggleExpand();
}}
size="sm"
variant="outline"
>
Collapse System Message
</Button>
</div>
{/if}
</Card>
</button>
</div>
{/if}
{#if message.timestamp}
<div class="max-w-[80%]">
<ChatMessageActions
actionsPosition="right"
{deletionInfo}
justify="end"
{onConfirmDelete}
{onCopy}
{onDelete}
{onEdit}
{onNavigateToSibling}
{onShowDeleteDialogChange}
{siblingInfo}
{showDeleteDialog}
role="user"
/>
</div>
{/if}
{/if}
</div>

View File

@ -145,7 +145,7 @@
{#if message.content.trim()} {#if message.content.trim()}
<Card <Card
class="max-w-[80%] rounded-[1.125rem] bg-primary px-3.75 py-1.5 text-primary-foreground data-[multiline]:py-2.5" class="max-w-[80%] rounded-[1.125rem] border-none bg-primary px-3.75 py-1.5 text-primary-foreground data-[multiline]:py-2.5"
data-multiline={isMultiline ? '' : undefined} data-multiline={isMultiline ? '' : undefined}
> >
{#if currentConfig.renderUserContentAsMarkdown} {#if currentConfig.renderUserContentAsMarkdown}

View File

@ -2,6 +2,7 @@
import { ChatMessage } from '$lib/components/app'; import { ChatMessage } from '$lib/components/app';
import { chatStore } from '$lib/stores/chat.svelte'; import { chatStore } from '$lib/stores/chat.svelte';
import { conversationsStore, activeConversation } from '$lib/stores/conversations.svelte'; import { conversationsStore, activeConversation } from '$lib/stores/conversations.svelte';
import { config } from '$lib/stores/settings.svelte';
import { getMessageSiblings } from '$lib/utils'; import { getMessageSiblings } from '$lib/utils';
interface Props { interface Props {
@ -13,6 +14,7 @@
let { class: className, messages = [], onUserAction }: Props = $props(); let { class: className, messages = [], onUserAction }: Props = $props();
let allConversationMessages = $state<DatabaseMessage[]>([]); let allConversationMessages = $state<DatabaseMessage[]>([]);
const currentConfig = config();
function refreshAllMessages() { function refreshAllMessages() {
const conversation = activeConversation(); const conversation = activeConversation();
@ -40,7 +42,12 @@
return []; 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); const siblingInfo = getMessageSiblings(allConversationMessages, message.id);
return { return {

View File

@ -36,12 +36,6 @@
title: 'General', title: 'General',
icon: Settings, icon: Settings,
fields: [ fields: [
{ key: 'apiKey', label: 'API Key', type: 'input' },
{
key: 'systemMessage',
label: 'System Message (will be disabled if left empty)',
type: 'textarea'
},
{ {
key: 'theme', key: 'theme',
label: 'Theme', label: 'Theme',
@ -52,6 +46,12 @@
{ value: 'dark', label: 'Dark', icon: Moon } { value: 'dark', label: 'Dark', icon: Moon }
] ]
}, },
{ key: 'apiKey', label: 'API Key', type: 'input' },
{
key: 'systemMessage',
label: 'System Message',
type: 'textarea'
},
{ {
key: 'pasteLongTextToFileLen', key: 'pasteLongTextToFileLen',
label: 'Paste long text to file length', label: 'Paste long text to file length',

View File

@ -95,7 +95,7 @@
</div> </div>
{#if field.help || SETTING_CONFIG_INFO[field.key]} {#if field.help || SETTING_CONFIG_INFO[field.key]}
<p class="mt-1 text-xs text-muted-foreground"> <p class="mt-1 text-xs text-muted-foreground">
{field.help || SETTING_CONFIG_INFO[field.key]} {@html field.help || SETTING_CONFIG_INFO[field.key]}
</p> </p>
{/if} {/if}
{:else if field.type === 'textarea'} {:else if field.type === 'textarea'}
@ -112,13 +112,28 @@
value={String(localConfig[field.key] ?? '')} value={String(localConfig[field.key] ?? '')}
onchange={(e) => onConfigChange(field.key, e.currentTarget.value)} onchange={(e) => onConfigChange(field.key, e.currentTarget.value)}
placeholder={`Default: ${SETTING_CONFIG_DEFAULT[field.key] ?? 'none'}`} 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]} {#if field.help || SETTING_CONFIG_INFO[field.key]}
<p class="mt-1 text-xs text-muted-foreground"> <p class="mt-1 text-xs text-muted-foreground">
{field.help || SETTING_CONFIG_INFO[field.key]} {field.help || SETTING_CONFIG_INFO[field.key]}
</p> </p>
{/if} {/if}
{#if field.key === 'systemMessage'}
<div class="mt-3 flex items-center gap-2">
<Checkbox
id="showSystemMessage"
checked={Boolean(localConfig.showSystemMessage ?? true)}
onCheckedChange={(checked) => onConfigChange('showSystemMessage', Boolean(checked))}
/>
<Label for="showSystemMessage" class="cursor-pointer text-sm font-normal">
Show system message in conversations
</Label>
</div>
{/if}
{:else if field.type === 'select'} {:else if field.type === 'select'}
{@const selectedOption = field.options?.find( {@const selectedOption = field.options?.find(
(opt: { value: string; label: string; icon?: Component }) => (opt: { value: string; label: string; icon?: Component }) =>

View File

@ -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 ChatMessageActions } from './chat/ChatMessages/ChatMessageActions.svelte';
export { default as ChatMessageBranchingControls } from './chat/ChatMessages/ChatMessageBranchingControls.svelte'; export { default as ChatMessageBranchingControls } from './chat/ChatMessages/ChatMessageBranchingControls.svelte';
export { default as ChatMessageStatistics } from './chat/ChatMessages/ChatMessageStatistics.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 ChatMessageThinkingBlock } from './chat/ChatMessages/ChatMessageThinkingBlock.svelte';
export { default as ChatMessages } from './chat/ChatMessages/ChatMessages.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 ChatScreen } from './chat/ChatScreen/ChatScreen.svelte';
export { default as ChatScreenHeader } from './chat/ChatScreen/ChatScreenHeader.svelte'; export { default as ChatScreenHeader } from './chat/ChatScreen/ChatScreenHeader.svelte';

View File

@ -337,19 +337,23 @@
line-height: 1.75; line-height: 1.75;
} }
div :global(:is(h1, h2, h3, h4, h5, h6):first-child) {
margin-top: 0;
}
/* Headers with consistent spacing */ /* Headers with consistent spacing */
div :global(h1) { div :global(h1) {
font-size: 1.875rem; font-size: 1.875rem;
font-weight: 700; font-weight: 700;
margin: 1.5rem 0 0.75rem 0;
line-height: 1.2; line-height: 1.2;
margin: 1.5rem 0 0.75rem 0;
} }
div :global(h2) { div :global(h2) {
font-size: 1.5rem; font-size: 1.5rem;
font-weight: 600; font-weight: 600;
margin: 1.25rem 0 0.5rem 0;
line-height: 1.3; line-height: 1.3;
margin: 1.25rem 0 0.5rem 0;
} }
div :global(h3) { div :global(h3) {

View File

@ -3,6 +3,7 @@ export const SETTING_CONFIG_DEFAULT: Record<string, string | number | boolean> =
// Do not use nested objects, keep it single level. Prefix the key if you need to group them. // Do not use nested objects, keep it single level. Prefix the key if you need to group them.
apiKey: '', apiKey: '',
systemMessage: '', systemMessage: '',
showSystemMessage: true,
theme: 'system', theme: 'system',
showThoughtInProgress: false, showThoughtInProgress: false,
showToolCalls: false, showToolCalls: false,
@ -42,8 +43,9 @@ export const SETTING_CONFIG_DEFAULT: Record<string, string | number | boolean> =
}; };
export const SETTING_CONFIG_INFO: Record<string, string> = { export const SETTING_CONFIG_INFO: Record<string, string> = {
apiKey: 'Set the API Key if you are using --api-key option for the server.', apiKey: 'Set the API Key if you are using <code>--api-key</code> option for the server.',
systemMessage: 'The starting message that defines how model should behave.', systemMessage: 'The starting message that defines how model should behave.',
showSystemMessage: 'Display the system message at the top of each conversation.',
theme: theme:
'Choose the color theme for the interface. You can choose between System (follows your device settings), Light, or Dark.', 'Choose the color theme for the interface. You can choose between System (follows your device settings), Light, or Dark.',
pasteLongTextToFileLen: pasteLongTextToFileLen:

View File

@ -89,7 +89,6 @@ export class ChatService {
custom, custom,
timings_per_token, timings_per_token,
// Config options // Config options
systemMessage,
disableReasoningFormat disableReasoningFormat
} = options; } = options;
@ -103,6 +102,7 @@ export class ChatService {
} }
}) })
.filter((msg) => { .filter((msg) => {
// Filter out empty system messages
if (msg.role === 'system') { if (msg.role === 'system') {
const content = typeof msg.content === 'string' ? msg.content : ''; const content = typeof msg.content === 'string' ? msg.content : '';
@ -112,10 +112,8 @@ export class ChatService {
return true; return true;
}); });
const processedMessages = ChatService.injectSystemMessage(normalizedMessages, systemMessage);
const requestBody: ApiChatCompletionRequest = { const requestBody: ApiChatCompletionRequest = {
messages: processedMessages.map((msg: ApiChatMessageData) => ({ messages: normalizedMessages.map((msg: ApiChatMessageData) => ({
role: msg.role, role: msg.role,
content: msg.content content: msg.content
})), })),
@ -677,46 +675,6 @@ export class ChatService {
// Utilities // 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 * Parses error response and creates appropriate error with context information
* @param response - HTTP response object * @param response - HTTP response object

View File

@ -166,6 +166,49 @@ export class DatabaseService {
return rootMessage.id; 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: '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. * Deletes a conversation and all its messages.
* *

View File

@ -624,6 +624,22 @@ class ChatStore {
this.clearChatStreaming(currentConv.id); this.clearChatStreaming(currentConv.id);
try { 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); const userMessage = await this.addMessage('user', content, 'text', '-1', extras);
if (!userMessage) throw new Error('Failed to add user message'); if (!userMessage) throw new Error('Failed to add user message');
if (isNewConversation && content) if (isNewConversation && content)
@ -999,14 +1015,20 @@ class ChatStore {
const activeConv = conversationsStore.activeConversation; const activeConv = conversationsStore.activeConversation;
if (!activeConv || this.isLoading) return; 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; if (!result) return;
const { message: msg } = result; const { message: msg } = result;
try { try {
const allMessages = await conversationsStore.getConversationMessages(activeConv.id); const allMessages = await conversationsStore.getConversationMessages(activeConv.id);
const rootMessage = allMessages.find((m) => m.type === 'root' && m.parent === null); 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; const parentId = msg.parent || rootMessage?.id;
if (!parentId) return; if (!parentId) return;
@ -1037,7 +1059,10 @@ class ChatStore {
); );
} }
await conversationsStore.refreshActiveMessages(); await conversationsStore.refreshActiveMessages();
await this.generateResponseForMessage(newMessage.id);
if (msg.role === 'user') {
await this.generateResponseForMessage(newMessage.id);
}
} catch (error) { } catch (error) {
console.error('Failed to edit message with branching:', error); console.error('Failed to edit message with branching:', error);
} }

View File

@ -1,4 +1,4 @@
export type ChatMessageType = 'root' | 'text' | 'think'; export type ChatMessageType = 'root' | 'text' | 'think' | 'system';
export type ChatRole = 'user' | 'assistant' | 'system'; export type ChatRole = 'user' | 'assistant' | 'system';
export interface ChatUploadedFile { export interface ChatUploadedFile {