Skip to content

Commit 21f24f2

Browse files
authored
webui: Per-conversation system message with UI displaying, edition & branching (ggml-org#17275)
* feat: Per-conversation system message with optional display in UI, edition and branching (WIP) * chore: update webui build output
1 parent 7b43f55 commit 21f24f2

File tree

14 files changed

+357
-64
lines changed

14 files changed

+357
-64
lines changed

tools/server/public/index.html.gz

297 Bytes
Binary file not shown.

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

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { copyToClipboard, isIMEComposing } from '$lib/utils';
44
import ChatMessageAssistant from './ChatMessageAssistant.svelte';
55
import ChatMessageUser from './ChatMessageUser.svelte';
6+
import ChatMessageSystem from './ChatMessageSystem.svelte';
67
78
interface Props {
89
class?: string;
@@ -140,8 +141,7 @@
140141
}
141142
142143
function handleSaveEdit() {
143-
if (message.role === 'user') {
144-
// For user messages, trim to avoid accidental whitespace
144+
if (message.role === 'user' || message.role === 'system') {
145145
onEditWithBranching?.(message, editedContent.trim());
146146
} else {
147147
// For assistant messages, preserve exact content including trailing whitespace
@@ -167,7 +167,28 @@
167167
}
168168
</script>
169169

170-
{#if message.role === 'user'}
170+
{#if message.role === 'system'}
171+
<ChatMessageSystem
172+
bind:textareaElement
173+
class={className}
174+
{deletionInfo}
175+
{editedContent}
176+
{isEditing}
177+
{message}
178+
onCancelEdit={handleCancelEdit}
179+
onConfirmDelete={handleConfirmDelete}
180+
onCopy={handleCopy}
181+
onDelete={handleDelete}
182+
onEdit={handleEdit}
183+
onEditKeydown={handleEditKeydown}
184+
onEditedContentChange={handleEditedContentChange}
185+
{onNavigateToSibling}
186+
onSaveEdit={handleSaveEdit}
187+
onShowDeleteDialogChange={handleShowDeleteDialogChange}
188+
{showDeleteDialog}
189+
{siblingInfo}
190+
/>
191+
{:else if message.role === 'user'}
171192
<ChatMessageUser
172193
bind:textareaElement
173194
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
@@ -145,7 +145,7 @@
145145

146146
{#if message.content.trim()}
147147
<Card
148-
class="max-w-[80%] rounded-[1.125rem] bg-primary px-3.75 py-1.5 text-primary-foreground data-[multiline]:py-2.5"
148+
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"
149149
data-multiline={isMultiline ? '' : undefined}
150150
>
151151
{#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
@@ -2,6 +2,7 @@
22
import { ChatMessage } from '$lib/components/app';
33
import { chatStore } from '$lib/stores/chat.svelte';
44
import { conversationsStore, activeConversation } from '$lib/stores/conversations.svelte';
5+
import { config } from '$lib/stores/settings.svelte';
56
import { getMessageSiblings } from '$lib/utils';
67
78
interface Props {
@@ -13,6 +14,7 @@
1314
let { class: className, messages = [], onUserAction }: Props = $props();
1415
1516
let allConversationMessages = $state<DatabaseMessage[]>([]);
17+
const currentConfig = config();
1618
1719
function refreshAllMessages() {
1820
const conversation = activeConversation();
@@ -40,7 +42,12 @@
4042
return [];
4143
}
4244
43-
return messages.map((message) => {
45+
// Filter out system messages if showSystemMessage is false
46+
const filteredMessages = currentConfig.showSystemMessage
47+
? messages
48+
: messages.filter((msg) => msg.type !== 'system');
49+
50+
return filteredMessages.map((message) => {
4451
const siblingInfo = getMessageSiblings(allConversationMessages, message.id);
4552
4653
return {

tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettings.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: 'pasteLongTextToFileLen',
5757
label: 'Paste long text to file length',

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
@@ -95,7 +95,7 @@
9595
</div>
9696
{#if field.help || SETTING_CONFIG_INFO[field.key]}
9797
<p class="mt-1 text-xs text-muted-foreground">
98-
{field.help || SETTING_CONFIG_INFO[field.key]}
98+
{@html field.help || SETTING_CONFIG_INFO[field.key]}
9999
</p>
100100
{/if}
101101
{:else if field.type === 'textarea'}
@@ -112,13 +112,28 @@
112112
value={String(localConfig[field.key] ?? '')}
113113
onchange={(e) => onConfigChange(field.key, e.currentTarget.value)}
114114
placeholder={`Default: ${SETTING_CONFIG_DEFAULT[field.key] ?? 'none'}`}
115-
class="min-h-[100px] w-full md:max-w-2xl"
115+
class="min-h-[10rem] w-full md:max-w-2xl"
116116
/>
117+
117118
{#if field.help || SETTING_CONFIG_INFO[field.key]}
118119
<p class="mt-1 text-xs text-muted-foreground">
119120
{field.help || SETTING_CONFIG_INFO[field.key]}
120121
</p>
121122
{/if}
123+
124+
{#if field.key === 'systemMessage'}
125+
<div class="mt-3 flex items-center gap-2">
126+
<Checkbox
127+
id="showSystemMessage"
128+
checked={Boolean(localConfig.showSystemMessage ?? true)}
129+
onCheckedChange={(checked) => onConfigChange('showSystemMessage', Boolean(checked))}
130+
/>
131+
132+
<Label for="showSystemMessage" class="cursor-pointer text-sm font-normal">
133+
Show system message in conversations
134+
</Label>
135+
</div>
136+
{/if}
122137
{:else if field.type === 'select'}
123138
{@const selectedOption = field.options?.find(
124139
(opt: { value: string; label: string; icon?: Component }) =>

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,10 @@ export { default as ChatMessage } from './chat/ChatMessages/ChatMessage.svelte';
1919
export { default as ChatMessageActions } from './chat/ChatMessages/ChatMessageActions.svelte';
2020
export { default as ChatMessageBranchingControls } from './chat/ChatMessages/ChatMessageBranchingControls.svelte';
2121
export { default as ChatMessageStatistics } from './chat/ChatMessages/ChatMessageStatistics.svelte';
22+
export { default as ChatMessageSystem } from './chat/ChatMessages/ChatMessageSystem.svelte';
2223
export { default as ChatMessageThinkingBlock } from './chat/ChatMessages/ChatMessageThinkingBlock.svelte';
2324
export { default as ChatMessages } from './chat/ChatMessages/ChatMessages.svelte';
25+
export { default as MessageBranchingControls } from './chat/ChatMessages/ChatMessageBranchingControls.svelte';
2426

2527
export { default as ChatScreen } from './chat/ChatScreen/ChatScreen.svelte';
2628
export { default as ChatScreenHeader } from './chat/ChatScreen/ChatScreenHeader.svelte';

0 commit comments

Comments
 (0)