* feat: Per-conversation system message with optional display in UI, edition and branching (WIP)
* chore: update webui build output
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;
}
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
}
</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
bind:textareaElement
class={className}
--- /dev/null
+<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>
{#if message.content.trim()}
<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}
>
{#if currentConfig.renderUserContentAsMarkdown}
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 {
let { class: className, messages = [], onUserAction }: Props = $props();
let allConversationMessages = $state<DatabaseMessage[]>([]);
+ const currentConfig = config();
function refreshAllMessages() {
const conversation = activeConversation();
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 {
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',
{ 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',
</div>
{#if field.help || SETTING_CONFIG_INFO[field.key]}
<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>
{/if}
{:else if field.type === 'textarea'}
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]}
<p class="mt-1 text-xs text-muted-foreground">
{field.help || SETTING_CONFIG_INFO[field.key]}
</p>
{/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'}
{@const selectedOption = field.options?.find(
(opt: { value: string; label: string; icon?: Component }) =>
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';
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) {
// 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,
};
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.',
+ 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:
custom,
timings_per_token,
// Config options
- systemMessage,
disableReasoningFormat
} = options;
}
})
.filter((msg) => {
+ // Filter out empty system messages
if (msg.role === 'system') {
const content = typeof msg.content === 'string' ? msg.content : '';
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
})),
// 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
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.
*
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)
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;
);
}
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);
}
-export type ChatMessageType = 'root' | 'text' | 'think';
+export type ChatMessageType = 'root' | 'text' | 'think' | 'system';
export type ChatRole = 'user' | 'assistant' | 'system';
export interface ChatUploadedFile {