Skip to content

Commit 9f5f6c5

Browse files
committed
feat: Per-conversation system message with optional display in UI, edition and branching (WIP)
1 parent e1fcf8b commit 9f5f6c5

File tree

13 files changed

+349
-19
lines changed

13 files changed

+349
-19
lines changed

tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessage.svelte

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import { isIMEComposing } from '$lib/utils/is-ime-composing';
55
import ChatMessageAssistant from './ChatMessageAssistant.svelte';
66
import ChatMessageUser from './ChatMessageUser.svelte';
7+
import ChatMessageSystem from './ChatMessageSystem.svelte';
78
89
interface Props {
910
class?: string;
@@ -110,7 +111,7 @@
110111
}
111112
112113
function handleSaveEdit() {
113-
if (message.role === 'user') {
114+
if (message.role === 'user' || message.role === 'system') {
114115
onEditWithBranching?.(message, editedContent.trim());
115116
} else {
116117
onEditWithReplacement?.(message, editedContent.trim(), shouldBranchAfterEdit);
@@ -125,7 +126,28 @@
125126
}
126127
</script>
127128

128-
{#if message.role === 'user'}
129+
{#if message.role === 'system'}
130+
<ChatMessageSystem
131+
bind:textareaElement
132+
class={className}
133+
{deletionInfo}
134+
{editedContent}
135+
{isEditing}
136+
{message}
137+
onCancelEdit={handleCancelEdit}
138+
onConfirmDelete={handleConfirmDelete}
139+
onCopy={handleCopy}
140+
onDelete={handleDelete}
141+
onEdit={handleEdit}
142+
onEditKeydown={handleEditKeydown}
143+
onEditedContentChange={handleEditedContentChange}
144+
{onNavigateToSibling}
145+
onSaveEdit={handleSaveEdit}
146+
onShowDeleteDialogChange={handleShowDeleteDialogChange}
147+
{showDeleteDialog}
148+
{siblingInfo}
149+
/>
150+
{:else if message.role === 'user'}
129151
<ChatMessageUser
130152
bind:textareaElement
131153
class={className}
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
<script lang="ts">
2+
import { Check, X } from '@lucide/svelte';
3+
import { Card } from '$lib/components/ui/card';
4+
import { Button } from '$lib/components/ui/button';
5+
import { MarkdownContent } from '$lib/components/app';
6+
import { INPUT_CLASSES } from '$lib/constants/input-classes';
7+
import { config } from '$lib/stores/settings.svelte';
8+
import ChatMessageActions from './ChatMessageActions.svelte';
9+
10+
interface Props {
11+
class?: string;
12+
message: DatabaseMessage;
13+
isEditing: boolean;
14+
editedContent: string;
15+
siblingInfo?: ChatMessageSiblingInfo | null;
16+
showDeleteDialog: boolean;
17+
deletionInfo: {
18+
totalCount: number;
19+
userMessages: number;
20+
assistantMessages: number;
21+
messageTypes: string[];
22+
} | null;
23+
onCancelEdit: () => void;
24+
onSaveEdit: () => void;
25+
onEditKeydown: (event: KeyboardEvent) => void;
26+
onEditedContentChange: (content: string) => void;
27+
onCopy: () => void;
28+
onEdit: () => void;
29+
onDelete: () => void;
30+
onConfirmDelete: () => void;
31+
onNavigateToSibling?: (siblingId: string) => void;
32+
onShowDeleteDialogChange: (show: boolean) => void;
33+
textareaElement?: HTMLTextAreaElement;
34+
}
35+
36+
let {
37+
class: className = '',
38+
message,
39+
isEditing,
40+
editedContent,
41+
siblingInfo = null,
42+
showDeleteDialog,
43+
deletionInfo,
44+
onCancelEdit,
45+
onSaveEdit,
46+
onEditKeydown,
47+
onEditedContentChange,
48+
onCopy,
49+
onEdit,
50+
onDelete,
51+
onConfirmDelete,
52+
onNavigateToSibling,
53+
onShowDeleteDialogChange,
54+
textareaElement = $bindable()
55+
}: Props = $props();
56+
57+
let isMultiline = $state(false);
58+
let messageElement: HTMLElement | undefined = $state();
59+
let isExpanded = $state(false);
60+
let contentHeight = $state(0);
61+
const MAX_HEIGHT = 200; // pixels
62+
const currentConfig = config();
63+
64+
let showExpandButton = $derived(contentHeight > MAX_HEIGHT);
65+
66+
$effect(() => {
67+
if (!messageElement || !message.content.trim()) return;
68+
69+
if (message.content.includes('\n')) {
70+
isMultiline = true;
71+
}
72+
73+
const resizeObserver = new ResizeObserver((entries) => {
74+
for (const entry of entries) {
75+
const element = entry.target as HTMLElement;
76+
const estimatedSingleLineHeight = 24;
77+
78+
isMultiline = element.offsetHeight > estimatedSingleLineHeight * 1.5;
79+
contentHeight = element.scrollHeight;
80+
}
81+
});
82+
83+
resizeObserver.observe(messageElement);
84+
85+
return () => {
86+
resizeObserver.disconnect();
87+
};
88+
});
89+
90+
function toggleExpand() {
91+
isExpanded = !isExpanded;
92+
}
93+
</script>
94+
95+
<div
96+
aria-label="System message with actions"
97+
class="group flex flex-col items-end gap-3 md:gap-2 {className}"
98+
role="group"
99+
>
100+
{#if isEditing}
101+
<div class="w-full max-w-[80%]">
102+
<textarea
103+
bind:this={textareaElement}
104+
bind:value={editedContent}
105+
class="min-h-[60px] w-full resize-none rounded-2xl px-3 py-2 text-sm {INPUT_CLASSES}"
106+
onkeydown={onEditKeydown}
107+
oninput={(e) => onEditedContentChange(e.currentTarget.value)}
108+
placeholder="Edit system message..."
109+
></textarea>
110+
111+
<div class="mt-2 flex justify-end gap-2">
112+
<Button class="h-8 px-3" onclick={onCancelEdit} size="sm" variant="outline">
113+
<X class="mr-1 h-3 w-3" />
114+
Cancel
115+
</Button>
116+
117+
<Button class="h-8 px-3" onclick={onSaveEdit} disabled={!editedContent.trim()} size="sm">
118+
<Check class="mr-1 h-3 w-3" />
119+
Send
120+
</Button>
121+
</div>
122+
</div>
123+
{:else}
124+
{#if message.content.trim()}
125+
<div class="relative max-w-[80%]">
126+
<button
127+
class="group/expand w-full text-left {!isExpanded && showExpandButton
128+
? 'cursor-pointer'
129+
: 'cursor-auto'}"
130+
onclick={showExpandButton && !isExpanded ? toggleExpand : undefined}
131+
type="button"
132+
>
133+
<Card
134+
class="rounded-[1.125rem] !border-2 !border-dashed !border-border/50 bg-muted px-3.75 py-1.5 data-[multiline]:py-2.5"
135+
data-multiline={isMultiline ? '' : undefined}
136+
style="border: 2px dashed hsl(var(--border));"
137+
>
138+
<div
139+
class="relative overflow-hidden transition-all duration-300 {isExpanded
140+
? 'cursor-text select-text'
141+
: 'select-none'}"
142+
style={!isExpanded && showExpandButton
143+
? `max-height: ${MAX_HEIGHT}px;`
144+
: 'max-height: none;'}
145+
>
146+
{#if currentConfig.renderUserContentAsMarkdown}
147+
<div bind:this={messageElement} class="text-md {isExpanded ? 'cursor-text' : ''}">
148+
<MarkdownContent class="markdown-system-content" content={message.content} />
149+
</div>
150+
{:else}
151+
<span
152+
bind:this={messageElement}
153+
class="text-md whitespace-pre-wrap {isExpanded ? 'cursor-text' : ''}"
154+
>
155+
{message.content}
156+
</span>
157+
{/if}
158+
159+
{#if !isExpanded && showExpandButton}
160+
<div
161+
class="pointer-events-none absolute right-0 bottom-0 left-0 h-48 bg-gradient-to-t from-muted to-transparent"
162+
></div>
163+
<div
164+
class="pointer-events-none absolute right-0 bottom-4 left-0 flex justify-center opacity-0 transition-opacity group-hover/expand:opacity-100"
165+
>
166+
<Button
167+
class="rounded-full px-4 py-1.5 text-xs shadow-md"
168+
size="sm"
169+
variant="outline"
170+
>
171+
Show full system message
172+
</Button>
173+
</div>
174+
{/if}
175+
</div>
176+
177+
{#if isExpanded && showExpandButton}
178+
<div class="mb-2 flex justify-center">
179+
<Button
180+
class="rounded-full px-4 py-1.5 text-xs"
181+
onclick={(e) => {
182+
e.stopPropagation();
183+
toggleExpand();
184+
}}
185+
size="sm"
186+
variant="outline"
187+
>
188+
Collapse System Message
189+
</Button>
190+
</div>
191+
{/if}
192+
</Card>
193+
</button>
194+
</div>
195+
{/if}
196+
197+
{#if message.timestamp}
198+
<div class="max-w-[80%]">
199+
<ChatMessageActions
200+
actionsPosition="right"
201+
{deletionInfo}
202+
justify="end"
203+
{onConfirmDelete}
204+
{onCopy}
205+
{onDelete}
206+
{onEdit}
207+
{onNavigateToSibling}
208+
{onShowDeleteDialogChange}
209+
{siblingInfo}
210+
{showDeleteDialog}
211+
role="user"
212+
/>
213+
</div>
214+
{/if}
215+
{/if}
216+
</div>

tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageUser.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@
122122

123123
{#if message.content.trim()}
124124
<Card
125-
class="max-w-[80%] rounded-[1.125rem] bg-primary px-3.75 py-1.5 text-primary-foreground data-[multiline]:py-2.5"
125+
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"
126126
data-multiline={isMultiline ? '' : undefined}
127127
>
128128
{#if currentConfig.renderUserContentAsMarkdown}

tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessages.svelte

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
editAssistantMessage,
1010
regenerateMessageWithBranching
1111
} from '$lib/stores/chat.svelte';
12+
import { config } from '$lib/stores/settings.svelte';
1213
import { getMessageSiblings } from '$lib/utils/branching';
1314
1415
interface Props {
@@ -20,6 +21,7 @@
2021
let { class: className, messages = [], onUserAction }: Props = $props();
2122
2223
let allConversationMessages = $state<DatabaseMessage[]>([]);
24+
const currentConfig = config();
2325
2426
function refreshAllMessages() {
2527
const conversation = activeConversation();
@@ -47,7 +49,12 @@
4749
return [];
4850
}
4951
50-
return messages.map((message) => {
52+
// Filter out system messages if showSystemMessage is false
53+
const filteredMessages = currentConfig.showSystemMessage
54+
? messages
55+
: messages.filter((msg) => msg.type !== 'system');
56+
57+
return filteredMessages.map((message) => {
5158
const siblingInfo = getMessageSiblings(allConversationMessages, message.id);
5259
5360
return {

tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsDialog.svelte

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -36,12 +36,6 @@
3636
title: 'General',
3737
icon: Settings,
3838
fields: [
39-
{ key: 'apiKey', label: 'API Key', type: 'input' },
40-
{
41-
key: 'systemMessage',
42-
label: 'System Message (will be disabled if left empty)',
43-
type: 'textarea'
44-
},
4539
{
4640
key: 'theme',
4741
label: 'Theme',
@@ -52,6 +46,12 @@
5246
{ value: 'dark', label: 'Dark', icon: Moon }
5347
]
5448
},
49+
{ key: 'apiKey', label: 'API Key', type: 'input' },
50+
{
51+
key: 'systemMessage',
52+
label: 'System Message',
53+
type: 'textarea'
54+
},
5555
{
5656
key: 'showMessageStats',
5757
label: 'Show message generation statistics',

tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsFields.svelte

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@
9393
</div>
9494
{#if field.help || SETTING_CONFIG_INFO[field.key]}
9595
<p class="mt-1 text-xs text-muted-foreground">
96-
{field.help || SETTING_CONFIG_INFO[field.key]}
96+
{@html field.help || SETTING_CONFIG_INFO[field.key]}
9797
</p>
9898
{/if}
9999
{:else if field.type === 'textarea'}
@@ -106,13 +106,28 @@
106106
value={String(localConfig[field.key] ?? '')}
107107
onchange={(e) => onConfigChange(field.key, e.currentTarget.value)}
108108
placeholder={`Default: ${SETTING_CONFIG_DEFAULT[field.key] ?? 'none'}`}
109-
class="min-h-[100px] w-full md:max-w-2xl"
109+
class="min-h-[10rem] w-full md:max-w-2xl"
110110
/>
111+
111112
{#if field.help || SETTING_CONFIG_INFO[field.key]}
112113
<p class="mt-1 text-xs text-muted-foreground">
113114
{field.help || SETTING_CONFIG_INFO[field.key]}
114115
</p>
115116
{/if}
117+
118+
{#if field.key === 'systemMessage'}
119+
<div class="mt-3 flex items-center gap-2">
120+
<Checkbox
121+
id="showSystemMessage"
122+
checked={Boolean(localConfig.showSystemMessage ?? true)}
123+
onCheckedChange={(checked) => onConfigChange('showSystemMessage', Boolean(checked))}
124+
/>
125+
126+
<Label for="showSystemMessage" class="cursor-pointer text-sm font-normal">
127+
Show system message in conversations
128+
</Label>
129+
</div>
130+
{/if}
116131
{:else if field.type === 'select'}
117132
{@const selectedOption = field.options?.find(
118133
(opt: { value: string; label: string; icon?: Component }) =>

tools/server/webui/src/lib/components/app/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export { default as ChatFormFileInputInvisible } from './chat/ChatForm/ChatFormF
1515

1616
export { default as ChatMessage } from './chat/ChatMessages/ChatMessage.svelte';
1717
export { default as ChatMessages } from './chat/ChatMessages/ChatMessages.svelte';
18+
export { default as ChatMessageSystem } from './chat/ChatMessages/ChatMessageSystem.svelte';
1819
export { default as ChatMessageThinkingBlock } from './chat/ChatMessages/ChatMessageThinkingBlock.svelte';
1920
export { default as MessageBranchingControls } from './chat/ChatMessages/ChatMessageBranchingControls.svelte';
2021

0 commit comments

Comments
 (0)