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
This commit is contained in:
Aleksander Grygier 2025-11-19 14:39:50 +01:00 committed by GitHub
parent 07b0e7a5ac
commit 99c53d6558
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 414 additions and 47 deletions

Binary file not shown.

View File

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

View File

@ -2109,9 +2109,9 @@
} }
}, },
"node_modules/@sveltejs/kit": { "node_modules/@sveltejs/kit": {
"version": "2.48.4", "version": "2.48.5",
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.48.4.tgz", "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.48.5.tgz",
"integrity": "sha512-TGFX1pZUt9qqY20Cv5NyYvy0iLWHf2jXi8s+eCGsig7jQMdwZWKUFMR6TbvFNhfDSUpc1sH/Y5EHv20g3HHA3g==", "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", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {

View File

@ -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}

View File

@ -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>

View File

@ -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}

View File

@ -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"> {#if onSaveEditOnly}
<Check class="mr-1 h-3 w-3" /> <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>

View File

@ -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>

View File

@ -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', key: 'showModelInfo',
label: 'Ask for confirmation before changing conversation title', label: 'Show model information',
type: 'checkbox' type: 'checkbox'
}, },
{ {
key: 'pasteLongTextToFileLen', key: 'enableContinueGeneration',
label: 'Paste long text to file length', label: 'Enable "Continue" button',
type: 'input' type: 'checkbox',
isExperimental: true
}, },
{ {
key: 'pdfAsImage', key: 'pdfAsImage',
@ -83,13 +89,13 @@
type: 'checkbox' type: 'checkbox'
}, },
{ {
key: 'showModelInfo', key: 'renderUserContentAsMarkdown',
label: 'Show model information', label: 'Render user content as Markdown',
type: 'checkbox' type: 'checkbox'
}, },
{ {
key: 'renderUserContentAsMarkdown', key: 'askForTitleConfirmation',
label: 'Render user content as Markdown', label: 'Ask for confirmation before changing conversation title',
type: 'checkbox' type: 'checkbox'
} }
] ]

View File

@ -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]}

View File

@ -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.'
}; };

View File

@ -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;

View File

@ -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);

View File

@ -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 }>;
} }