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:
parent
07b0e7a5ac
commit
99c53d6558
Binary file not shown.
|
|
@ -25,3 +25,4 @@ vite.config.ts.timestamp-*
|
|||
|
||||
*storybook.log
|
||||
storybook-static
|
||||
*.code-workspace
|
||||
|
|
@ -2109,9 +2109,9 @@
|
|||
}
|
||||
},
|
||||
"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": {
|
||||
|
|
@ -5087,9 +5087,9 @@
|
|||
"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": {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -26,9 +28,11 @@
|
|||
class: className = '',
|
||||
message,
|
||||
onCopy,
|
||||
onContinueAssistantMessage,
|
||||
onDelete,
|
||||
onEditWithBranching,
|
||||
onEditWithReplacement,
|
||||
onEditUserMessagePreserveResponses,
|
||||
onNavigateToSibling,
|
||||
onRegenerateWithBranching,
|
||||
siblingInfo = null
|
||||
|
|
@ -133,17 +137,33 @@
|
|||
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;
|
||||
}
|
||||
|
|
@ -166,6 +186,7 @@
|
|||
onEditedContentChange={handleEditedContentChange}
|
||||
{onNavigateToSibling}
|
||||
onSaveEdit={handleSaveEdit}
|
||||
onSaveEditOnly={handleSaveEditOnly}
|
||||
onShowDeleteDialogChange={handleShowDeleteDialogChange}
|
||||
{showDeleteDialog}
|
||||
{siblingInfo}
|
||||
|
|
@ -181,6 +202,7 @@
|
|||
messageContent={message.content}
|
||||
onCancelEdit={handleCancelEdit}
|
||||
onConfirmDelete={handleConfirmDelete}
|
||||
onContinue={handleContinue}
|
||||
onCopy={handleCopy}
|
||||
onDelete={handleDelete}
|
||||
onEdit={handleEdit}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -69,6 +71,10 @@
|
|||
<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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -107,6 +110,12 @@
|
|||
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();
|
||||
|
|
@ -190,7 +199,10 @@
|
|||
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>
|
||||
|
||||
|
|
@ -335,6 +347,9 @@
|
|||
{onCopy}
|
||||
{onEdit}
|
||||
{onRegenerate}
|
||||
onContinue={currentConfig.enableContinueGeneration && !thinkingContent
|
||||
? onContinue
|
||||
: undefined}
|
||||
{onDelete}
|
||||
{onConfirmDelete}
|
||||
{onNavigateToSibling}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -58,6 +61,12 @@
|
|||
let messageElement: HTMLElement | undefined = $state();
|
||||
const currentConfig = config();
|
||||
|
||||
$effect(() => {
|
||||
if (isEditing && textareaElement) {
|
||||
autoResizeTextarea(textareaElement);
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!messageElement || !message.content.trim()) return;
|
||||
|
||||
|
|
@ -95,20 +104,34 @@
|
|||
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>
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
@ -93,6 +95,26 @@
|
|||
|
||||
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);
|
||||
|
||||
|
|
@ -110,7 +132,9 @@
|
|||
onNavigateToSibling={handleNavigateToSibling}
|
||||
onEditWithBranching={handleEditWithBranching}
|
||||
onEditWithReplacement={handleEditWithReplacement}
|
||||
onEditUserMessagePreserveResponses={handleEditUserMessagePreserveResponses}
|
||||
onRegenerateWithBranching={handleRegenerateWithBranching}
|
||||
onContinueAssistantMessage={handleContinueAssistantMessage}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -52,6 +52,11 @@
|
|||
{ value: 'dark', label: 'Dark', icon: Moon }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'pasteLongTextToFileLen',
|
||||
label: 'Paste long text to file length',
|
||||
type: 'input'
|
||||
},
|
||||
{
|
||||
key: 'showMessageStats',
|
||||
label: 'Show message generation statistics',
|
||||
|
|
@ -68,14 +73,15 @@
|
|||
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',
|
||||
|
|
@ -83,13 +89,13 @@
|
|||
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'
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
@ -55,8 +55,12 @@
|
|||
})()}
|
||||
|
||||
<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 />
|
||||
|
|
@ -97,8 +101,12 @@
|
|||
</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
|
||||
|
|
@ -129,8 +137,12 @@
|
|||
})()}
|
||||
|
||||
<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 />
|
||||
|
|
@ -214,9 +226,13 @@
|
|||
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]}
|
||||
|
|
|
|||
|
|
@ -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.'
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 }>;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue