diff --git a/tools/server/public/index.html.gz b/tools/server/public/index.html.gz index 9e44f03260..b5266edee7 100644 Binary files a/tools/server/public/index.html.gz and b/tools/server/public/index.html.gz differ diff --git a/tools/server/webui/docs/architecture/high-level-architecture-simplified.md b/tools/server/webui/docs/architecture/high-level-architecture-simplified.md index 50f2e1df0a..a6cb1e9c39 100644 --- a/tools/server/webui/docs/architecture/high-level-architecture-simplified.md +++ b/tools/server/webui/docs/architecture/high-level-architecture-simplified.md @@ -11,6 +11,8 @@ flowchart TB C_Screen["ChatScreen"] C_Form["ChatForm"] C_Messages["ChatMessages"] + C_Message["ChatMessage"] + C_MessageEditForm["ChatMessageEditForm"] C_ModelsSelector["ModelsSelector"] C_Settings["ChatSettings"] end @@ -54,7 +56,9 @@ flowchart TB %% Component hierarchy C_Screen --> C_Form & C_Messages & C_Settings - C_Form & C_Messages --> C_ModelsSelector + C_Messages --> C_Message + C_Message --> C_MessageEditForm + C_Form & C_MessageEditForm --> C_ModelsSelector %% Components → Hooks → Stores C_Form & C_Messages --> H1 & H2 @@ -93,7 +97,7 @@ flowchart TB classDef apiStyle fill:#e3f2fd,stroke:#1565c0,stroke-width:2px class R1,R2,RL routeStyle - class C_Sidebar,C_Screen,C_Form,C_Messages,C_ModelsSelector,C_Settings componentStyle + class C_Sidebar,C_Screen,C_Form,C_Messages,C_Message,C_MessageEditForm,C_ModelsSelector,C_Settings componentStyle class H1,H2 hookStyle class S1,S2,S3,S4,S5 storeStyle class SV1,SV2,SV3,SV4,SV5 serviceStyle diff --git a/tools/server/webui/docs/architecture/high-level-architecture.md b/tools/server/webui/docs/architecture/high-level-architecture.md index 730da10a59..c5ec4d6909 100644 --- a/tools/server/webui/docs/architecture/high-level-architecture.md +++ b/tools/server/webui/docs/architecture/high-level-architecture.md @@ -16,6 +16,8 @@ end C_Form["ChatForm"] C_Messages["ChatMessages"] C_Message["ChatMessage"] + C_MessageUser["ChatMessageUser"] + C_MessageEditForm["ChatMessageEditForm"] C_Attach["ChatAttachments"] C_ModelsSelector["ModelsSelector"] C_Settings["ChatSettings"] @@ -38,7 +40,7 @@ end S1Error["Error Handling:showErrorDialog()dismissErrorDialog()isAbortError()"] S1Msg["Message Operations:addMessage()sendMessage()updateMessage()deleteMessage()getDeletionInfo()"] S1Regen["Regeneration:regenerateMessage()regenerateMessageWithBranching()continueAssistantMessage()"] - S1Edit["Editing:editAssistantMessage()editUserMessagePreserveResponses()editMessageWithBranching()"] + S1Edit["Editing:editAssistantMessage()editUserMessagePreserveResponses()editMessageWithBranching()clearEditMode()isEditModeActive()getAddFilesHandler()setEditModeActive()"] S1Utils["Utilities:getApiOptions()parseTimingData()getOrCreateAbortController()getConversationModel()"] end subgraph S2["conversationsStore"] @@ -88,6 +90,10 @@ end RE7["getChatStreaming()"] RE8["getAllLoadingChats()"] RE9["getAllStreamingChats()"] + RE9a["isEditModeActive()"] + RE9b["getAddFilesHandler()"] + RE9c["setEditModeActive()"] + RE9d["clearEditMode()"] end subgraph ConvExports["conversationsStore"] RE10["conversations()"] @@ -182,7 +188,10 @@ end %% Component hierarchy C_Screen --> C_Form & C_Messages & C_Settings C_Messages --> C_Message - C_Message --> C_ModelsSelector + C_Message --> C_MessageUser + C_MessageUser --> C_MessageEditForm + C_MessageEditForm --> C_ModelsSelector + C_MessageEditForm --> C_Attach C_Form --> C_ModelsSelector C_Form --> C_Attach C_Message --> C_Attach @@ -190,6 +199,7 @@ end %% Components use Hooks C_Form --> H1 C_Message --> H1 & H2 + C_MessageEditForm --> H1 C_Screen --> H2 %% Hooks use Stores @@ -244,7 +254,7 @@ end classDef apiStyle fill:#e3f2fd,stroke:#1565c0,stroke-width:2px class R1,R2,RL routeStyle - class C_Sidebar,C_Screen,C_Form,C_Messages,C_Message componentStyle + class C_Sidebar,C_Screen,C_Form,C_Messages,C_Message,C_MessageUser,C_MessageEditForm componentStyle class C_ModelsSelector,C_Settings componentStyle class C_Attach componentStyle class H1,H2,H3 methodStyle diff --git a/tools/server/webui/package-lock.json b/tools/server/webui/package-lock.json index 0d1a03aca3..6fa9d39c71 100644 --- a/tools/server/webui/package-lock.json +++ b/tools/server/webui/package-lock.json @@ -25,7 +25,7 @@ "@chromatic-com/storybook": "^4.1.2", "@eslint/compat": "^1.2.5", "@eslint/js": "^9.18.0", - "@internationalized/date": "^3.8.2", + "@internationalized/date": "^3.10.1", "@lucide/svelte": "^0.515.0", "@playwright/test": "^1.49.1", "@storybook/addon-a11y": "^10.0.7", @@ -862,9 +862,9 @@ } }, "node_modules/@internationalized/date": { - "version": "3.8.2", - "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.8.2.tgz", - "integrity": "sha512-/wENk7CbvLbkUvX1tu0mwq49CVkkWpkXubGel6birjRPyo6uQ4nQpnq5xZu823zRCwwn82zgHrvgF1vZyvmVgA==", + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.10.1.tgz", + "integrity": "sha512-oJrXtQiAXLvT9clCf1K4kxp3eKsQhIaZqxEyowkBcsvZDdZkbWrVmnGknxs5flTD0VGsxrxKgBCZty1EzoiMzA==", "dev": true, "license": "Apache-2.0", "dependencies": { diff --git a/tools/server/webui/package.json b/tools/server/webui/package.json index 1c970ae7a8..1a8c273749 100644 --- a/tools/server/webui/package.json +++ b/tools/server/webui/package.json @@ -26,7 +26,7 @@ "@chromatic-com/storybook": "^4.1.2", "@eslint/compat": "^1.2.5", "@eslint/js": "^9.18.0", - "@internationalized/date": "^3.8.2", + "@internationalized/date": "^3.10.1", "@lucide/svelte": "^0.515.0", "@playwright/test": "^1.49.1", "@storybook/addon-a11y": "^10.0.7", diff --git a/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatForm.svelte b/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatForm.svelte index 3ad14ed3ab..fd2f7f60e5 100644 --- a/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatForm.svelte +++ b/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatForm.svelte @@ -8,6 +8,7 @@ ChatFormTextarea } from '$lib/components/app'; import { INPUT_CLASSES } from '$lib/constants/input-classes'; + import { SETTING_CONFIG_DEFAULT } from '$lib/constants/settings-config'; import { config } from '$lib/stores/settings.svelte'; import { modelsStore, modelOptions, selectedModelId } from '$lib/stores/models.svelte'; import { isRouterMode } from '$lib/stores/server.svelte'; @@ -66,7 +67,7 @@ let message = $state(''); let pasteLongTextToFileLength = $derived.by(() => { const n = Number(currentConfig.pasteLongTextToFileLen); - return Number.isNaN(n) ? 2500 : n; + return Number.isNaN(n) ? Number(SETTING_CONFIG_DEFAULT.pasteLongTextToFileLen) : n; }); let previousIsLoading = $state(isLoading); let recordingSupported = $state(false); diff --git a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessage.svelte b/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessage.svelte index 0969a937ed..220276fc9e 100644 --- a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessage.svelte +++ b/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessage.svelte @@ -12,13 +12,21 @@ onCopy?: (message: DatabaseMessage) => void; onContinueAssistantMessage?: (message: DatabaseMessage) => void; onDelete?: (message: DatabaseMessage) => void; - onEditWithBranching?: (message: DatabaseMessage, newContent: string) => void; + onEditWithBranching?: ( + message: DatabaseMessage, + newContent: string, + newExtras?: DatabaseMessageExtra[] + ) => void; onEditWithReplacement?: ( message: DatabaseMessage, newContent: string, shouldBranch: boolean ) => void; - onEditUserMessagePreserveResponses?: (message: DatabaseMessage, newContent: string) => void; + onEditUserMessagePreserveResponses?: ( + message: DatabaseMessage, + newContent: string, + newExtras?: DatabaseMessageExtra[] + ) => void; onNavigateToSibling?: (siblingId: string) => void; onRegenerateWithBranching?: (message: DatabaseMessage, modelOverride?: string) => void; siblingInfo?: ChatMessageSiblingInfo | null; @@ -45,6 +53,8 @@ messageTypes: string[]; } | null>(null); let editedContent = $state(message.content); + let editedExtras = $state(message.extra ? [...message.extra] : []); + let editedUploadedFiles = $state([]); let isEditing = $state(false); let showDeleteDialog = $state(false); let shouldBranchAfterEdit = $state(false); @@ -85,6 +95,16 @@ function handleCancelEdit() { isEditing = false; editedContent = message.content; + editedExtras = message.extra ? [...message.extra] : []; + editedUploadedFiles = []; + } + + function handleEditedExtrasChange(extras: DatabaseMessageExtra[]) { + editedExtras = extras; + } + + function handleEditedUploadedFilesChange(files: ChatUploadedFile[]) { + editedUploadedFiles = files; } async function handleCopy() { @@ -107,6 +127,8 @@ function handleEdit() { isEditing = true; editedContent = message.content; + editedExtras = message.extra ? [...message.extra] : []; + editedUploadedFiles = []; setTimeout(() => { if (textareaElement) { @@ -143,9 +165,10 @@ onContinueAssistantMessage?.(message); } - function handleSaveEdit() { + async function handleSaveEdit() { if (message.role === 'user' || message.role === 'system') { - onEditWithBranching?.(message, editedContent.trim()); + const finalExtras = await getMergedExtras(); + onEditWithBranching?.(message, editedContent.trim(), finalExtras); } else { // For assistant messages, preserve exact content including trailing whitespace // This is important for the Continue feature to work properly @@ -154,15 +177,30 @@ isEditing = false; shouldBranchAfterEdit = false; + editedUploadedFiles = []; } - function handleSaveEditOnly() { + async function handleSaveEditOnly() { if (message.role === 'user') { // For user messages, trim to avoid accidental whitespace - onEditUserMessagePreserveResponses?.(message, editedContent.trim()); + const finalExtras = await getMergedExtras(); + onEditUserMessagePreserveResponses?.(message, editedContent.trim(), finalExtras); } isEditing = false; + editedUploadedFiles = []; + } + + async function getMergedExtras(): Promise { + if (editedUploadedFiles.length === 0) { + return editedExtras; + } + + const { parseFilesToMessageExtras } = await import('$lib/utils/browser-only'); + const result = await parseFilesToMessageExtras(editedUploadedFiles); + const newExtras = result?.extras || []; + + return [...editedExtras, ...newExtras]; } function handleShowDeleteDialogChange(show: boolean) { @@ -197,6 +235,8 @@ class={className} {deletionInfo} {editedContent} + {editedExtras} + {editedUploadedFiles} {isEditing} {message} onCancelEdit={handleCancelEdit} @@ -206,6 +246,8 @@ onEdit={handleEdit} onEditKeydown={handleEditKeydown} onEditedContentChange={handleEditedContentChange} + onEditedExtrasChange={handleEditedExtrasChange} + onEditedUploadedFilesChange={handleEditedUploadedFilesChange} {onNavigateToSibling} onSaveEdit={handleSaveEdit} onSaveEditOnly={handleSaveEditOnly} diff --git a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageEditForm.svelte b/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageEditForm.svelte new file mode 100644 index 0000000000..f812ea2fd9 --- /dev/null +++ b/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageEditForm.svelte @@ -0,0 +1,391 @@ + + + + + + + + { + if (fileId.startsWith('attachment-')) { + const index = parseInt(fileId.replace('attachment-', ''), 10); + if (!isNaN(index) && index >= 0 && index < editedExtras.length) { + handleRemoveExistingAttachment(index); + } + } else { + handleRemoveUploadedFile(fileId); + } + }} + limitToSingleRow + class="py-5" + style="scroll-padding: 1rem;" + /> + + + { + autoResizeTextarea(e.currentTarget); + onEditedContentChange(e.currentTarget.value); + }} + onpaste={handlePaste} + placeholder="Edit your message..." + > + + + fileInputElement?.click()} + type="button" + title="Add attachment" + > + Attach files + + + + + + + {#if isRouter} + + {/if} + + + {saveWithoutRegenerate ? 'Save' : 'Send'} + + + + + + + + + {#if showSaveOnlyOption && onSaveEditOnly} + + + + + Update without re-sending + + + {:else} + + {/if} + + + + + Cancel + + + + (showDiscardDialog = false)} +/> diff --git a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageUser.svelte b/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageUser.svelte index 3d2b8dd35b..041c6bd251 100644 --- a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageUser.svelte +++ b/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageUser.svelte @@ -1,18 +1,17 @@ + + + + diff --git a/tools/server/webui/src/lib/stores/chat.svelte.ts b/tools/server/webui/src/lib/stores/chat.svelte.ts index e0431ee643..0108894524 100644 --- a/tools/server/webui/src/lib/stores/chat.svelte.ts +++ b/tools/server/webui/src/lib/stores/chat.svelte.ts @@ -74,6 +74,8 @@ class ChatStore { private processingStates = new SvelteMap(); private activeConversationId = $state(null); private isStreamingActive = $state(false); + private isEditModeActive = $state(false); + private addFilesHandler: ((files: File[]) => void) | null = $state(null); // ───────────────────────────────────────────────────────────────────────────── // Loading State @@ -965,230 +967,9 @@ class ChatStore { // Editing // ───────────────────────────────────────────────────────────────────────────── - async editAssistantMessage( - messageId: string, - newContent: string, - shouldBranch: boolean - ): Promise { - const activeConv = conversationsStore.activeConversation; - if (!activeConv || this.isLoading) return; - - const result = this.getMessageByIdWithRole(messageId, 'assistant'); - if (!result) return; - const { message: msg, index: idx } = result; - - try { - if (shouldBranch) { - const newMessage = await DatabaseService.createMessageBranch( - { - convId: msg.convId, - type: msg.type, - timestamp: Date.now(), - role: msg.role, - content: newContent, - thinking: msg.thinking || '', - toolCalls: msg.toolCalls || '', - children: [], - model: msg.model - }, - msg.parent! - ); - await conversationsStore.updateCurrentNode(newMessage.id); - } else { - await DatabaseService.updateMessage(msg.id, { content: newContent, timestamp: Date.now() }); - await conversationsStore.updateCurrentNode(msg.id); - conversationsStore.updateMessageAtIndex(idx, { - content: newContent, - timestamp: Date.now() - }); - } - conversationsStore.updateConversationTimestamp(); - await conversationsStore.refreshActiveMessages(); - } catch (error) { - console.error('Failed to edit assistant message:', error); - } - } - - async editUserMessagePreserveResponses(messageId: string, newContent: string): Promise { - const activeConv = conversationsStore.activeConversation; - if (!activeConv) return; - - const result = this.getMessageByIdWithRole(messageId, 'user'); - if (!result) return; - const { message: msg, index: idx } = result; - - try { - await DatabaseService.updateMessage(messageId, { - content: newContent, - timestamp: Date.now() - }); - conversationsStore.updateMessageAtIndex(idx, { content: newContent, timestamp: Date.now() }); - - const allMessages = await conversationsStore.getConversationMessages(activeConv.id); - const rootMessage = allMessages.find((m) => m.type === 'root' && m.parent === null); - - if (rootMessage && msg.parent === rootMessage.id && newContent.trim()) { - await conversationsStore.updateConversationTitleWithConfirmation( - activeConv.id, - newContent.trim(), - conversationsStore.titleUpdateConfirmationCallback - ); - } - conversationsStore.updateConversationTimestamp(); - } catch (error) { - console.error('Failed to edit user message:', error); - } - } - - async editMessageWithBranching(messageId: string, newContent: string): Promise { - const activeConv = conversationsStore.activeConversation; - if (!activeConv || this.isLoading) return; - - let result = this.getMessageByIdWithRole(messageId, 'user'); - - if (!result) { - result = this.getMessageByIdWithRole(messageId, 'system'); - } - - if (!result) return; - const { message: msg } = result; - - try { - const allMessages = await conversationsStore.getConversationMessages(activeConv.id); - const rootMessage = allMessages.find((m) => m.type === 'root' && m.parent === null); - const isFirstUserMessage = - msg.role === 'user' && rootMessage && msg.parent === rootMessage.id; - - const parentId = msg.parent || rootMessage?.id; - if (!parentId) return; - - const newMessage = await DatabaseService.createMessageBranch( - { - convId: msg.convId, - type: msg.type, - timestamp: Date.now(), - role: msg.role, - content: newContent, - thinking: msg.thinking || '', - toolCalls: msg.toolCalls || '', - children: [], - extra: msg.extra ? JSON.parse(JSON.stringify(msg.extra)) : undefined, - model: msg.model - }, - parentId - ); - await conversationsStore.updateCurrentNode(newMessage.id); - conversationsStore.updateConversationTimestamp(); - - if (isFirstUserMessage && newContent.trim()) { - await conversationsStore.updateConversationTitleWithConfirmation( - activeConv.id, - newContent.trim(), - conversationsStore.titleUpdateConfirmationCallback - ); - } - await conversationsStore.refreshActiveMessages(); - - if (msg.role === 'user') { - await this.generateResponseForMessage(newMessage.id); - } - } catch (error) { - console.error('Failed to edit message with branching:', error); - } - } - - async regenerateMessageWithBranching(messageId: string, modelOverride?: string): Promise { - const activeConv = conversationsStore.activeConversation; - if (!activeConv || this.isLoading) return; - try { - const idx = conversationsStore.findMessageIndex(messageId); - if (idx === -1) return; - const msg = conversationsStore.activeMessages[idx]; - if (msg.role !== 'assistant') return; - - const allMessages = await conversationsStore.getConversationMessages(activeConv.id); - const parentMessage = allMessages.find((m) => m.id === msg.parent); - if (!parentMessage) return; - - this.setChatLoading(activeConv.id, true); - this.clearChatStreaming(activeConv.id); - - const newAssistantMessage = await DatabaseService.createMessageBranch( - { - convId: activeConv.id, - type: 'text', - timestamp: Date.now(), - role: 'assistant', - content: '', - thinking: '', - toolCalls: '', - children: [], - model: null - }, - parentMessage.id - ); - await conversationsStore.updateCurrentNode(newAssistantMessage.id); - conversationsStore.updateConversationTimestamp(); - await conversationsStore.refreshActiveMessages(); - - const conversationPath = filterByLeafNodeId( - allMessages, - parentMessage.id, - false - ) as DatabaseMessage[]; - // Use modelOverride if provided, otherwise use the original message's model - // If neither is available, don't pass model (will use global selection) - const modelToUse = modelOverride || msg.model || undefined; - await this.streamChatCompletion( - conversationPath, - newAssistantMessage, - undefined, - undefined, - modelToUse - ); - } catch (error) { - if (!this.isAbortError(error)) - console.error('Failed to regenerate message with branching:', error); - this.setChatLoading(activeConv?.id || '', false); - } - } - - private async generateResponseForMessage(userMessageId: string): Promise { - const activeConv = conversationsStore.activeConversation; - - if (!activeConv) return; - - this.errorDialogState = null; - this.setChatLoading(activeConv.id, true); - this.clearChatStreaming(activeConv.id); - - try { - const allMessages = await conversationsStore.getConversationMessages(activeConv.id); - const conversationPath = filterByLeafNodeId( - allMessages, - userMessageId, - false - ) as DatabaseMessage[]; - const assistantMessage = await DatabaseService.createMessageBranch( - { - convId: activeConv.id, - type: 'text', - timestamp: Date.now(), - role: 'assistant', - content: '', - thinking: '', - toolCalls: '', - children: [], - model: null - }, - userMessageId - ); - conversationsStore.addMessageToActive(assistantMessage); - await this.streamChatCompletion(conversationPath, assistantMessage); - } catch (error) { - console.error('Failed to generate response:', error); - this.setChatLoading(activeConv.id, false); - } + clearEditMode(): void { + this.isEditModeActive = false; + this.addFilesHandler = null; } async continueAssistantMessage(messageId: string): Promise { @@ -1340,19 +1121,284 @@ class ChatStore { } } - public isChatLoadingPublic(convId: string): boolean { - return this.isChatLoading(convId); + async editAssistantMessage( + messageId: string, + newContent: string, + shouldBranch: boolean + ): Promise { + const activeConv = conversationsStore.activeConversation; + if (!activeConv || this.isLoading) return; + + const result = this.getMessageByIdWithRole(messageId, 'assistant'); + if (!result) return; + const { message: msg, index: idx } = result; + + try { + if (shouldBranch) { + const newMessage = await DatabaseService.createMessageBranch( + { + convId: msg.convId, + type: msg.type, + timestamp: Date.now(), + role: msg.role, + content: newContent, + thinking: msg.thinking || '', + toolCalls: msg.toolCalls || '', + children: [], + model: msg.model + }, + msg.parent! + ); + await conversationsStore.updateCurrentNode(newMessage.id); + } else { + await DatabaseService.updateMessage(msg.id, { content: newContent }); + await conversationsStore.updateCurrentNode(msg.id); + conversationsStore.updateMessageAtIndex(idx, { + content: newContent + }); + } + conversationsStore.updateConversationTimestamp(); + await conversationsStore.refreshActiveMessages(); + } catch (error) { + console.error('Failed to edit assistant message:', error); + } } + + async editUserMessagePreserveResponses( + messageId: string, + newContent: string, + newExtras?: DatabaseMessageExtra[] + ): Promise { + const activeConv = conversationsStore.activeConversation; + if (!activeConv) return; + + const result = this.getMessageByIdWithRole(messageId, 'user'); + if (!result) return; + const { message: msg, index: idx } = result; + + try { + const updateData: Partial = { + content: newContent + }; + + // Update extras if provided (including empty array to clear attachments) + // Deep clone to avoid Proxy objects from Svelte reactivity + if (newExtras !== undefined) { + updateData.extra = JSON.parse(JSON.stringify(newExtras)); + } + + await DatabaseService.updateMessage(messageId, updateData); + conversationsStore.updateMessageAtIndex(idx, updateData); + + const allMessages = await conversationsStore.getConversationMessages(activeConv.id); + const rootMessage = allMessages.find((m) => m.type === 'root' && m.parent === null); + + if (rootMessage && msg.parent === rootMessage.id && newContent.trim()) { + await conversationsStore.updateConversationTitleWithConfirmation( + activeConv.id, + newContent.trim(), + conversationsStore.titleUpdateConfirmationCallback + ); + } + conversationsStore.updateConversationTimestamp(); + } catch (error) { + console.error('Failed to edit user message:', error); + } + } + + async editMessageWithBranching( + messageId: string, + newContent: string, + newExtras?: DatabaseMessageExtra[] + ): Promise { + const activeConv = conversationsStore.activeConversation; + if (!activeConv || this.isLoading) return; + + let result = this.getMessageByIdWithRole(messageId, 'user'); + + if (!result) { + result = this.getMessageByIdWithRole(messageId, 'system'); + } + + if (!result) return; + const { message: msg } = result; + + try { + const allMessages = await conversationsStore.getConversationMessages(activeConv.id); + const rootMessage = allMessages.find((m) => m.type === 'root' && m.parent === null); + const isFirstUserMessage = + msg.role === 'user' && rootMessage && msg.parent === rootMessage.id; + + const parentId = msg.parent || rootMessage?.id; + if (!parentId) return; + + // Use newExtras if provided, otherwise copy existing extras + // Deep clone to avoid Proxy objects from Svelte reactivity + const extrasToUse = + newExtras !== undefined + ? JSON.parse(JSON.stringify(newExtras)) + : msg.extra + ? JSON.parse(JSON.stringify(msg.extra)) + : undefined; + + const newMessage = await DatabaseService.createMessageBranch( + { + convId: msg.convId, + type: msg.type, + timestamp: Date.now(), + role: msg.role, + content: newContent, + thinking: msg.thinking || '', + toolCalls: msg.toolCalls || '', + children: [], + extra: extrasToUse, + model: msg.model + }, + parentId + ); + await conversationsStore.updateCurrentNode(newMessage.id); + conversationsStore.updateConversationTimestamp(); + + if (isFirstUserMessage && newContent.trim()) { + await conversationsStore.updateConversationTitleWithConfirmation( + activeConv.id, + newContent.trim(), + conversationsStore.titleUpdateConfirmationCallback + ); + } + await conversationsStore.refreshActiveMessages(); + + if (msg.role === 'user') { + await this.generateResponseForMessage(newMessage.id); + } + } catch (error) { + console.error('Failed to edit message with branching:', error); + } + } + + async regenerateMessageWithBranching(messageId: string, modelOverride?: string): Promise { + const activeConv = conversationsStore.activeConversation; + if (!activeConv || this.isLoading) return; + try { + const idx = conversationsStore.findMessageIndex(messageId); + if (idx === -1) return; + const msg = conversationsStore.activeMessages[idx]; + if (msg.role !== 'assistant') return; + + const allMessages = await conversationsStore.getConversationMessages(activeConv.id); + const parentMessage = allMessages.find((m) => m.id === msg.parent); + if (!parentMessage) return; + + this.setChatLoading(activeConv.id, true); + this.clearChatStreaming(activeConv.id); + + const newAssistantMessage = await DatabaseService.createMessageBranch( + { + convId: activeConv.id, + type: 'text', + timestamp: Date.now(), + role: 'assistant', + content: '', + thinking: '', + toolCalls: '', + children: [], + model: null + }, + parentMessage.id + ); + await conversationsStore.updateCurrentNode(newAssistantMessage.id); + conversationsStore.updateConversationTimestamp(); + await conversationsStore.refreshActiveMessages(); + + const conversationPath = filterByLeafNodeId( + allMessages, + parentMessage.id, + false + ) as DatabaseMessage[]; + // Use modelOverride if provided, otherwise use the original message's model + // If neither is available, don't pass model (will use global selection) + const modelToUse = modelOverride || msg.model || undefined; + await this.streamChatCompletion( + conversationPath, + newAssistantMessage, + undefined, + undefined, + modelToUse + ); + } catch (error) { + if (!this.isAbortError(error)) + console.error('Failed to regenerate message with branching:', error); + this.setChatLoading(activeConv?.id || '', false); + } + } + + private async generateResponseForMessage(userMessageId: string): Promise { + const activeConv = conversationsStore.activeConversation; + + if (!activeConv) return; + + this.errorDialogState = null; + this.setChatLoading(activeConv.id, true); + this.clearChatStreaming(activeConv.id); + + try { + const allMessages = await conversationsStore.getConversationMessages(activeConv.id); + const conversationPath = filterByLeafNodeId( + allMessages, + userMessageId, + false + ) as DatabaseMessage[]; + const assistantMessage = await DatabaseService.createMessageBranch( + { + convId: activeConv.id, + type: 'text', + timestamp: Date.now(), + role: 'assistant', + content: '', + thinking: '', + toolCalls: '', + children: [], + model: null + }, + userMessageId + ); + conversationsStore.addMessageToActive(assistantMessage); + await this.streamChatCompletion(conversationPath, assistantMessage); + } catch (error) { + console.error('Failed to generate response:', error); + this.setChatLoading(activeConv.id, false); + } + } + + getAddFilesHandler(): ((files: File[]) => void) | null { + return this.addFilesHandler; + } + + public getAllLoadingChats(): string[] { + return Array.from(this.chatLoadingStates.keys()); + } + + public getAllStreamingChats(): string[] { + return Array.from(this.chatStreamingStates.keys()); + } + public getChatStreamingPublic( convId: string ): { response: string; messageId: string } | undefined { return this.getChatStreaming(convId); } - public getAllLoadingChats(): string[] { - return Array.from(this.chatLoadingStates.keys()); + + public isChatLoadingPublic(convId: string): boolean { + return this.isChatLoading(convId); } - public getAllStreamingChats(): string[] { - return Array.from(this.chatStreamingStates.keys()); + + isEditing(): boolean { + return this.isEditModeActive; + } + + setEditModeActive(handler: (files: File[]) => void): void { + this.isEditModeActive = true; + this.addFilesHandler = handler; } // ───────────────────────────────────────────────────────────────────────────── @@ -1416,13 +1462,17 @@ class ChatStore { export const chatStore = new ChatStore(); -export const isLoading = () => chatStore.isLoading; +export const activeProcessingState = () => chatStore.activeProcessingState; +export const clearEditMode = () => chatStore.clearEditMode(); export const currentResponse = () => chatStore.currentResponse; export const errorDialog = () => chatStore.errorDialogState; -export const activeProcessingState = () => chatStore.activeProcessingState; -export const isChatStreaming = () => chatStore.isStreaming(); - -export const isChatLoading = (convId: string) => chatStore.isChatLoadingPublic(convId); -export const getChatStreaming = (convId: string) => chatStore.getChatStreamingPublic(convId); +export const getAddFilesHandler = () => chatStore.getAddFilesHandler(); export const getAllLoadingChats = () => chatStore.getAllLoadingChats(); export const getAllStreamingChats = () => chatStore.getAllStreamingChats(); +export const getChatStreaming = (convId: string) => chatStore.getChatStreamingPublic(convId); +export const isChatLoading = (convId: string) => chatStore.isChatLoadingPublic(convId); +export const isChatStreaming = () => chatStore.isStreaming(); +export const isEditing = () => chatStore.isEditing(); +export const isLoading = () => chatStore.isLoading; +export const setEditModeActive = (handler: (files: File[]) => void) => + chatStore.setEditModeActive(handler);