]> git.djapps.eu Git - pkg/ggml/sources/llama.cpp/commitdiff
webui: Add a "Continue" Action for Assistant Message (#16971)
authorAleksander Grygier <redacted>
Wed, 19 Nov 2025 13:39:50 +0000 (14:39 +0100)
committerGitHub <redacted>
Wed, 19 Nov 2025 13:39:50 +0000 (14:39 +0100)
* feat: Add "Continue" action for assistant messages

* feat: Continuation logic & prompt improvements

* chore: update webui build output

* feat: Improve logic for continuing the assistant message

* chore: update webui build output

* chore: Linting

* chore: update webui build output

* fix: Remove synthetic prompt logic, use the prefill feature by sending the conversation payload ending with assistant message

* chore: update webui build output

* feat: Enable "Continue" button based on config & non-reasoning model type

* chore: update webui build output

* chore: Update packages with `npm audit fix`

* fix: Remove redundant error

* chore: update webui build output

* chore: Update `.gitignore`

* fix: Add missing change

* feat: Add auto-resizing for Edit Assistant/User Message textareas

* chore: update webui build output

14 files changed:
tools/server/public/index.html.gz
tools/server/webui/.gitignore
tools/server/webui/package-lock.json
tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessage.svelte
tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageActions.svelte
tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageAssistant.svelte
tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageUser.svelte
tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessages.svelte
tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsDialog.svelte
tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsFields.svelte
tools/server/webui/src/lib/constants/settings-config.ts
tools/server/webui/src/lib/services/chat.ts
tools/server/webui/src/lib/stores/chat.svelte.ts
tools/server/webui/src/lib/types/settings.d.ts

index 48e341dbd12b3bae525fe9d55f9fc5c5280dd088..5ee68eced2a2bc6108b92258c7253e6ac3d02cda 100644 (file)
Binary files a/tools/server/public/index.html.gz and b/tools/server/public/index.html.gz differ
index cc54bb717ff2f9a21a14b43cb93c984c45ef42e0..051d884b08ea6746922d2b6607ef4355c80b164b 100644 (file)
@@ -25,3 +25,4 @@ vite.config.ts.timestamp-*
 
 *storybook.log
 storybook-static
+*.code-workspace
\ No newline at end of file
index a11b87ad509029910ee375bcaecbb71692672bca..4af5e86ab9adc5c375dad1b061b7c5ee5ddbe8d2 100644 (file)
                        }
                },
                "node_modules/@sveltejs/kit": {
-                       "version": "2.48.4",
-                       "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.48.4.tgz",
-                       "integrity": "sha512-TGFX1pZUt9qqY20Cv5NyYvy0iLWHf2jXi8s+eCGsig7jQMdwZWKUFMR6TbvFNhfDSUpc1sH/Y5EHv20g3HHA3g==",
+                       "version": "2.48.5",
+                       "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.48.5.tgz",
+                       "integrity": "sha512-/rnwfSWS3qwUSzvHynUTORF9xSJi7PCR9yXkxUOnRrNqyKmCmh3FPHH+E9BbgqxXfTevGXBqgnlh9kMb+9T5XA==",
                        "dev": true,
                        "license": "MIT",
                        "dependencies": {
                        "license": "MIT"
                },
                "node_modules/js-yaml": {
-                       "version": "4.1.0",
-                       "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
-                       "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
+                       "version": "4.1.1",
+                       "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
+                       "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
                        "dev": true,
                        "license": "MIT",
                        "dependencies": {
index e47a5a7dba9e6ee4282424bc05f1f73625ae184c..ae0dc2ed9fdceeb3bdd3599df9424f35adf7a5a7 100644 (file)
@@ -10,6 +10,7 @@
                class?: string;
                message: DatabaseMessage;
                onCopy?: (message: DatabaseMessage) => void;
+               onContinueAssistantMessage?: (message: DatabaseMessage) => void;
                onDelete?: (message: DatabaseMessage) => void;
                onEditWithBranching?: (message: DatabaseMessage, newContent: string) => void;
                onEditWithReplacement?: (
@@ -17,6 +18,7 @@
                        newContent: string,
                        shouldBranch: boolean
                ) => void;
+               onEditUserMessagePreserveResponses?: (message: DatabaseMessage, newContent: string) => void;
                onNavigateToSibling?: (siblingId: string) => void;
                onRegenerateWithBranching?: (message: DatabaseMessage) => void;
                siblingInfo?: ChatMessageSiblingInfo | null;
                class: className = '',
                message,
                onCopy,
+               onContinueAssistantMessage,
                onDelete,
                onEditWithBranching,
                onEditWithReplacement,
+               onEditUserMessagePreserveResponses,
                onNavigateToSibling,
                onRegenerateWithBranching,
                siblingInfo = null
                onRegenerateWithBranching?.(message);
        }
 
+       function handleContinue() {
+               onContinueAssistantMessage?.(message);
+       }
+
        function handleSaveEdit() {
                if (message.role === 'user') {
+                       // For user messages, trim to avoid accidental whitespace
                        onEditWithBranching?.(message, editedContent.trim());
                } else {
-                       onEditWithReplacement?.(message, editedContent.trim(), shouldBranchAfterEdit);
+                       // For assistant messages, preserve exact content including trailing whitespace
+                       // This is important for the Continue feature to work properly
+                       onEditWithReplacement?.(message, editedContent, shouldBranchAfterEdit);
                }
 
                isEditing = false;
                shouldBranchAfterEdit = false;
        }
 
+       function handleSaveEditOnly() {
+               if (message.role === 'user') {
+                       // For user messages, trim to avoid accidental whitespace
+                       onEditUserMessagePreserveResponses?.(message, editedContent.trim());
+               }
+
+               isEditing = false;
+       }
+
        function handleShowDeleteDialogChange(show: boolean) {
                showDeleteDialog = show;
        }
                onEditedContentChange={handleEditedContentChange}
                {onNavigateToSibling}
                onSaveEdit={handleSaveEdit}
+               onSaveEditOnly={handleSaveEditOnly}
                onShowDeleteDialogChange={handleShowDeleteDialogChange}
                {showDeleteDialog}
                {siblingInfo}
                messageContent={message.content}
                onCancelEdit={handleCancelEdit}
                onConfirmDelete={handleConfirmDelete}
+               onContinue={handleContinue}
                onCopy={handleCopy}
                onDelete={handleDelete}
                onEdit={handleEdit}
index c16a3105cbd55affa3ee3d0ae88cb5544aec500d..d37d80651406c7da55504d8d2c6231852c96461c 100644 (file)
@@ -1,5 +1,5 @@
 <script lang="ts">
-       import { Edit, Copy, RefreshCw, Trash2 } from '@lucide/svelte';
+       import { Edit, Copy, RefreshCw, Trash2, ArrowRight } from '@lucide/svelte';
        import { ActionButton, ConfirmationDialog } from '$lib/components/app';
        import ChatMessageBranchingControls from './ChatMessageBranchingControls.svelte';
 
@@ -18,6 +18,7 @@
                onCopy: () => void;
                onEdit?: () => void;
                onRegenerate?: () => void;
+               onContinue?: () => void;
                onDelete: () => void;
                onConfirmDelete: () => void;
                onNavigateToSibling?: (siblingId: string) => void;
@@ -31,6 +32,7 @@
                onCopy,
                onEdit,
                onConfirmDelete,
+               onContinue,
                onDelete,
                onNavigateToSibling,
                onShowDeleteDialogChange,
                                <ActionButton icon={RefreshCw} tooltip="Regenerate" onclick={onRegenerate} />
                        {/if}
 
+                       {#if role === 'assistant' && onContinue}
+                               <ActionButton icon={ArrowRight} tooltip="Continue" onclick={onContinue} />
+                       {/if}
+
                        <ActionButton icon={Trash2} tooltip="Delete" onclick={onDelete} />
                </div>
        </div>
index 26372426d29d5c1b5ba68a44d63e4276957ecc87..865d81ba44813eadea5ce4e520ab666337cfb70b 100644 (file)
@@ -2,6 +2,7 @@
        import { ChatMessageThinkingBlock, MarkdownContent } from '$lib/components/app';
        import { useProcessingState } from '$lib/hooks/use-processing-state.svelte';
        import { isLoading } from '$lib/stores/chat.svelte';
+       import autoResizeTextarea from '$lib/utils/autoresize-textarea';
        import { fade } from 'svelte/transition';
        import {
                Check,
@@ -39,6 +40,7 @@
                onCancelEdit?: () => void;
                onCopy: () => void;
                onConfirmDelete: () => void;
+               onContinue?: () => void;
                onDelete: () => void;
                onEdit?: () => void;
                onEditKeydown?: (event: KeyboardEvent) => void;
@@ -65,6 +67,7 @@
                messageContent,
                onCancelEdit,
                onConfirmDelete,
+               onContinue,
                onCopy,
                onDelete,
                onEdit,
                void copyToClipboard(model ?? '');
        }
 
+       $effect(() => {
+               if (isEditing && textareaElement) {
+                       autoResizeTextarea(textareaElement);
+               }
+       });
+
        function formatToolCallBadge(toolCall: ApiChatCompletionToolCall, index: number) {
                const callNumber = index + 1;
                const functionName = toolCall.function?.name?.trim();
                                bind:value={editedContent}
                                class="min-h-[50vh] w-full resize-y rounded-2xl px-3 py-2 text-sm {INPUT_CLASSES}"
                                onkeydown={onEditKeydown}
-                               oninput={(e) => onEditedContentChange?.(e.currentTarget.value)}
+                               oninput={(e) => {
+                                       autoResizeTextarea(e.currentTarget);
+                                       onEditedContentChange?.(e.currentTarget.value);
+                               }}
                                placeholder="Edit assistant message..."
                        ></textarea>
 
                        {onCopy}
                        {onEdit}
                        {onRegenerate}
+                       onContinue={currentConfig.enableContinueGeneration && !thinkingContent
+                               ? onContinue
+                               : undefined}
                        {onDelete}
                        {onConfirmDelete}
                        {onNavigateToSibling}
index cc2631b830c3eca2fa523b40c8116ee6e26bf05a..c8b615e1613fa6b7df5441e4f9310d5ea8a8d001 100644 (file)
@@ -1,10 +1,11 @@
 <script lang="ts">
-       import { Check, X } from '@lucide/svelte';
+       import { Check, X, Send } from '@lucide/svelte';
        import { Card } from '$lib/components/ui/card';
        import { Button } from '$lib/components/ui/button';
        import { ChatAttachmentsList, MarkdownContent } from '$lib/components/app';
        import { INPUT_CLASSES } from '$lib/constants/input-classes';
        import { config } from '$lib/stores/settings.svelte';
+       import autoResizeTextarea from '$lib/utils/autoresize-textarea';
        import ChatMessageActions from './ChatMessageActions.svelte';
 
        interface Props {
@@ -22,6 +23,7 @@
                } | null;
                onCancelEdit: () => void;
                onSaveEdit: () => void;
+               onSaveEditOnly?: () => void;
                onEditKeydown: (event: KeyboardEvent) => void;
                onEditedContentChange: (content: string) => void;
                onCopy: () => void;
@@ -43,6 +45,7 @@
                deletionInfo,
                onCancelEdit,
                onSaveEdit,
+               onSaveEditOnly,
                onEditKeydown,
                onEditedContentChange,
                onCopy,
        let messageElement: HTMLElement | undefined = $state();
        const currentConfig = config();
 
+       $effect(() => {
+               if (isEditing && textareaElement) {
+                       autoResizeTextarea(textareaElement);
+               }
+       });
+
        $effect(() => {
                if (!messageElement || !message.content.trim()) return;
 
                                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)}
+                               oninput={(e) => {
+                                       autoResizeTextarea(e.currentTarget);
+                                       onEditedContentChange(e.currentTarget.value);
+                               }}
                                placeholder="Edit your message..."
                        ></textarea>
 
                        <div class="mt-2 flex justify-end gap-2">
-                               <Button class="h-8 px-3" onclick={onCancelEdit} size="sm" variant="outline">
+                               <Button class="h-8 px-3" onclick={onCancelEdit} size="sm" variant="ghost">
                                        <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" />
+                               {#if onSaveEditOnly}
+                                       <Button
+                                               class="h-8 px-3"
+                                               onclick={onSaveEditOnly}
+                                               disabled={!editedContent.trim()}
+                                               size="sm"
+                                               variant="outline"
+                                       >
+                                               <Check class="mr-1 h-3 w-3" />
+                                               Save
+                                       </Button>
+                               {/if}
 
+                               <Button class="h-8 px-3" onclick={onSaveEdit} disabled={!editedContent.trim()} size="sm">
+                                       <Send class="mr-1 h-3 w-3" />
                                        Send
                                </Button>
                        </div>
index 45efeeb436fefcc4f7f7e4b247df7e6ae1fe6989..ee147858fbff22379594bbb4b2e44e992e7cbc09 100644 (file)
@@ -3,10 +3,12 @@
        import { DatabaseStore } from '$lib/stores/database';
        import {
                activeConversation,
+               continueAssistantMessage,
                deleteMessage,
-               navigateToSibling,
-               editMessageWithBranching,
                editAssistantMessage,
+               editMessageWithBranching,
+               editUserMessagePreserveResponses,
+               navigateToSibling,
                regenerateMessageWithBranching
        } from '$lib/stores/chat.svelte';
        import { getMessageSiblings } from '$lib/utils/branching';
 
                refreshAllMessages();
        }
+
+       async function handleContinueAssistantMessage(message: DatabaseMessage) {
+               onUserAction?.();
+
+               await continueAssistantMessage(message.id);
+
+               refreshAllMessages();
+       }
+
+       async function handleEditUserMessagePreserveResponses(
+               message: DatabaseMessage,
+               newContent: string
+       ) {
+               onUserAction?.();
+
+               await editUserMessagePreserveResponses(message.id, newContent);
+
+               refreshAllMessages();
+       }
+
        async function handleDeleteMessage(message: DatabaseMessage) {
                await deleteMessage(message.id);
 
                        onNavigateToSibling={handleNavigateToSibling}
                        onEditWithBranching={handleEditWithBranching}
                        onEditWithReplacement={handleEditWithReplacement}
+                       onEditUserMessagePreserveResponses={handleEditUserMessagePreserveResponses}
                        onRegenerateWithBranching={handleRegenerateWithBranching}
+                       onContinueAssistantMessage={handleContinueAssistantMessage}
                />
        {/each}
 </div>
index d2a0a739c54d1beb6d1e1bdd0907d99aa0754017..6734fc06930bac02b2d78cda5bea601ff27b4a02 100644 (file)
                                                { value: 'dark', label: 'Dark', icon: Moon }
                                        ]
                                },
+                               {
+                                       key: 'pasteLongTextToFileLen',
+                                       label: 'Paste long text to file length',
+                                       type: 'input'
+                               },
                                {
                                        key: 'showMessageStats',
                                        label: 'Show message generation statistics',
                                        type: 'checkbox'
                                },
                                {
-                                       key: 'askForTitleConfirmation',
-                                       label: 'Ask for confirmation before changing conversation title',
+                                       key: 'showModelInfo',
+                                       label: 'Show model information',
                                        type: 'checkbox'
                                },
                                {
-                                       key: 'pasteLongTextToFileLen',
-                                       label: 'Paste long text to file length',
-                                       type: 'input'
+                                       key: 'enableContinueGeneration',
+                                       label: 'Enable "Continue" button',
+                                       type: 'checkbox',
+                                       isExperimental: true
                                },
                                {
                                        key: 'pdfAsImage',
                                        type: 'checkbox'
                                },
                                {
-                                       key: 'showModelInfo',
-                                       label: 'Show model information',
+                                       key: 'renderUserContentAsMarkdown',
+                                       label: 'Render user content as Markdown',
                                        type: 'checkbox'
                                },
                                {
-                                       key: 'renderUserContentAsMarkdown',
-                                       label: 'Render user content as Markdown',
+                                       key: 'askForTitleConfirmation',
+                                       label: 'Ask for confirmation before changing conversation title',
                                        type: 'checkbox'
                                }
                        ]
index d17f7e4229af6ea15dc2d30d11ebaba59030ce1d..992075e9728554fa8302caec1c7928a82e737ddb 100644 (file)
@@ -1,5 +1,5 @@
 <script lang="ts">
-       import { RotateCcw } from '@lucide/svelte';
+       import { RotateCcw, FlaskConical } from '@lucide/svelte';
        import { Checkbox } from '$lib/components/ui/checkbox';
        import { Input } from '$lib/components/ui/input';
        import Label from '$lib/components/ui/label/label.svelte';
                        })()}
 
                        <div class="flex items-center gap-2">
-                               <Label for={field.key} class="text-sm font-medium">
+                               <Label for={field.key} class="flex items-center gap-1.5 text-sm font-medium">
                                        {field.label}
+
+                                       {#if field.isExperimental}
+                                               <FlaskConical class="h-3.5 w-3.5 text-muted-foreground" />
+                                       {/if}
                                </Label>
                                {#if isCustomRealTime}
                                        <ParameterSourceIndicator />
                                </p>
                        {/if}
                {:else if field.type === 'textarea'}
-                       <Label for={field.key} class="block text-sm font-medium">
+                       <Label for={field.key} class="block flex items-center gap-1.5 text-sm font-medium">
                                {field.label}
+
+                               {#if field.isExperimental}
+                                       <FlaskConical class="h-3.5 w-3.5 text-muted-foreground" />
+                               {/if}
                        </Label>
 
                        <Textarea
                        })()}
 
                        <div class="flex items-center gap-2">
-                               <Label for={field.key} class="text-sm font-medium">
+                               <Label for={field.key} class="flex items-center gap-1.5 text-sm font-medium">
                                        {field.label}
+
+                                       {#if field.isExperimental}
+                                               <FlaskConical class="h-3.5 w-3.5 text-muted-foreground" />
+                                       {/if}
                                </Label>
                                {#if isCustomRealTime}
                                        <ParameterSourceIndicator />
                                                for={field.key}
                                                class="cursor-pointer text-sm leading-none font-medium {isDisabled
                                                        ? 'text-muted-foreground'
-                                                       : ''}"
+                                                       : ''} flex items-center gap-1.5"
                                        >
                                                {field.label}
+
+                                               {#if field.isExperimental}
+                                                       <FlaskConical class="h-3.5 w-3.5 text-muted-foreground" />
+                                               {/if}
                                        </label>
 
                                        {#if field.help || SETTING_CONFIG_INFO[field.key]}
index 7547832d95ae1bbbb0772f540e894085f4bbc217..c25ea23f37be3047e25f07dff60b2091e8062d71 100644 (file)
@@ -38,7 +38,8 @@ export const SETTING_CONFIG_DEFAULT: Record<string, string | number | boolean> =
        max_tokens: -1,
        custom: '', // custom json-stringified object
        // experimental features
-       pyInterpreterEnabled: false
+       pyInterpreterEnabled: false,
+       enableContinueGeneration: false
 };
 
 export const SETTING_CONFIG_INFO: Record<string, string> = {
@@ -96,5 +97,7 @@ export const SETTING_CONFIG_INFO: Record<string, string> = {
        modelSelectorEnabled:
                'Enable the model selector in the chat input to choose the inference model. Sends the associated model field in API requests.',
        pyInterpreterEnabled:
-               'Enable Python interpreter using Pyodide. Allows running Python code in markdown code blocks.'
+               'Enable Python interpreter using Pyodide. Allows running Python code in markdown code blocks.',
+       enableContinueGeneration:
+               'Enable "Continue" button for assistant messages. Currently works only with non-reasoning models.'
 };
index 1908d83909eab0e5a1a6300ab1cded0416c8dbc8..aa83910b27f534c9d642651ebd0b978ee760ea34 100644 (file)
@@ -312,7 +312,6 @@ export class ChatService {
                let aggregatedContent = '';
                let fullReasoningContent = '';
                let aggregatedToolCalls: ApiChatCompletionToolCall[] = [];
-               let hasReceivedData = false;
                let lastTimings: ChatMessageTimings | undefined;
                let streamFinished = false;
                let modelEmitted = false;
@@ -352,8 +351,6 @@ export class ChatService {
                                return;
                        }
 
-                       hasReceivedData = true;
-
                        if (!abortSignal?.aborted) {
                                onToolCallChunk?.(serializedToolCalls);
                        }
@@ -415,7 +412,6 @@ export class ChatService {
 
                                                        if (content) {
                                                                finalizeOpenToolCallBatch();
-                                                               hasReceivedData = true;
                                                                aggregatedContent += content;
                                                                if (!abortSignal?.aborted) {
                                                                        onChunk?.(content);
@@ -424,7 +420,6 @@ export class ChatService {
 
                                                        if (reasoningContent) {
                                                                finalizeOpenToolCallBatch();
-                                                               hasReceivedData = true;
                                                                fullReasoningContent += reasoningContent;
                                                                if (!abortSignal?.aborted) {
                                                                        onReasoningChunk?.(reasoningContent);
@@ -446,15 +441,6 @@ export class ChatService {
                        if (streamFinished) {
                                finalizeOpenToolCallBatch();
 
-                               if (
-                                       !hasReceivedData &&
-                                       aggregatedContent.length === 0 &&
-                                       aggregatedToolCalls.length === 0
-                               ) {
-                                       const noResponseError = new Error('No response received from server. Please try again.');
-                                       throw noResponseError;
-                               }
-
                                const finalToolCalls =
                                        aggregatedToolCalls.length > 0 ? JSON.stringify(aggregatedToolCalls) : undefined;
 
index 5b5a9d74a5bc678c9dee65a63a8b97034b6d401c..c70b9580cb75ba0ee7746ac89aaff99659f212ba 100644 (file)
@@ -1486,6 +1486,10 @@ class ChatStore {
                                        timestamp: Date.now()
                                });
 
+                               // Ensure currNode points to the edited message to maintain correct path
+                               await DatabaseStore.updateCurrentNode(this.activeConversation.id, messageToEdit.id);
+                               this.activeConversation.currNode = messageToEdit.id;
+
                                this.updateMessageAtIndex(messageIndex, {
                                        content: newContent,
                                        timestamp: Date.now()
@@ -1499,6 +1503,69 @@ class ChatStore {
                }
        }
 
+       /**
+        * Edits a user message and preserves all responses below
+        * Updates the message content in-place without deleting or regenerating responses
+        *
+        * **Use Case**: When you want to fix a typo or rephrase a question without losing the assistant's response
+        *
+        * **Important Behavior:**
+        * - Does NOT create a branch (unlike editMessageWithBranching)
+        * - Does NOT regenerate assistant responses
+        * - Only updates the user message content in the database
+        * - Preserves the entire conversation tree below the edited message
+        * - Updates conversation title if this is the first user message
+        *
+        * @param messageId - The ID of the user message to edit
+        * @param newContent - The new content for the message
+        */
+       async editUserMessagePreserveResponses(messageId: string, newContent: string): Promise<void> {
+               if (!this.activeConversation) return;
+
+               try {
+                       const messageIndex = this.findMessageIndex(messageId);
+                       if (messageIndex === -1) {
+                               console.error('Message not found for editing');
+                               return;
+                       }
+
+                       const messageToEdit = this.activeMessages[messageIndex];
+                       if (messageToEdit.role !== 'user') {
+                               console.error('Only user messages can be edited with this method');
+                               return;
+                       }
+
+                       // Simply update the message content in-place
+                       await DatabaseStore.updateMessage(messageId, {
+                               content: newContent,
+                               timestamp: Date.now()
+                       });
+
+                       this.updateMessageAtIndex(messageIndex, {
+                               content: newContent,
+                               timestamp: Date.now()
+                       });
+
+                       // Check if first user message for title update
+                       const allMessages = await DatabaseStore.getConversationMessages(this.activeConversation.id);
+                       const rootMessage = allMessages.find((m) => m.type === 'root' && m.parent === null);
+                       const isFirstUserMessage =
+                               rootMessage && messageToEdit.parent === rootMessage.id && messageToEdit.role === 'user';
+
+                       if (isFirstUserMessage && newContent.trim()) {
+                               await this.updateConversationTitleWithConfirmation(
+                                       this.activeConversation.id,
+                                       newContent.trim(),
+                                       this.titleUpdateConfirmationCallback
+                               );
+                       }
+
+                       this.updateConversationTimestamp();
+               } catch (error) {
+                       console.error('Failed to edit user message:', error);
+               }
+       }
+
        /**
         * Edits a message by creating a new branch with the edited content
         * @param messageId - The ID of the message to edit
@@ -1696,6 +1763,200 @@ class ChatStore {
                }
        }
 
+       /**
+        * Continues generation for an existing assistant message
+        * @param messageId - The ID of the assistant message to continue
+        */
+       async continueAssistantMessage(messageId: string): Promise<void> {
+               if (!this.activeConversation || this.isLoading) return;
+
+               try {
+                       const messageIndex = this.findMessageIndex(messageId);
+                       if (messageIndex === -1) {
+                               console.error('Message not found for continuation');
+                               return;
+                       }
+
+                       const messageToContinue = this.activeMessages[messageIndex];
+                       if (messageToContinue.role !== 'assistant') {
+                               console.error('Only assistant messages can be continued');
+                               return;
+                       }
+
+                       // Race condition protection: Check if this specific conversation is already loading
+                       // This prevents multiple rapid clicks on "Continue" from creating concurrent operations
+                       if (this.isConversationLoading(this.activeConversation.id)) {
+                               console.warn('Continuation already in progress for this conversation');
+                               return;
+                       }
+
+                       this.errorDialogState = null;
+                       this.setConversationLoading(this.activeConversation.id, true);
+                       this.clearConversationStreaming(this.activeConversation.id);
+
+                       // IMPORTANT: Fetch the latest content from the database to ensure we have
+                       // the most up-to-date content, especially after a stopped generation
+                       // This prevents issues where the in-memory state might be stale
+                       const allMessages = await DatabaseStore.getConversationMessages(this.activeConversation.id);
+                       const dbMessage = allMessages.find((m) => m.id === messageId);
+
+                       if (!dbMessage) {
+                               console.error('Message not found in database for continuation');
+                               this.setConversationLoading(this.activeConversation.id, false);
+
+                               return;
+                       }
+
+                       // Use content from database as the source of truth
+                       const originalContent = dbMessage.content;
+                       const originalThinking = dbMessage.thinking || '';
+
+                       // Get conversation context up to (but not including) the message to continue
+                       const conversationContext = this.activeMessages.slice(0, messageIndex);
+
+                       const contextWithContinue = [
+                               ...conversationContext.map((msg) => {
+                                       if ('id' in msg && 'convId' in msg && 'timestamp' in msg) {
+                                               return msg as DatabaseMessage & { extra?: DatabaseMessageExtra[] };
+                                       }
+                                       return msg as ApiChatMessageData;
+                               }),
+                               {
+                                       role: 'assistant' as const,
+                                       content: originalContent
+                               }
+                       ];
+
+                       let appendedContent = '';
+                       let appendedThinking = '';
+                       let hasReceivedContent = false;
+
+                       await chatService.sendMessage(
+                               contextWithContinue,
+                               {
+                                       ...this.getApiOptions(),
+
+                                       onChunk: (chunk: string) => {
+                                               hasReceivedContent = true;
+                                               appendedContent += chunk;
+                                               // Preserve originalContent exactly as-is, including any trailing whitespace
+                                               // The concatenation naturally preserves any whitespace at the end of originalContent
+                                               const fullContent = originalContent + appendedContent;
+
+                                               this.setConversationStreaming(
+                                                       messageToContinue.convId,
+                                                       fullContent,
+                                                       messageToContinue.id
+                                               );
+
+                                               this.updateMessageAtIndex(messageIndex, {
+                                                       content: fullContent
+                                               });
+                                       },
+
+                                       onReasoningChunk: (reasoningChunk: string) => {
+                                               hasReceivedContent = true;
+                                               appendedThinking += reasoningChunk;
+
+                                               const fullThinking = originalThinking + appendedThinking;
+
+                                               this.updateMessageAtIndex(messageIndex, {
+                                                       thinking: fullThinking
+                                               });
+                                       },
+
+                                       onComplete: async (
+                                               finalContent?: string,
+                                               reasoningContent?: string,
+                                               timings?: ChatMessageTimings
+                                       ) => {
+                                               const fullContent = originalContent + (finalContent || appendedContent);
+                                               const fullThinking = originalThinking + (reasoningContent || appendedThinking);
+
+                                               const updateData: {
+                                                       content: string;
+                                                       thinking: string;
+                                                       timestamp: number;
+                                                       timings?: ChatMessageTimings;
+                                               } = {
+                                                       content: fullContent,
+                                                       thinking: fullThinking,
+                                                       timestamp: Date.now(),
+                                                       timings: timings
+                                               };
+
+                                               await DatabaseStore.updateMessage(messageToContinue.id, updateData);
+
+                                               this.updateMessageAtIndex(messageIndex, updateData);
+
+                                               this.updateConversationTimestamp();
+
+                                               this.setConversationLoading(messageToContinue.convId, false);
+                                               this.clearConversationStreaming(messageToContinue.convId);
+                                               slotsService.clearConversationState(messageToContinue.convId);
+                                       },
+
+                                       onError: async (error: Error) => {
+                                               if (this.isAbortError(error)) {
+                                                       // User cancelled - save partial continuation if any content was received
+                                                       if (hasReceivedContent && appendedContent) {
+                                                               const partialContent = originalContent + appendedContent;
+                                                               const partialThinking = originalThinking + appendedThinking;
+
+                                                               await DatabaseStore.updateMessage(messageToContinue.id, {
+                                                                       content: partialContent,
+                                                                       thinking: partialThinking,
+                                                                       timestamp: Date.now()
+                                                               });
+
+                                                               this.updateMessageAtIndex(messageIndex, {
+                                                                       content: partialContent,
+                                                                       thinking: partialThinking,
+                                                                       timestamp: Date.now()
+                                                               });
+                                                       }
+
+                                                       this.setConversationLoading(messageToContinue.convId, false);
+                                                       this.clearConversationStreaming(messageToContinue.convId);
+                                                       slotsService.clearConversationState(messageToContinue.convId);
+
+                                                       return;
+                                               }
+
+                                               // Non-abort error - rollback to original content
+                                               console.error('Continue generation error:', error);
+
+                                               // Rollback: Restore original content in UI
+                                               this.updateMessageAtIndex(messageIndex, {
+                                                       content: originalContent,
+                                                       thinking: originalThinking
+                                               });
+
+                                               // Ensure database has original content (in case of partial writes)
+                                               await DatabaseStore.updateMessage(messageToContinue.id, {
+                                                       content: originalContent,
+                                                       thinking: originalThinking
+                                               });
+
+                                               this.setConversationLoading(messageToContinue.convId, false);
+                                               this.clearConversationStreaming(messageToContinue.convId);
+                                               slotsService.clearConversationState(messageToContinue.convId);
+
+                                               const dialogType = error.name === 'TimeoutError' ? 'timeout' : 'server';
+                                               this.showErrorDialog(dialogType, error.message);
+                                       }
+                               },
+                               messageToContinue.convId
+                       );
+               } catch (error) {
+                       if (this.isAbortError(error)) return;
+                       console.error('Failed to continue message:', error);
+                       if (this.activeConversation) {
+                               this.setConversationLoading(this.activeConversation.id, false);
+                       }
+               }
+       }
+
        /**
         * Public methods for accessing per-conversation states
         */
@@ -1743,8 +2004,11 @@ export const refreshActiveMessages = chatStore.refreshActiveMessages.bind(chatSt
 export const navigateToSibling = chatStore.navigateToSibling.bind(chatStore);
 export const editAssistantMessage = chatStore.editAssistantMessage.bind(chatStore);
 export const editMessageWithBranching = chatStore.editMessageWithBranching.bind(chatStore);
+export const editUserMessagePreserveResponses =
+       chatStore.editUserMessagePreserveResponses.bind(chatStore);
 export const regenerateMessageWithBranching =
        chatStore.regenerateMessageWithBranching.bind(chatStore);
+export const continueAssistantMessage = chatStore.continueAssistantMessage.bind(chatStore);
 export const deleteMessage = chatStore.deleteMessage.bind(chatStore);
 export const getDeletionInfo = chatStore.getDeletionInfo.bind(chatStore);
 export const updateConversationName = chatStore.updateConversationName.bind(chatStore);
index b85b0597d0068e0c7757144bda3087c5972d2e86..b47842b66e619c279907996980aaf35ac85ad9c5 100644 (file)
@@ -7,6 +7,7 @@ export interface SettingsFieldConfig {
        key: string;
        label: string;
        type: 'input' | 'textarea' | 'checkbox' | 'select';
+       isExperimental?: boolean;
        help?: string;
        options?: Array<{ value: string; label: string; icon?: typeof import('@lucide/svelte').Icon }>;
 }