feat: Enable adding System Prompt per-chat

This commit is contained in:
Aleksander Grygier 2026-01-05 12:03:35 +01:00
parent 469263668f
commit 2d6020b574
8 changed files with 259 additions and 9 deletions

View File

@ -42,6 +42,7 @@
onFileUpload?: (files: File[]) => void;
onSend?: (message: string, files?: ChatUploadedFile[]) => Promise<boolean>;
onStop?: () => void;
onSystemPromptAdd?: () => void;
showHelperText?: boolean;
uploadedFiles?: ChatUploadedFile[];
}
@ -54,6 +55,7 @@
onFileUpload,
onSend,
onStop,
onSystemPromptAdd,
showHelperText = true,
uploadedFiles = $bindable([])
}: Props = $props();
@ -389,6 +391,7 @@
onFileUpload={handleFileUpload}
onMicClick={handleMicClick}
onStop={handleStop}
onSystemPromptClick={onSystemPromptAdd}
/>
</div>
</form>

View File

@ -1,5 +1,5 @@
<script lang="ts">
import { Plus } from '@lucide/svelte';
import { Plus, 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';
@ -15,6 +15,7 @@
showMcpOption?: boolean;
onFileUpload?: (fileType?: FileTypeCategory) => void;
onMcpClick?: () => void;
onSystemPromptClick?: () => void;
}
let {
@ -24,7 +25,8 @@
hasVisionModality = false,
showMcpOption = false,
onFileUpload,
onMcpClick
onMcpClick,
onSystemPromptClick
}: Props = $props();
const fileUploadTooltipText = 'Add files or MCP servers';
@ -136,6 +138,24 @@
<span>MCP Servers</span>
</DropdownMenu.Item>
{/if}
<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>

View File

@ -30,6 +30,7 @@
onFileUpload?: (fileType?: FileTypeCategory) => void;
onMicClick?: () => void;
onStop?: () => void;
onSystemPromptClick?: () => void;
}
let {
@ -42,7 +43,8 @@
uploadedFiles = [],
onFileUpload,
onMicClick,
onStop
onStop,
onSystemPromptClick
}: Props = $props();
let currentConfig = $derived(config());
@ -167,7 +169,7 @@
let showMcpDialog = $state(false);
// MCP servers state (simplified - just need to check if any exist)
let mcpServers = $derived(parseMcpServerSettings(currentConfig.mcpServers));
let hasMcpServers = $derived(mcpServers.length > 0);
</script>
@ -181,6 +183,7 @@
showMcpOption={!hasMcpServers}
onMcpClick={() => (showMcpDialog = true)}
{onFileUpload}
{onSystemPromptClick}
/>
{#if hasMcpServers}

View 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';
@ -69,8 +78,30 @@
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 = [];
@ -103,7 +134,12 @@
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 = [];
@ -143,7 +179,26 @@
}
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 {

View File

@ -116,7 +116,7 @@
<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>

View File

@ -433,6 +433,7 @@
onFileUpload={handleFileUpload}
onSend={handleSendMessage}
onStop={() => chatStore.stopGeneration()}
onSystemPromptAdd={() => chatStore.addSystemPrompt()}
showHelperText={false}
bind:uploadedFiles
/>
@ -491,6 +492,7 @@
onFileUpload={handleFileUpload}
onSend={handleSendMessage}
onStop={() => chatStore.stopGeneration()}
onSystemPromptAdd={() => chatStore.addSystemPrompt()}
showHelperText={true}
bind:uploadedFiles
/>

View File

@ -0,0 +1 @@
export const SYSTEM_MESSAGE_PLACEHOLDER = 'System message';

View File

@ -17,6 +17,7 @@ import {
import { SvelteMap } from 'svelte/reactivity';
import { DEFAULT_CONTEXT } from '$lib/constants/default-context';
import { getAgenticConfig } from '$lib/config/agentic';
import { SYSTEM_MESSAGE_PLACEHOLDER } from '$lib/constants/ui';
/**
* chatStore - Active AI interaction and streaming state management
@ -78,6 +79,7 @@ class ChatStore {
private isStreamingActive = $state(false);
private isEditModeActive = $state(false);
private addFilesHandler: ((files: File[]) => void) | null = $state(null);
pendingEditMessageId = $state<string | null>(null);
// ─────────────────────────────────────────────────────────────────────────────
// Loading State
@ -457,6 +459,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;
@ -1514,3 +1676,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);