]> git.djapps.eu Git - pkg/ggml/sources/llama.cpp/commitdiff
(webui) FEATURE: Enable adding or injecting System Message into chat (#19556)
authorAleksander Grygier <redacted>
Thu, 12 Feb 2026 12:56:08 +0000 (13:56 +0100)
committerGitHub <redacted>
Thu, 12 Feb 2026 12:56:08 +0000 (13:56 +0100)
* feat: Enable adding System Prompt per-chat

* fix: Save draft message in Chat Form when adding System Prompt from new chat view

* fix: Proper system message deletion logic

* chore: Formatting

* chore: update webui build output

tools/server/public/index.html.gz
tools/server/webui/src/lib/components/app/chat/ChatForm/ChatForm.svelte
tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionFileAttachments.svelte
tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActions.svelte
tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessage.svelte
tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageSystem.svelte
tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreen.svelte
tools/server/webui/src/lib/stores/chat.svelte.ts

index 64a6402fc9dc94d4792dbe950aee98cff702ef85..ae2c8f77a7c83a47f1a1183ece27be472e03e84f 100644 (file)
Binary files a/tools/server/public/index.html.gz and b/tools/server/public/index.html.gz differ
index 27ab975cbd0b406cc55ddb1a2d81b459f3c6424f..95645295fb9690886bb75e70243d0a06714bd059 100644 (file)
        interface Props {
                class?: string;
                disabled?: boolean;
+               initialMessage?: string;
                isLoading?: boolean;
                onFileRemove?: (fileId: string) => void;
                onFileUpload?: (files: File[]) => void;
                onSend?: (message: string, files?: ChatUploadedFile[]) => Promise<boolean>;
                onStop?: () => void;
+               onSystemPromptAdd?: (draft: { message: string; files: ChatUploadedFile[] }) => void;
                showHelperText?: boolean;
                uploadedFiles?: ChatUploadedFile[];
        }
        let {
                class: className,
                disabled = false,
+               initialMessage = '',
                isLoading = false,
                onFileRemove,
                onFileUpload,
                onSend,
                onStop,
+               onSystemPromptAdd,
                showHelperText = true,
                uploadedFiles = $bindable([])
        }: Props = $props();
        let currentConfig = $derived(config());
        let fileInputRef: ChatFormFileInputInvisible | undefined = $state(undefined);
        let isRecording = $state(false);
-       let message = $state('');
+       let message = $state(initialMessage);
        let pasteLongTextToFileLength = $derived.by(() => {
                const n = Number(currentConfig.pasteLongTextToFileLen);
                return Number.isNaN(n) ? Number(SETTING_CONFIG_DEFAULT.pasteLongTextToFileLen) : n;
        });
        let previousIsLoading = $state(isLoading);
+       let previousInitialMessage = $state(initialMessage);
        let recordingSupported = $state(false);
        let textareaRef: ChatFormTextarea | undefined = $state(undefined);
 
+       // Sync message when initialMessage prop changes (e.g., after draft restoration)
+       $effect(() => {
+               if (initialMessage !== previousInitialMessage) {
+                       message = initialMessage;
+                       previousInitialMessage = initialMessage;
+               }
+       });
+
+       function handleSystemPromptClick() {
+               onSystemPromptAdd?.({ message, files: uploadedFiles });
+       }
+
        // Check if model is selected (in ROUTER mode)
        let conversationModel = $derived(
                chatStore.getConversationModel(activeMessages() as DatabaseMessage[])
                        onFileUpload={handleFileUpload}
                        onMicClick={handleMicClick}
                        onStop={handleStop}
+                       onSystemPromptClick={handleSystemPromptClick}
                />
        </div>
 </form>
index dd372680964781cccc0e8b8fc0ac92ae9f079416..3545b4aebf86bcd38c38bad1d772a53b4ef3e7dd 100644 (file)
@@ -1,5 +1,6 @@
 <script lang="ts">
        import { Paperclip } from '@lucide/svelte';
+       import { MessageSquare } from '@lucide/svelte';
        import { Button } from '$lib/components/ui/button';
        import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
        import * as Tooltip from '$lib/components/ui/tooltip';
@@ -11,6 +12,7 @@
                hasAudioModality?: boolean;
                hasVisionModality?: boolean;
                onFileUpload?: () => void;
+               onSystemPromptClick?: () => void;
        }
 
        let {
@@ -18,7 +20,8 @@
                disabled = false,
                hasAudioModality = false,
                hasVisionModality = false,
-               onFileUpload
+               onFileUpload,
+               onSystemPromptClick
        }: Props = $props();
 
        const fileUploadTooltipText = $derived.by(() => {
                                        </Tooltip.Content>
                                {/if}
                        </Tooltip.Root>
+                       <DropdownMenu.Separator />
+                       <Tooltip.Root>
+                               <Tooltip.Trigger class="w-full">
+                                       <DropdownMenu.Item
+                                               class="flex cursor-pointer items-center gap-2"
+                                               onclick={() => onSystemPromptClick?.()}
+                                       >
+                                               <MessageSquare class="h-4 w-4" />
+
+                                               <span>System Prompt</span>
+                                       </DropdownMenu.Item>
+                               </Tooltip.Trigger>
+
+                               <Tooltip.Content>
+                                       <p>Add a custom system message for this conversation</p>
+                               </Tooltip.Content>
+                       </Tooltip.Root>
                </DropdownMenu.Content>
        </DropdownMenu.Root>
 </div>
index dde9bda2d8f2ba0040a0fc4f996f6278140ac63a..c621a69e05050a721ae8cefc423dddc7c8f7382c 100644 (file)
@@ -27,6 +27,7 @@
                onFileUpload?: () => void;
                onMicClick?: () => void;
                onStop?: () => void;
+               onSystemPromptClick?: () => void;
        }
 
        let {
@@ -39,7 +40,8 @@
                uploadedFiles = [],
                onFileUpload,
                onMicClick,
-               onStop
+               onStop,
+               onSystemPromptClick
        }: Props = $props();
 
        let currentConfig = $derived(config());
                {hasAudioModality}
                {hasVisionModality}
                {onFileUpload}
+               {onSystemPromptClick}
        />
 
        <ModelsSelector
index 220276fc9e3135e5210835490297780ee800d6ba..82ef7de7c75b8e9c702ef5e9b2c09cde16ef8e20 100644 (file)
@@ -1,6 +1,15 @@
 <script lang="ts">
-       import { chatStore } from '$lib/stores/chat.svelte';
+       import { goto } from '$app/navigation';
+       import {
+               chatStore,
+               pendingEditMessageId,
+               clearPendingEditMessageId,
+               removeSystemPromptPlaceholder
+       } from '$lib/stores/chat.svelte';
+       import { conversationsStore } from '$lib/stores/conversations.svelte';
+       import { DatabaseService } from '$lib/services';
        import { config } from '$lib/stores/settings.svelte';
+       import { SYSTEM_MESSAGE_PLACEHOLDER } from '$lib/constants/ui';
        import { copyToClipboard, isIMEComposing, formatMessageForClipboard } from '$lib/utils';
        import ChatMessageAssistant from './ChatMessageAssistant.svelte';
        import ChatMessageUser from './ChatMessageUser.svelte';
                return null;
        });
 
