webui: Per-conversation system message with UI displaying, edition & branching (#17275)
* feat: Per-conversation system message with optional display in UI, edition and branching (WIP) * chore: update webui build output
This commit is contained in:
parent
7b43f55753
commit
21f24f27a9
Binary file not shown.
|
|
@ -3,6 +3,7 @@
|
||||||
import { copyToClipboard, isIMEComposing } from '$lib/utils';
|
import { copyToClipboard, isIMEComposing } from '$lib/utils';
|
||||||
import ChatMessageAssistant from './ChatMessageAssistant.svelte';
|
import ChatMessageAssistant from './ChatMessageAssistant.svelte';
|
||||||
import ChatMessageUser from './ChatMessageUser.svelte';
|
import ChatMessageUser from './ChatMessageUser.svelte';
|
||||||
|
import ChatMessageSystem from './ChatMessageSystem.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
class?: string;
|
class?: string;
|
||||||
|
|
@ -140,8 +141,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSaveEdit() {
|
function handleSaveEdit() {
|
||||||
if (message.role === 'user') {
|
if (message.role === 'user' || message.role === 'system') {
|
||||||
// For user messages, trim to avoid accidental whitespace
|
|
||||||
onEditWithBranching?.(message, editedContent.trim());
|
onEditWithBranching?.(message, editedContent.trim());
|
||||||
} else {
|
} else {
|
||||||
// For assistant messages, preserve exact content including trailing whitespace
|
// For assistant messages, preserve exact content including trailing whitespace
|
||||||
|
|
@ -167,7 +167,28 @@
|
||||||
}
|
}
|
||||||
</script>
|
</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
|
<ChatMessageUser
|
||||||
bind:textareaElement
|
bind:textareaElement
|
||||||
class={className}
|
class={className}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,216 @@
|
||||||
|
<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>
|
||||||
|
|
@ -145,7 +145,7 @@
|
||||||
|
|
||||||
{#if message.content.trim()}
|
{#if message.content.trim()}
|
||||||
<Card
|
<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}
|
data-multiline={isMultiline ? '' : undefined}
|
||||||
>
|
>
|
||||||
{#if currentConfig.renderUserContentAsMarkdown}
|
{#if currentConfig.renderUserContentAsMarkdown}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
import { ChatMessage } from '$lib/components/app';
|
import { ChatMessage } from '$lib/components/app';
|
||||||
import { chatStore } from '$lib/stores/chat.svelte';
|
import { chatStore } from '$lib/stores/chat.svelte';
|
||||||
import { conversationsStore, activeConversation } from '$lib/stores/conversations.svelte';
|
import { conversationsStore, activeConversation } from '$lib/stores/conversations.svelte';
|
||||||
|
import { config } from '$lib/stores/settings.svelte';
|
||||||
import { getMessageSiblings } from '$lib/utils';
|
import { getMessageSiblings } from '$lib/utils';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
@ -13,6 +14,7 @@
|
||||||
let { class: className, messages = [], onUserAction }: Props = $props();
|
let { class: className, messages = [], onUserAction }: Props = $props();
|
||||||
|
|
||||||
let allConversationMessages = $state<DatabaseMessage[]>([]);
|
let allConversationMessages = $state<DatabaseMessage[]>([]);
|
||||||
|
const currentConfig = config();
|
||||||
|
|
||||||
function refreshAllMessages() {
|
function refreshAllMessages() {
|
||||||
const conversation = activeConversation();
|
const conversation = activeConversation();
|
||||||
|
|
@ -40,7 +42,12 @@
|
||||||
return [];
|
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);
|
const siblingInfo = getMessageSiblings(allConversationMessages, message.id);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -36,12 +36,6 @@
|
||||||
title: 'General',
|
title: 'General',
|
||||||
icon: Settings,
|
icon: Settings,
|
||||||
fields: [
|
fields: [
|
||||||
{ key: 'apiKey', label: 'API Key', type: 'input' },
|
|
||||||
{
|
|
||||||
key: 'systemMessage',
|
|
||||||
label: 'System Message (will be disabled if left empty)',
|
|
||||||
type: 'textarea'
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: 'theme',
|
key: 'theme',
|
||||||
label: 'Theme',
|
label: 'Theme',
|
||||||
|
|
@ -52,6 +46,12 @@
|
||||||
{ value: 'dark', label: 'Dark', icon: Moon }
|
{ value: 'dark', label: 'Dark', icon: Moon }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{ key: 'apiKey', label: 'API Key', type: 'input' },
|
||||||
|
{
|
||||||
|
key: 'systemMessage',
|
||||||
|
label: 'System Message',
|
||||||
|
type: 'textarea'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'pasteLongTextToFileLen',
|
key: 'pasteLongTextToFileLen',
|
||||||
label: 'Paste long text to file length',
|
label: 'Paste long text to file length',
|
||||||
|
|
|
||||||
|
|
@ -95,7 +95,7 @@
|
||||||
</div>
|
</div>
|
||||||
{#if field.help || SETTING_CONFIG_INFO[field.key]}
|
{#if field.help || SETTING_CONFIG_INFO[field.key]}
|
||||||
<p class="mt-1 text-xs text-muted-foreground">
|
<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>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
{:else if field.type === 'textarea'}
|
{:else if field.type === 'textarea'}
|
||||||
|
|
@ -112,13 +112,28 @@
|
||||||
value={String(localConfig[field.key] ?? '')}
|
value={String(localConfig[field.key] ?? '')}
|
||||||
onchange={(e) => onConfigChange(field.key, e.currentTarget.value)}
|
onchange={(e) => onConfigChange(field.key, e.currentTarget.value)}
|
||||||
placeholder={`Default: ${SETTING_CONFIG_DEFAULT[field.key] ?? 'none'}`}
|
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]}
|
{#if field.help || SETTING_CONFIG_INFO[field.key]}
|
||||||
<p class="mt-1 text-xs text-muted-foreground">
|
<p class="mt-1 text-xs text-muted-foreground">
|
||||||
{field.help || SETTING_CONFIG_INFO[field.key]}
|
{field.help || SETTING_CONFIG_INFO[field.key]}
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/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'}
|
{:else if field.type === 'select'}
|
||||||
{@const selectedOption = field.options?.find(
|
{@const selectedOption = field.options?.find(
|
||||||
(opt: { value: string; label: string; icon?: Component }) =>
|
(opt: { value: string; label: string; icon?: Component }) =>
|
||||||
|
|
|
||||||
|
|
@ -19,8 +19,10 @@ export { default as ChatMessage } from './chat/ChatMessages/ChatMessage.svelte';
|
||||||
export { default as ChatMessageActions } from './chat/ChatMessages/ChatMessageActions.svelte';
|
export { default as ChatMessageActions } from './chat/ChatMessages/ChatMessageActions.svelte';
|
||||||
export { default as ChatMessageBranchingControls } from './chat/ChatMessages/ChatMessageBranchingControls.svelte';
|
export { default as ChatMessageBranchingControls } from './chat/ChatMessages/ChatMessageBranchingControls.svelte';
|
||||||
export { default as ChatMessageStatistics } from './chat/ChatMessages/ChatMessageStatistics.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 ChatMessageThinkingBlock } from './chat/ChatMessages/ChatMessageThinkingBlock.svelte';
|
||||||
export { default as ChatMessages } from './chat/ChatMessages/ChatMessages.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 ChatScreen } from './chat/ChatScreen/ChatScreen.svelte';
|
||||||
export { default as ChatScreenHeader } from './chat/ChatScreen/ChatScreenHeader.svelte';
|
export { default as ChatScreenHeader } from './chat/ChatScreen/ChatScreenHeader.svelte';
|
||||||
|
|
|
||||||
|
|
@ -337,19 +337,23 @@
|
||||||
line-height: 1.75;
|
line-height: 1.75;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
div :global(:is(h1, h2, h3, h4, h5, h6):first-child) {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* Headers with consistent spacing */
|
/* Headers with consistent spacing */
|
||||||
div :global(h1) {
|
div :global(h1) {
|
||||||
font-size: 1.875rem;
|
font-size: 1.875rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
margin: 1.5rem 0 0.75rem 0;
|
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
|
margin: 1.5rem 0 0.75rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
div :global(h2) {
|
div :global(h2) {
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin: 1.25rem 0 0.5rem 0;
|
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
|
margin: 1.25rem 0 0.5rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
div :global(h3) {
|
div :global(h3) {
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ export const SETTING_CONFIG_DEFAULT: Record<string, string | number | boolean> =
|
||||||
// Do not use nested objects, keep it single level. Prefix the key if you need to group them.
|
// Do not use nested objects, keep it single level. Prefix the key if you need to group them.
|
||||||
apiKey: '',
|
apiKey: '',
|
||||||
systemMessage: '',
|
systemMessage: '',
|
||||||
|
showSystemMessage: true,
|
||||||
theme: 'system',
|
theme: 'system',
|
||||||
showThoughtInProgress: false,
|
showThoughtInProgress: false,
|
||||||
showToolCalls: false,
|
showToolCalls: false,
|
||||||
|
|
@ -42,8 +43,9 @@ export const SETTING_CONFIG_DEFAULT: Record<string, string | number | boolean> =
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SETTING_CONFIG_INFO: Record<string, string> = {
|
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.',
|
systemMessage: 'The starting message that defines how model should behave.',
|
||||||
|
showSystemMessage: 'Display the system message at the top of each conversation.',
|
||||||
theme:
|
theme:
|
||||||
'Choose the color theme for the interface. You can choose between System (follows your device settings), Light, or Dark.',
|
'Choose the color theme for the interface. You can choose between System (follows your device settings), Light, or Dark.',
|
||||||
pasteLongTextToFileLen:
|
pasteLongTextToFileLen:
|
||||||
|
|
|
||||||
|
|
@ -89,7 +89,6 @@ export class ChatService {
|
||||||
custom,
|
custom,
|
||||||
timings_per_token,
|
timings_per_token,
|
||||||
// Config options
|
// Config options
|
||||||
systemMessage,
|
|
||||||
disableReasoningFormat
|
disableReasoningFormat
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
|
|
@ -103,6 +102,7 @@ export class ChatService {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.filter((msg) => {
|
.filter((msg) => {
|
||||||
|
// Filter out empty system messages
|
||||||
if (msg.role === 'system') {
|
if (msg.role === 'system') {
|
||||||
const content = typeof msg.content === 'string' ? msg.content : '';
|
const content = typeof msg.content === 'string' ? msg.content : '';
|
||||||
|
|
||||||
|
|
@ -112,10 +112,8 @@ export class ChatService {
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
const processedMessages = ChatService.injectSystemMessage(normalizedMessages, systemMessage);
|
|
||||||
|
|
||||||
const requestBody: ApiChatCompletionRequest = {
|
const requestBody: ApiChatCompletionRequest = {
|
||||||
messages: processedMessages.map((msg: ApiChatMessageData) => ({
|
messages: normalizedMessages.map((msg: ApiChatMessageData) => ({
|
||||||
role: msg.role,
|
role: msg.role,
|
||||||
content: msg.content
|
content: msg.content
|
||||||
})),
|
})),
|
||||||
|
|
@ -677,46 +675,6 @@ export class ChatService {
|
||||||
// Utilities
|
// 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
|
* Parses error response and creates appropriate error with context information
|
||||||
* @param response - HTTP response object
|
* @param response - HTTP response object
|
||||||
|
|
|
||||||
|
|
@ -166,6 +166,49 @@ export class DatabaseService {
|
||||||
return rootMessage.id;
|
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.
|
* Deletes a conversation and all its messages.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -624,6 +624,22 @@ class ChatStore {
|
||||||
this.clearChatStreaming(currentConv.id);
|
this.clearChatStreaming(currentConv.id);
|
||||||
|
|
||||||
try {
|
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);
|
const userMessage = await this.addMessage('user', content, 'text', '-1', extras);
|
||||||
if (!userMessage) throw new Error('Failed to add user message');
|
if (!userMessage) throw new Error('Failed to add user message');
|
||||||
if (isNewConversation && content)
|
if (isNewConversation && content)
|
||||||
|
|
@ -999,14 +1015,20 @@ class ChatStore {
|
||||||
const activeConv = conversationsStore.activeConversation;
|
const activeConv = conversationsStore.activeConversation;
|
||||||
if (!activeConv || this.isLoading) return;
|
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;
|
if (!result) return;
|
||||||
const { message: msg } = result;
|
const { message: msg } = result;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const allMessages = await conversationsStore.getConversationMessages(activeConv.id);
|
const allMessages = await conversationsStore.getConversationMessages(activeConv.id);
|
||||||
const rootMessage = allMessages.find((m) => m.type === 'root' && m.parent === null);
|
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;
|
const parentId = msg.parent || rootMessage?.id;
|
||||||
if (!parentId) return;
|
if (!parentId) return;
|
||||||
|
|
@ -1037,7 +1059,10 @@ class ChatStore {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
await conversationsStore.refreshActiveMessages();
|
await conversationsStore.refreshActiveMessages();
|
||||||
await this.generateResponseForMessage(newMessage.id);
|
|
||||||
|
if (msg.role === 'user') {
|
||||||
|
await this.generateResponseForMessage(newMessage.id);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to edit message with branching:', error);
|
console.error('Failed to edit message with branching:', error);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
export type ChatMessageType = 'root' | 'text' | 'think';
|
export type ChatMessageType = 'root' | 'text' | 'think' | 'system';
|
||||||
export type ChatRole = 'user' | 'assistant' | 'system';
|
export type ChatRole = 'user' | 'assistant' | 'system';
|
||||||
|
|
||||||
export interface ChatUploadedFile {
|
export interface ChatUploadedFile {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue