Ver Fonte

webui: Add a "Continue" Action for Assistant Message (#16971)

* 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
Aleksander Grygier há 1 mês atrás
pai
commit
99c53d6558

BIN
tools/server/public/index.html.gz


+ 1 - 0
tools/server/webui/.gitignore

@@ -25,3 +25,4 @@ vite.config.ts.timestamp-*
 
 
 *storybook.log
 *storybook.log
 storybook-static
 storybook-static
+*.code-workspace

+ 6 - 6
tools/server/webui/package-lock.json

@@ -2109,9 +2109,9 @@
 			}
 			}
 		},
 		},
 		"node_modules/@sveltejs/kit": {
 		"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,
 			"dev": true,
 			"license": "MIT",
 			"license": "MIT",
 			"dependencies": {
 			"dependencies": {
@@ -5087,9 +5087,9 @@
 			"license": "MIT"
 			"license": "MIT"
 		},
 		},
 		"node_modules/js-yaml": {
 		"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,
 			"dev": true,
 			"license": "MIT",
 			"license": "MIT",
 			"dependencies": {
 			"dependencies": {

+ 23 - 1
tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessage.svelte

@@ -10,6 +10,7 @@
 		class?: string;
 		class?: string;
 		message: DatabaseMessage;
 		message: DatabaseMessage;
 		onCopy?: (message: DatabaseMessage) => void;
 		onCopy?: (message: DatabaseMessage) => void;
+		onContinueAssistantMessage?: (message: DatabaseMessage) => void;
 		onDelete?: (message: DatabaseMessage) => void;
 		onDelete?: (message: DatabaseMessage) => void;
 		onEditWithBranching?: (message: DatabaseMessage, newContent: string) => void;
 		onEditWithBranching?: (message: DatabaseMessage, newContent: string) => void;
 		onEditWithReplacement?: (
 		onEditWithReplacement?: (
@@ -17,6 +18,7 @@
 			newContent: string,
 			newContent: string,
 			shouldBranch: boolean
 			shouldBranch: boolean
 		) => void;
 		) => void;
+		onEditUserMessagePreserveResponses?: (message: DatabaseMessage, newContent: string) => void;
 		onNavigateToSibling?: (siblingId: string) => void;
 		onNavigateToSibling?: (siblingId: string) => void;
 		onRegenerateWithBranching?: (message: DatabaseMessage) => void;
 		onRegenerateWithBranching?: (message: DatabaseMessage) => void;
 		siblingInfo?: ChatMessageSiblingInfo | null;
 		siblingInfo?: ChatMessageSiblingInfo | null;
@@ -26,9 +28,11 @@
 		class: className = '',
 		class: className = '',
 		message,
 		message,
 		onCopy,
 		onCopy,
+		onContinueAssistantMessage,
 		onDelete,
 		onDelete,
 		onEditWithBranching,
 		onEditWithBranching,
 		onEditWithReplacement,
 		onEditWithReplacement,
+		onEditUserMessagePreserveResponses,
 		onNavigateToSibling,
 		onNavigateToSibling,
 		onRegenerateWithBranching,
 		onRegenerateWithBranching,
 		siblingInfo = null
 		siblingInfo = null
@@ -133,17 +137,33 @@
 		onRegenerateWithBranching?.(message);
 		onRegenerateWithBranching?.(message);
 	}
 	}
 
 
+	function handleContinue() {
+		onContinueAssistantMessage?.(message);
+	}
+
 	function handleSaveEdit() {
 	function handleSaveEdit() {
 		if (message.role === 'user') {
 		if (message.role === 'user') {
+			// For user messages, trim to avoid accidental whitespace
 			onEditWithBranching?.(message, editedContent.trim());
 			onEditWithBranching?.(message, editedContent.trim());
 		} else {
 		} 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;
 		isEditing = false;
 		shouldBranchAfterEdit = 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) {
 	function handleShowDeleteDialogChange(show: boolean) {
 		showDeleteDialog = show;
 		showDeleteDialog = show;
 	}
 	}
@@ -166,6 +186,7 @@
 		onEditedContentChange={handleEditedContentChange}
 		onEditedContentChange={handleEditedContentChange}
 		{onNavigateToSibling}
 		{onNavigateToSibling}
 		onSaveEdit={handleSaveEdit}
 		onSaveEdit={handleSaveEdit}
+		onSaveEditOnly={handleSaveEditOnly}
 		onShowDeleteDialogChange={handleShowDeleteDialogChange}
 		onShowDeleteDialogChange={handleShowDeleteDialogChange}
 		{showDeleteDialog}
 		{showDeleteDialog}
 		{siblingInfo}
 		{siblingInfo}
@@ -181,6 +202,7 @@
 		messageContent={message.content}
 		messageContent={message.content}
 		onCancelEdit={handleCancelEdit}
 		onCancelEdit={handleCancelEdit}
 		onConfirmDelete={handleConfirmDelete}
 		onConfirmDelete={handleConfirmDelete}
+		onContinue={handleContinue}
 		onCopy={handleCopy}
 		onCopy={handleCopy}
 		onDelete={handleDelete}
 		onDelete={handleDelete}
 		onEdit={handleEdit}
 		onEdit={handleEdit}

+ 7 - 1
tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageActions.svelte

@@ -1,5 +1,5 @@
 <script lang="ts">
 <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 { ActionButton, ConfirmationDialog } from '$lib/components/app';
 	import ChatMessageBranchingControls from './ChatMessageBranchingControls.svelte';
 	import ChatMessageBranchingControls from './ChatMessageBranchingControls.svelte';
 
 
@@ -18,6 +18,7 @@
 		onCopy: () => void;
 		onCopy: () => void;
 		onEdit?: () => void;
 		onEdit?: () => void;
 		onRegenerate?: () => void;
 		onRegenerate?: () => void;
+		onContinue?: () => void;
 		onDelete: () => void;
 		onDelete: () => void;
 		onConfirmDelete: () => void;
 		onConfirmDelete: () => void;
 		onNavigateToSibling?: (siblingId: string) => void;
 		onNavigateToSibling?: (siblingId: string) => void;
@@ -31,6 +32,7 @@
 		onCopy,
 		onCopy,
 		onEdit,
 		onEdit,
 		onConfirmDelete,
 		onConfirmDelete,
+		onContinue,
 		onDelete,
 		onDelete,
 		onNavigateToSibling,
 		onNavigateToSibling,
 		onShowDeleteDialogChange,
 		onShowDeleteDialogChange,
@@ -69,6 +71,10 @@
 				<ActionButton icon={RefreshCw} tooltip="Regenerate" onclick={onRegenerate} />
 				<ActionButton icon={RefreshCw} tooltip="Regenerate" onclick={onRegenerate} />
 			{/if}
 			{/if}
 
 
+			{#if role === 'assistant' && onContinue}
+				<ActionButton icon={ArrowRight} tooltip="Continue" onclick={onContinue} />
+			{/if}
+
 			<ActionButton icon={Trash2} tooltip="Delete" onclick={onDelete} />
 			<ActionButton icon={Trash2} tooltip="Delete" onclick={onDelete} />
 		</div>
 		</div>
 	</div>
 	</div>

+ 16 - 1
tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageAssistant.svelte

@@ -2,6 +2,7 @@
 	import { ChatMessageThinkingBlock, MarkdownContent } from '$lib/components/app';
 	import { ChatMessageThinkingBlock, MarkdownContent } from '$lib/components/app';
 	import { useProcessingState } from '$lib/hooks/use-processing-state.svelte';
 	import { useProcessingState } from '$lib/hooks/use-processing-state.svelte';
 	import { isLoading } from '$lib/stores/chat.svelte';
 	import { isLoading } from '$lib/stores/chat.svelte';
+	import autoResizeTextarea from '$lib/utils/autoresize-textarea';
 	import { fade } from 'svelte/transition';
 	import { fade } from 'svelte/transition';
 	import {
 	import {
 		Check,
 		Check,
@@ -39,6 +40,7 @@
 		onCancelEdit?: () => void;
 		onCancelEdit?: () => void;
 		onCopy: () => void;
 		onCopy: () => void;
 		onConfirmDelete: () => void;
 		onConfirmDelete: () => void;
+		onContinue?: () => void;
 		onDelete: () => void;
 		onDelete: () => void;
 		onEdit?: () => void;
 		onEdit?: () => void;
 		onEditKeydown?: (event: KeyboardEvent) => void;
 		onEditKeydown?: (event: KeyboardEvent) => void;
@@ -65,6 +67,7 @@
 		messageContent,
 		messageContent,
 		onCancelEdit,
 		onCancelEdit,
 		onConfirmDelete,
 		onConfirmDelete,
+		onContinue,
 		onCopy,
 		onCopy,
 		onDelete,
 		onDelete,
 		onEdit,
 		onEdit,
@@ -107,6 +110,12 @@
 		void copyToClipboard(model ?? '');
 		void copyToClipboard(model ?? '');
 	}
 	}
 
 
+	$effect(() => {
+		if (isEditing && textareaElement) {
+			autoResizeTextarea(textareaElement);
+		}
+	});
+
 	function formatToolCallBadge(toolCall: ApiChatCompletionToolCall, index: number) {
 	function formatToolCallBadge(toolCall: ApiChatCompletionToolCall, index: number) {
 		const callNumber = index + 1;
 		const callNumber = index + 1;
 		const functionName = toolCall.function?.name?.trim();
 		const functionName = toolCall.function?.name?.trim();
@@ -190,7 +199,10 @@
 				bind:value={editedContent}
 				bind:value={editedContent}
 				class="min-h-[50vh] w-full resize-y rounded-2xl px-3 py-2 text-sm {INPUT_CLASSES}"
 				class="min-h-[50vh] w-full resize-y rounded-2xl px-3 py-2 text-sm {INPUT_CLASSES}"
 				onkeydown={onEditKeydown}
 				onkeydown={onEditKeydown}
-				oninput={(e) => onEditedContentChange?.(e.currentTarget.value)}
+				oninput={(e) => {
+					autoResizeTextarea(e.currentTarget);
+					onEditedContentChange?.(e.currentTarget.value);
+				}}
 				placeholder="Edit assistant message..."
 				placeholder="Edit assistant message..."
 			></textarea>
 			></textarea>
 
 
@@ -335,6 +347,9 @@
 			{onCopy}
 			{onCopy}
 			{onEdit}
 			{onEdit}
 			{onRegenerate}
 			{onRegenerate}
+			onContinue={currentConfig.enableContinueGeneration && !thinkingContent
+				? onContinue
+				: undefined}
 			{onDelete}
 			{onDelete}
 			{onConfirmDelete}
 			{onConfirmDelete}
 			{onNavigateToSibling}
 			{onNavigateToSibling}

+ 29 - 6
tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageUser.svelte

@@ -1,10 +1,11 @@
 <script lang="ts">
 <script lang="ts">
-	import { Check, X } from '@lucide/svelte';
+	import { Check, X, Send } from '@lucide/svelte';
 	import { Card } from '$lib/components/ui/card';
 	import { Card } from '$lib/components/ui/card';
 	import { Button } from '$lib/components/ui/button';
 	import { Button } from '$lib/components/ui/button';
 	import { ChatAttachmentsList, MarkdownContent } from '$lib/components/app';
 	import { ChatAttachmentsList, MarkdownContent } from '$lib/components/app';
 	import { INPUT_CLASSES } from '$lib/constants/input-classes';
 	import { INPUT_CLASSES } from '$lib/constants/input-classes';
 	import { config } from '$lib/stores/settings.svelte';
 	import { config } from '$lib/stores/settings.svelte';
+	import autoResizeTextarea from '$lib/utils/autoresize-textarea';
 	import ChatMessageActions from './ChatMessageActions.svelte';
 	import ChatMessageActions from './ChatMessageActions.svelte';
 
 
 	interface Props {
 	interface Props {
@@ -22,6 +23,7 @@
 		} | null;
 		} | null;
 		onCancelEdit: () => void;
 		onCancelEdit: () => void;
 		onSaveEdit: () => void;
 		onSaveEdit: () => void;
+		onSaveEditOnly?: () => void;
 		onEditKeydown: (event: KeyboardEvent) => void;
 		onEditKeydown: (event: KeyboardEvent) => void;
 		onEditedContentChange: (content: string) => void;
 		onEditedContentChange: (content: string) => void;
 		onCopy: () => void;
 		onCopy: () => void;
@@ -43,6 +45,7 @@
 		deletionInfo,
 		deletionInfo,
 		onCancelEdit,
 		onCancelEdit,
 		onSaveEdit,
 		onSaveEdit,
+		onSaveEditOnly,
 		onEditKeydown,
 		onEditKeydown,
 		onEditedContentChange,
 		onEditedContentChange,
 		onCopy,
 		onCopy,
@@ -58,6 +61,12 @@
 	let messageElement: HTMLElement | undefined = $state();
 	let messageElement: HTMLElement | undefined = $state();
 	const currentConfig = config();
 	const currentConfig = config();
 
 
+	$effect(() => {
+		if (isEditing && textareaElement) {
+			autoResizeTextarea(textareaElement);
+		}
+	});
+
 	$effect(() => {
 	$effect(() => {
 		if (!messageElement || !message.content.trim()) return;
 		if (!messageElement || !message.content.trim()) return;
 
 
@@ -95,20 +104,34 @@
 				bind:value={editedContent}
 				bind:value={editedContent}
 				class="min-h-[60px] w-full resize-none rounded-2xl px-3 py-2 text-sm {INPUT_CLASSES}"
 				class="min-h-[60px] w-full resize-none rounded-2xl px-3 py-2 text-sm {INPUT_CLASSES}"
 				onkeydown={onEditKeydown}
 				onkeydown={onEditKeydown}
-				oninput={(e) => onEditedContentChange(e.currentTarget.value)}
+				oninput={(e) => {
+					autoResizeTextarea(e.currentTarget);
+					onEditedContentChange(e.currentTarget.value);
+				}}
 				placeholder="Edit your message..."
 				placeholder="Edit your message..."
 			></textarea>
 			></textarea>
 
 
 			<div class="mt-2 flex justify-end gap-2">
 			<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" />
 					<X class="mr-1 h-3 w-3" />
-
 					Cancel
 					Cancel
 				</Button>
 				</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
 					Send
 				</Button>
 				</Button>
 			</div>
 			</div>

+ 26 - 2
tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessages.svelte

@@ -3,10 +3,12 @@
 	import { DatabaseStore } from '$lib/stores/database';
 	import { DatabaseStore } from '$lib/stores/database';
 	import {
 	import {
 		activeConversation,
 		activeConversation,
+		continueAssistantMessage,
 		deleteMessage,
 		deleteMessage,
-		navigateToSibling,
-		editMessageWithBranching,
 		editAssistantMessage,
 		editAssistantMessage,
+		editMessageWithBranching,
+		editUserMessagePreserveResponses,
+		navigateToSibling,
 		regenerateMessageWithBranching
 		regenerateMessageWithBranching
 	} from '$lib/stores/chat.svelte';
 	} from '$lib/stores/chat.svelte';
 	import { getMessageSiblings } from '$lib/utils/branching';
 	import { getMessageSiblings } from '$lib/utils/branching';
@@ -93,6 +95,26 @@
 
 
 		refreshAllMessages();
 		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) {
 	async function handleDeleteMessage(message: DatabaseMessage) {
 		await deleteMessage(message.id);
 		await deleteMessage(message.id);
 
 
@@ -110,7 +132,9 @@
 			onNavigateToSibling={handleNavigateToSibling}
 			onNavigateToSibling={handleNavigateToSibling}
 			onEditWithBranching={handleEditWithBranching}
 			onEditWithBranching={handleEditWithBranching}
 			onEditWithReplacement={handleEditWithReplacement}
 			onEditWithReplacement={handleEditWithReplacement}
+			onEditUserMessagePreserveResponses={handleEditUserMessagePreserveResponses}
 			onRegenerateWithBranching={handleRegenerateWithBranching}
 			onRegenerateWithBranching={handleRegenerateWithBranching}
+			onContinueAssistantMessage={handleContinueAssistantMessage}
 		/>
 		/>
 	{/each}
 	{/each}
 </div>
 </div>

+ 15 - 9
tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsDialog.svelte

@@ -52,6 +52,11 @@
 						{ value: 'dark', label: 'Dark', icon: Moon }
 						{ value: 'dark', label: 'Dark', icon: Moon }
 					]
 					]
 				},
 				},
+				{
+					key: 'pasteLongTextToFileLen',
+					label: 'Paste long text to file length',
+					type: 'input'
+				},
 				{
 				{
 					key: 'showMessageStats',
 					key: 'showMessageStats',
 					label: 'Show message generation statistics',
 					label: 'Show message generation statistics',
@@ -68,14 +73,15 @@
 					type: 'checkbox'
 					type: 'checkbox'
 				},
 				},
 				{
 				{
-					key: 'askForTitleConfirmation',
-					label: 'Ask for confirmation before changing conversation title',
+					key: 'showModelInfo',
+					label: 'Show model information',
 					type: 'checkbox'
 					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',
 					key: 'pdfAsImage',
@@ -83,13 +89,13 @@
 					type: 'checkbox'
 					type: 'checkbox'
 				},
 				},
 				{
 				{
-					key: 'showModelInfo',
-					label: 'Show model information',
+					key: 'renderUserContentAsMarkdown',
+					label: 'Render user content as Markdown',
 					type: 'checkbox'
 					type: 'checkbox'
 				},
 				},
 				{
 				{
-					key: 'renderUserContentAsMarkdown',
-					label: 'Render user content as Markdown',
+					key: 'askForTitleConfirmation',
+					label: 'Ask for confirmation before changing conversation title',
 					type: 'checkbox'
 					type: 'checkbox'
 				}
 				}
 			]
 			]

+ 21 - 5
tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsFields.svelte

@@ -1,5 +1,5 @@
 <script lang="ts">
 <script lang="ts">
-	import { RotateCcw } from '@lucide/svelte';
+	import { RotateCcw, FlaskConical } from '@lucide/svelte';
 	import { Checkbox } from '$lib/components/ui/checkbox';
 	import { Checkbox } from '$lib/components/ui/checkbox';
 	import { Input } from '$lib/components/ui/input';
 	import { Input } from '$lib/components/ui/input';
 	import Label from '$lib/components/ui/label/label.svelte';
 	import Label from '$lib/components/ui/label/label.svelte';
@@ -55,8 +55,12 @@
 			})()}
 			})()}
 
 
 			<div class="flex items-center gap-2">
 			<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}
 					{field.label}