-       function handleCancelEdit() {
+       // Auto-start edit mode if this message is the pending edit target
+       $effect(() => {
+               const pendingId = pendingEditMessageId();
+
+               if (pendingId && pendingId === message.id && !isEditing) {
+                       handleEdit();
+                       clearPendingEditMessageId();
+               }
+       });
+
+       async function handleCancelEdit() {
                isEditing = false;
+
+               // If canceling a new system message with placeholder content, remove it without deleting children
+               if (message.role === 'system') {
+                       const conversationDeleted = await removeSystemPromptPlaceholder(message.id);
+
+                       if (conversationDeleted) {
+                               goto('/');
+                       }
+
+                       return;
+               }
+
                editedContent = message.content;
                editedExtras = message.extra ? [...message.extra] : [];
                editedUploadedFiles = [];
                onCopy?.(message);
        }
 
-       function handleConfirmDelete() {
-               onDelete?.(message);
+       async function handleConfirmDelete() {
+               if (message.role === 'system') {
+                       const conversationDeleted = await removeSystemPromptPlaceholder(message.id);
+
+                       if (conversationDeleted) {
+                               goto('/');
+                       }
+               } else {
+                       onDelete?.(message);
+               }
+
                showDeleteDialog = false;
        }
 
 
        function handleEdit() {
                isEditing = true;
-               editedContent = message.content;
+               // Clear placeholder content for system messages
+               editedContent =
+                       message.role === 'system' && message.content === SYSTEM_MESSAGE_PLACEHOLDER
+                               ? ''
+                               : message.content;
+               textareaElement?.focus();
                editedExtras = message.extra ? [...message.extra] : [];
                editedUploadedFiles = [];
 
        }
 
        async function handleSaveEdit() {
-               if (message.role === 'user' || message.role === 'system') {
+               if (message.role === 'system') {
+                       // System messages: update in place without branching
+                       const newContent = editedContent.trim();
+
+                       // If content is empty or still the placeholder, remove without deleting children
+                       if (!newContent) {
+                               const conversationDeleted = await removeSystemPromptPlaceholder(message.id);
+                               isEditing = false;
+                               if (conversationDeleted) {
+                                       goto('/');
+                               }
+                               return;
+                       }
+
+                       await DatabaseService.updateMessage(message.id, { content: newContent });
+                       const index = conversationsStore.findMessageIndex(message.id);
+                       if (index !== -1) {
+                               conversationsStore.updateMessageAtIndex(index, { content: newContent });
+                       }
+               } else if (message.role === 'user') {
                        const finalExtras = await getMergedExtras();
                        onEditWithBranching?.(message, editedContent.trim(), finalExtras);
                } else {
index c203822f60408a581b452db4c7535271987e5cac..887df5b7716a58c1e928eab98ca25f683561e3c6 100644 (file)
 
                                <Button class="h-8 px-3" onclick={onSaveEdit} disabled={!editedContent.trim()} size="sm">
                                        <Check class="mr-1 h-3 w-3" />
-                                       Send
+                                       Save
                                </Button>
                        </div>
                </div>
index 27439551a1f50e33a323c6ad2bb00f603dbd8aaa..6e26d510cdb0e2d0da19c6fcac3644e14dec5aa6 100644 (file)
@@ -71,6 +71,8 @@
 
        let emptyFileNames = $state<string[]>([]);
 
+       let initialMessage = $state('');
+
        let isEmpty = $derived(
                showCenteredEmpty && !activeConversation() && activeMessages().length === 0 && !isLoading()
        );
                }
        }
 
+       async function handleSystemPromptAdd(draft: { message: string; files: ChatUploadedFile[] }) {
+               if (draft.message || draft.files.length > 0) {
+                       chatStore.savePendingDraft(draft.message, draft.files);
+               }
+
+               await chatStore.addSystemPrompt();
+       }
+
        function handleScroll() {
                if (disableAutoScroll || !chatScrollContainer) return;
 
                if (!disableAutoScroll) {
                        setTimeout(() => scrollChatToBottom('instant'), INITIAL_SCROLL_DELAY);
                }
+
+               const pendingDraft = chatStore.consumePendingDraft();
+               if (pendingDraft) {
+                       initialMessage = pendingDraft.message;
+                       uploadedFiles = pendingDraft.files;
+               }
        });
 
        $effect(() => {
                        <div class="conversation-chat-form pointer-events-auto rounded-t-3xl pb-4">
                                <ChatForm
                                        disabled={hasPropsError || isEditing()}
+                                       {initialMessage}
                                        isLoading={isCurrentConversationLoading}
                                        onFileRemove={handleFileRemove}
                                        onFileUpload={handleFileUpload}
                                        onSend={handleSendMessage}
                                        onStop={() => chatStore.stopGeneration()}
+                                       onSystemPromptAdd={handleSystemPromptAdd}
                                        showHelperText={false}
                                        bind:uploadedFiles
                                />
                        <div in:fly={{ y: 10, duration: 250, delay: hasPropsError ? 0 : 300 }}>
                                <ChatForm
                                        disabled={hasPropsError}
+                                       {initialMessage}
                                        isLoading={isCurrentConversationLoading}
                                        onFileRemove={handleFileRemove}
                                        onFileUpload={handleFileUpload}
                                        onSend={handleSendMessage}
                                        onStop={() => chatStore.stopGeneration()}
+                                       onSystemPromptAdd={handleSystemPromptAdd}
                                        showHelperText={true}
                                        bind:uploadedFiles
                                />
index 879b2f3245fbe029766055e9279db9ec077628b8..89de4f080c2d5a6537236b9af5e1115166309551 100644 (file)
@@ -15,6 +15,7 @@ import {
 } from '$lib/utils';
 import { SvelteMap } from 'svelte/reactivity';
 import { DEFAULT_CONTEXT } from '$lib/constants/default-context';
+import { SYSTEM_MESSAGE_PLACEHOLDER } from '$lib/constants/ui';
 
 /**
  * chatStore - Active AI interaction and streaming state management
@@ -76,6 +77,10 @@ class ChatStore {
        private isStreamingActive = $state(false);
        private isEditModeActive = $state(false);
        private addFilesHandler: ((files: File[]) => void) | null = $state(null);
+       pendingEditMessageId = $state<string | null>(null);
+       // Draft preservation for navigation (e.g., when adding system prompt from welcome page)
+       private _pendingDraftMessage = $state<string>('');
+       private _pendingDraftFiles = $state<ChatUploadedFile[]>([]);
 
        // ─────────────────────────────────────────────────────────────────────────────
        // Loading State
@@ -455,6 +460,166 @@ class ChatStore {
                }
        }
 
+       /**
+        * Adds a system message at the top of a conversation and triggers edit mode.
+        * The system message is inserted between root and the first message of the active branch.
+        * Creates a new conversation if one doesn't exist.
+        */
+       async addSystemPrompt(): Promise<void> {
+               let activeConv = conversationsStore.activeConversation;
+
+               // Create conversation if needed
+               if (!activeConv) {
+                       await conversationsStore.createConversation();
+                       activeConv = conversationsStore.activeConversation;
+               }
+               if (!activeConv) return;
+
+               try {
+                       // Get all messages to find the root
+                       const allMessages = await conversationsStore.getConversationMessages(activeConv.id);
+                       const rootMessage = allMessages.find((m) => m.type === 'root' && m.parent === null);
+                       let rootId: string;
+
+                       // Create root message if it doesn't exist
+                       if (!rootMessage) {
+                               rootId = await DatabaseService.createRootMessage(activeConv.id);
+                       } else {
+                               rootId = rootMessage.id;
+                       }
+
+                       // Check if there's already a system message as root's child
+                       const existingSystemMessage = allMessages.find(
+                               (m) => m.role === 'system' && m.parent === rootId
+                       );
+
+                       if (existingSystemMessage) {
+                               // If system message exists, just trigger edit mode on it
+                               this.pendingEditMessageId = existingSystemMessage.id;
+
+                               // Make sure it's in active messages at the beginning
+                               if (!conversationsStore.activeMessages.some((m) => m.id === existingSystemMessage.id)) {
+                                       conversationsStore.activeMessages.unshift(existingSystemMessage);
+                               }
+                               return;
+                       }
+
+                       // Find the first message of the active branch (child of root that's in activeMessages)
+                       const activeMessages = conversationsStore.activeMessages;
+                       const firstActiveMessage = activeMessages.find((m) => m.parent === rootId);
+
+                       // Create new system message with placeholder content (will be edited by user)
+                       const systemMessage = await DatabaseService.createSystemMessage(
+                               activeConv.id,
+                               SYSTEM_MESSAGE_PLACEHOLDER,
+                               rootId
+                       );
+
+                       // If there's a first message in the active branch, re-parent it to the system message
+                       if (firstActiveMessage) {
+                               // Update the first message's parent to be the system message
+                               await DatabaseService.updateMessage(firstActiveMessage.id, {
+                                       parent: systemMessage.id
+                               });
+
+                               // Update the system message's children to include the first message
+                               await DatabaseService.updateMessage(systemMessage.id, {
+                                       children: [firstActiveMessage.id]
+                               });
+
+                               // Remove first message from root's children
+                               const updatedRootChildren = rootMessage
+                                       ? rootMessage.children.filter((id: string) => id !== firstActiveMessage.id)
+                                       : [];
+                               // Note: system message was already added to root's children by createSystemMessage
+                               await DatabaseService.updateMessage(rootId, {
+                                       children: [
+                                               ...updatedRootChildren.filter((id: string) => id !== systemMessage.id),
+                                               systemMessage.id
+                                       ]
+                               });
+
+                               // Update local state
+                               const firstMsgIndex = conversationsStore.findMessageIndex(firstActiveMessage.id);
+                               if (firstMsgIndex !== -1) {
+                                       conversationsStore.updateMessageAtIndex(firstMsgIndex, { parent: systemMessage.id });
+                               }
+                       }
+
+                       // Add system message to active messages at the beginning
+                       conversationsStore.activeMessages.unshift(systemMessage);
+
+                       // Set pending edit message ID to trigger edit mode
+                       this.pendingEditMessageId = systemMessage.id;
+
+                       conversationsStore.updateConversationTimestamp();
+               } catch (error) {
+                       console.error('Failed to add system prompt:', error);
+               }
+       }
+
+       /**
+        * Removes a system message placeholder without deleting its children.
+        * Re-parents children back to the root message.
+        * If this is a new empty conversation (only root + system placeholder), deletes the entire conversation.
+        * @returns true if the entire conversation was deleted, false otherwise
+        */
+       async removeSystemPromptPlaceholder(messageId: string): Promise<boolean> {
+               const activeConv = conversationsStore.activeConversation;
+               if (!activeConv) return false;
+
+               try {
+                       const allMessages = await conversationsStore.getConversationMessages(activeConv.id);
+                       const systemMessage = allMessages.find((m) => m.id === messageId);
+                       if (!systemMessage || systemMessage.role !== 'system') return false;
+
+                       const rootMessage = allMessages.find((m) => m.type === 'root' && m.parent === null);
+                       if (!rootMessage) return false;
+
+                       // Check if this is a new empty conversation (only root + system placeholder)
+                       const isEmptyConversation = allMessages.length === 2 && systemMessage.children.length === 0;
+
+                       if (isEmptyConversation) {
+                               // Delete the entire conversation
+                               await conversationsStore.deleteConversation(activeConv.id);
+                               return true;
+                       }
+
+                       // Re-parent system message's children to root
+                       for (const childId of systemMessage.children) {
+                               await DatabaseService.updateMessage(childId, { parent: rootMessage.id });
+
+                               // Update local state
+                               const childIndex = conversationsStore.findMessageIndex(childId);
+                               if (childIndex !== -1) {
+                                       conversationsStore.updateMessageAtIndex(childIndex, { parent: rootMessage.id });
+                               }
+                       }
+
+                       // Update root's children: remove system message, add system's children
+                       const newRootChildren = [
+                               ...rootMessage.children.filter((id: string) => id !== messageId),
+                               ...systemMessage.children
+                       ];
+                       await DatabaseService.updateMessage(rootMessage.id, { children: newRootChildren });
+
+                       // Delete the system message (without cascade)
+                       await DatabaseService.deleteMessage(messageId);
+
+                       // Remove from active messages
+                       const systemIndex = conversationsStore.findMessageIndex(messageId);
+                       if (systemIndex !== -1) {
+                               conversationsStore.activeMessages.splice(systemIndex, 1);
+                       }
+
+                       conversationsStore.updateConversationTimestamp();
+                       return false;
+               } catch (error) {
+                       console.error('Failed to remove system prompt placeholder:', error);
+                       return false;
+               }
+       }
+
        private async createAssistantMessage(parentId?: string): Promise<DatabaseMessage | null> {
                const activeConv = conversationsStore.activeConversation;
                if (!activeConv) return null;
@@ -916,6 +1081,28 @@ class ChatStore {
                if (!activeConv)
                        return { totalCount: 0, userMessages: 0, assistantMessages: 0, messageTypes: [] };
                const allMessages = await conversationsStore.getConversationMessages(activeConv.id);
+               const messageToDelete = allMessages.find((m) => m.id === messageId);
+
+               // For system messages, don't count descendants as they will be preserved (reparented to root)
+               if (messageToDelete?.role === 'system') {
+                       const messagesToDelete = allMessages.filter((m) => m.id === messageId);
+                       let userMessages = 0,
+                               assistantMessages = 0;
+                       const messageTypes: string[] = [];
+
+                       for (const msg of messagesToDelete) {
+                               if (msg.role === 'user') {
+                                       userMessages++;
+                                       if (!messageTypes.includes('user message')) messageTypes.push('user message');
+                               } else if (msg.role === 'assistant') {
+                                       assistantMessages++;
+                                       if (!messageTypes.includes('assistant response')) messageTypes.push('assistant response');
+                               }
+                       }
+
+                       return { totalCount: 1, userMessages, assistantMessages, messageTypes };
+               }
+
                const descendants = findDescendantMessages(allMessages, messageId);
                const allToDelete = [messageId, ...descendants];
                const messagesToDelete = allMessages.filter((m) => allToDelete.includes(m.id));
@@ -1381,6 +1568,31 @@ class ChatStore {
                return this.addFilesHandler;
        }
 
+       savePendingDraft(message: string, files: ChatUploadedFile[]): void {
+               this._pendingDraftMessage = message;
+               this._pendingDraftFiles = [...files];
+       }
+
+       consumePendingDraft(): { message: string; files: ChatUploadedFile[] } | null {
+               if (!this._pendingDraftMessage && this._pendingDraftFiles.length === 0) {
+                       return null;
+               }
+
+               const draft = {
+                       message: this._pendingDraftMessage,
+                       files: [...this._pendingDraftFiles]
+               };
+
+               this._pendingDraftMessage = '';
+               this._pendingDraftFiles = [];
+
+               return draft;
+       }
+
+       hasPendingDraft(): boolean {
+               return Boolean(this._pendingDraftMessage) || this._pendingDraftFiles.length > 0;
+       }
+
        public getAllLoadingChats(): string[] {
                return Array.from(this.chatLoadingStates.keys());
        }
@@ -1485,3 +1697,7 @@ export const isEditing = () => chatStore.isEditing();
 export const isLoading = () => chatStore.isLoading;
 export const setEditModeActive = (handler: (files: File[]) => void) =>
        chatStore.setEditModeActive(handler);
+export const pendingEditMessageId = () => chatStore.pendingEditMessageId;
+export const clearPendingEditMessageId = () => (chatStore.pendingEditMessageId = null);
+export const removeSystemPromptPlaceholder = (messageId: string) =>
+       chatStore.removeSystemPromptPlaceholder(messageId);