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>
<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 {
} 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
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
}
}
+ /**
+ * 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;
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));
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());
}
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);