+
+					{#if field.isExperimental}
+						<FlaskConical class="h-3.5 w-3.5 text-muted-foreground" />
+					{/if}
 				</Label>
 				</Label>
 				{#if isCustomRealTime}
 				{#if isCustomRealTime}
 					<ParameterSourceIndicator />
 					<ParameterSourceIndicator />
@@ -97,8 +101,12 @@
 				</p>
 				</p>
 			{/if}
 			{/if}
 		{:else if field.type === 'textarea'}
 		{: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}
 				{field.label}
+
+				{#if field.isExperimental}
+					<FlaskConical class="h-3.5 w-3.5 text-muted-foreground" />
+				{/if}
 			</Label>
 			</Label>
 
 
 			<Textarea
 			<Textarea
@@ -129,8 +137,12 @@
 			})()}
 			})()}
 
 
 			<div class="flex items-center gap-2">
 			<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}
 					{field.label}
+
+					{#if field.isExperimental}
+						<FlaskConical class="h-3.5 w-3.5 text-muted-foreground" />
+					{/if}
 				</Label>
 				</Label>
 				{#if isCustomRealTime}
 				{#if isCustomRealTime}
 					<ParameterSourceIndicator />
 					<ParameterSourceIndicator />
@@ -214,9 +226,13 @@
 						for={field.key}
 						for={field.key}
 						class="cursor-pointer text-sm leading-none font-medium {isDisabled
 						class="cursor-pointer text-sm leading-none font-medium {isDisabled
 							? 'text-muted-foreground'
 							? 'text-muted-foreground'
-							: ''}"
+							: ''} flex items-center gap-1.5"
 					>
 					>
 						{field.label}
 						{field.label}
+
+						{#if field.isExperimental}
+							<FlaskConical class="h-3.5 w-3.5 text-muted-foreground" />
+						{/if}
 					</label>
 					</label>
 
 
 					{#if field.help || SETTING_CONFIG_INFO[field.key]}
 					{#if field.help || SETTING_CONFIG_INFO[field.key]}

+ 5 - 2
tools/server/webui/src/lib/constants/settings-config.ts

@@ -38,7 +38,8 @@ export const SETTING_CONFIG_DEFAULT: Record<string, string | number | boolean> =
 	max_tokens: -1,
 	max_tokens: -1,
 	custom: '', // custom json-stringified object
 	custom: '', // custom json-stringified object
 	// experimental features
 	// experimental features
-	pyInterpreterEnabled: false
+	pyInterpreterEnabled: false,
+	enableContinueGeneration: false
 };
 };
 
 
 export const SETTING_CONFIG_INFO: Record<string, string> = {
 export const SETTING_CONFIG_INFO: Record<string, string> = {
@@ -96,5 +97,7 @@ export const SETTING_CONFIG_INFO: Record<string, string> = {
 	modelSelectorEnabled:
 	modelSelectorEnabled:
 		'Enable the model selector in the chat input to choose the inference model. Sends the associated model field in API requests.',
 		'Enable the model selector in the chat input to choose the inference model. Sends the associated model field in API requests.',
 	pyInterpreterEnabled:
 	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.'
 };
 };

+ 0 - 14
tools/server/webui/src/lib/services/chat.ts

@@ -312,7 +312,6 @@ export class ChatService {
 		let aggregatedContent = '';
 		let aggregatedContent = '';
 		let fullReasoningContent = '';
 		let fullReasoningContent = '';
 		let aggregatedToolCalls: ApiChatCompletionToolCall[] = [];
 		let aggregatedToolCalls: ApiChatCompletionToolCall[] = [];
-		let hasReceivedData = false;
 		let lastTimings: ChatMessageTimings | undefined;
 		let lastTimings: ChatMessageTimings | undefined;
 		let streamFinished = false;
 		let streamFinished = false;
 		let modelEmitted = false;
 		let modelEmitted = false;
@@ -352,8 +351,6 @@ export class ChatService {
 				return;
 				return;
 			}
 			}
 
 
-			hasReceivedData = true;
-
 			if (!abortSignal?.aborted) {
 			if (!abortSignal?.aborted) {
 				onToolCallChunk?.(serializedToolCalls);
 				onToolCallChunk?.(serializedToolCalls);
 			}
 			}
@@ -415,7 +412,6 @@ export class ChatService {
 
 
 							if (content) {
 							if (content) {
 								finalizeOpenToolCallBatch();
 								finalizeOpenToolCallBatch();
-								hasReceivedData = true;
 								aggregatedContent += content;
 								aggregatedContent += content;
 								if (!abortSignal?.aborted) {
 								if (!abortSignal?.aborted) {
 									onChunk?.(content);
 									onChunk?.(content);
@@ -424,7 +420,6 @@ export class ChatService {
 
 
 							if (reasoningContent) {
 							if (reasoningContent) {
 								finalizeOpenToolCallBatch();
 								finalizeOpenToolCallBatch();
-								hasReceivedData = true;
 								fullReasoningContent += reasoningContent;
 								fullReasoningContent += reasoningContent;
 								if (!abortSignal?.aborted) {
 								if (!abortSignal?.aborted) {
 									onReasoningChunk?.(reasoningContent);
 									onReasoningChunk?.(reasoningContent);
@@ -446,15 +441,6 @@ export class ChatService {
 			if (streamFinished) {
 			if (streamFinished) {
 				finalizeOpenToolCallBatch();
 				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 =
 				const finalToolCalls =
 					aggregatedToolCalls.length > 0 ? JSON.stringify(aggregatedToolCalls) : undefined;
 					aggregatedToolCalls.length > 0 ? JSON.stringify(aggregatedToolCalls) : undefined;
 
 

+ 264 - 0
tools/server/webui/src/lib/stores/chat.svelte.ts

@@ -1486,6 +1486,10 @@ class ChatStore {
 					timestamp: Date.now()
 					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, {
 				this.updateMessageAtIndex(messageIndex, {
 					content: newContent,
 					content: newContent,
 					timestamp: Date.now()
 					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
 	 * Edits a message by creating a new branch with the edited content
 	 * @param messageId - The ID of the message to edit
 	 * @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
 	 * 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 navigateToSibling = chatStore.navigateToSibling.bind(chatStore);
 export const editAssistantMessage = chatStore.editAssistantMessage.bind(chatStore);
 export const editAssistantMessage = chatStore.editAssistantMessage.bind(chatStore);
 export const editMessageWithBranching = chatStore.editMessageWithBranching.bind(chatStore);
 export const editMessageWithBranching = chatStore.editMessageWithBranching.bind(chatStore);
+export const editUserMessagePreserveResponses =
+	chatStore.editUserMessagePreserveResponses.bind(chatStore);
 export const regenerateMessageWithBranching =
 export const regenerateMessageWithBranching =
 	chatStore.regenerateMessageWithBranching.bind(chatStore);
 	chatStore.regenerateMessageWithBranching.bind(chatStore);
+export const continueAssistantMessage = chatStore.continueAssistantMessage.bind(chatStore);
 export const deleteMessage = chatStore.deleteMessage.bind(chatStore);
 export const deleteMessage = chatStore.deleteMessage.bind(chatStore);
 export const getDeletionInfo = chatStore.getDeletionInfo.bind(chatStore);
 export const getDeletionInfo = chatStore.getDeletionInfo.bind(chatStore);
 export const updateConversationName = chatStore.updateConversationName.bind(chatStore);
 export const updateConversationName = chatStore.updateConversationName.bind(chatStore);

+ 1 - 0
tools/server/webui/src/lib/types/settings.d.ts

@@ -7,6 +7,7 @@ export interface SettingsFieldConfig {
 	key: string;
 	key: string;
 	label: string;
 	label: string;
 	type: 'input' | 'textarea' | 'checkbox' | 'select';
 	type: 'input' | 'textarea' | 'checkbox' | 'select';
+	isExperimental?: boolean;
 	help?: string;
 	help?: string;
 	options?: Array<{ value: string; label: string; icon?: typeof import('@lucide/svelte').Icon }>;
 	options?: Array<{ value: string; label: string; icon?: typeof import('@lucide/svelte').Icon }>;
 }
 }