webui: Add editing attachments in user messages (#18147)
* feat: Enable editing attachments in user messages * feat: Improvements for data handling & UI * docs: Update Architecture diagrams * chore: update webui build output * refactor: Exports * chore: update webui build output * feat: Add handling paste for Chat Message Edit Form * chore: update webui build output * refactor: Cleanup * chore: update webui build output
This commit is contained in:
parent
0a271d82b4
commit
acb73d8340
Binary file not shown.
|
|
@ -11,6 +11,8 @@ flowchart TB
|
||||||
C_Screen["ChatScreen"]
|
C_Screen["ChatScreen"]
|
||||||
C_Form["ChatForm"]
|
C_Form["ChatForm"]
|
||||||
C_Messages["ChatMessages"]
|
C_Messages["ChatMessages"]
|
||||||
|
C_Message["ChatMessage"]
|
||||||
|
C_MessageEditForm["ChatMessageEditForm"]
|
||||||
C_ModelsSelector["ModelsSelector"]
|
C_ModelsSelector["ModelsSelector"]
|
||||||
C_Settings["ChatSettings"]
|
C_Settings["ChatSettings"]
|
||||||
end
|
end
|
||||||
|
|
@ -54,7 +56,9 @@ flowchart TB
|
||||||
|
|
||||||
%% Component hierarchy
|
%% Component hierarchy
|
||||||
C_Screen --> C_Form & C_Messages & C_Settings
|
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
|
%% Components → Hooks → Stores
|
||||||
C_Form & C_Messages --> H1 & H2
|
C_Form & C_Messages --> H1 & H2
|
||||||
|
|
@ -93,7 +97,7 @@ flowchart TB
|
||||||
classDef apiStyle fill:#e3f2fd,stroke:#1565c0,stroke-width:2px
|
classDef apiStyle fill:#e3f2fd,stroke:#1565c0,stroke-width:2px
|
||||||
|
|
||||||
class R1,R2,RL routeStyle
|
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 H1,H2 hookStyle
|
||||||
class S1,S2,S3,S4,S5 storeStyle
|
class S1,S2,S3,S4,S5 storeStyle
|
||||||
class SV1,SV2,SV3,SV4,SV5 serviceStyle
|
class SV1,SV2,SV3,SV4,SV5 serviceStyle
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,8 @@ end
|
||||||
C_Form["ChatForm"]
|
C_Form["ChatForm"]
|
||||||
C_Messages["ChatMessages"]
|
C_Messages["ChatMessages"]
|
||||||
C_Message["ChatMessage"]
|
C_Message["ChatMessage"]
|
||||||
|
C_MessageUser["ChatMessageUser"]
|
||||||
|
C_MessageEditForm["ChatMessageEditForm"]
|
||||||
C_Attach["ChatAttachments"]
|
C_Attach["ChatAttachments"]
|
||||||
C_ModelsSelector["ModelsSelector"]
|
C_ModelsSelector["ModelsSelector"]
|
||||||
C_Settings["ChatSettings"]
|
C_Settings["ChatSettings"]
|
||||||
|
|
@ -38,7 +40,7 @@ end
|
||||||
S1Error["<b>Error Handling:</b><br/>showErrorDialog()<br/>dismissErrorDialog()<br/>isAbortError()"]
|
S1Error["<b>Error Handling:</b><br/>showErrorDialog()<br/>dismissErrorDialog()<br/>isAbortError()"]
|
||||||
S1Msg["<b>Message Operations:</b><br/>addMessage()<br/>sendMessage()<br/>updateMessage()<br/>deleteMessage()<br/>getDeletionInfo()"]
|
S1Msg["<b>Message Operations:</b><br/>addMessage()<br/>sendMessage()<br/>updateMessage()<br/>deleteMessage()<br/>getDeletionInfo()"]
|
||||||
S1Regen["<b>Regeneration:</b><br/>regenerateMessage()<br/>regenerateMessageWithBranching()<br/>continueAssistantMessage()"]
|
S1Regen["<b>Regeneration:</b><br/>regenerateMessage()<br/>regenerateMessageWithBranching()<br/>continueAssistantMessage()"]
|
||||||
S1Edit["<b>Editing:</b><br/>editAssistantMessage()<br/>editUserMessagePreserveResponses()<br/>editMessageWithBranching()"]
|
S1Edit["<b>Editing:</b><br/>editAssistantMessage()<br/>editUserMessagePreserveResponses()<br/>editMessageWithBranching()<br/>clearEditMode()<br/>isEditModeActive()<br/>getAddFilesHandler()<br/>setEditModeActive()"]
|
||||||
S1Utils["<b>Utilities:</b><br/>getApiOptions()<br/>parseTimingData()<br/>getOrCreateAbortController()<br/>getConversationModel()"]
|
S1Utils["<b>Utilities:</b><br/>getApiOptions()<br/>parseTimingData()<br/>getOrCreateAbortController()<br/>getConversationModel()"]
|
||||||
end
|
end
|
||||||
subgraph S2["conversationsStore"]
|
subgraph S2["conversationsStore"]
|
||||||
|
|
@ -88,6 +90,10 @@ end
|
||||||
RE7["getChatStreaming()"]
|
RE7["getChatStreaming()"]
|
||||||
RE8["getAllLoadingChats()"]
|
RE8["getAllLoadingChats()"]
|
||||||
RE9["getAllStreamingChats()"]
|
RE9["getAllStreamingChats()"]
|
||||||
|
RE9a["isEditModeActive()"]
|
||||||
|
RE9b["getAddFilesHandler()"]
|
||||||
|
RE9c["setEditModeActive()"]
|
||||||
|
RE9d["clearEditMode()"]
|
||||||
end
|
end
|
||||||
subgraph ConvExports["conversationsStore"]
|
subgraph ConvExports["conversationsStore"]
|
||||||
RE10["conversations()"]
|
RE10["conversations()"]
|
||||||
|
|
@ -182,7 +188,10 @@ end
|
||||||
%% Component hierarchy
|
%% Component hierarchy
|
||||||
C_Screen --> C_Form & C_Messages & C_Settings
|
C_Screen --> C_Form & C_Messages & C_Settings
|
||||||
C_Messages --> C_Message
|
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_ModelsSelector
|
||||||
C_Form --> C_Attach
|
C_Form --> C_Attach
|
||||||
C_Message --> C_Attach
|
C_Message --> C_Attach
|
||||||
|
|
@ -190,6 +199,7 @@ end
|
||||||
%% Components use Hooks
|
%% Components use Hooks
|
||||||
C_Form --> H1
|
C_Form --> H1
|
||||||
C_Message --> H1 & H2
|
C_Message --> H1 & H2
|
||||||
|
C_MessageEditForm --> H1
|
||||||
C_Screen --> H2
|
C_Screen --> H2
|
||||||
|
|
||||||
%% Hooks use Stores
|
%% Hooks use Stores
|
||||||
|
|
@ -244,7 +254,7 @@ end
|
||||||
classDef apiStyle fill:#e3f2fd,stroke:#1565c0,stroke-width:2px
|
classDef apiStyle fill:#e3f2fd,stroke:#1565c0,stroke-width:2px
|
||||||
|
|
||||||
class R1,R2,RL routeStyle
|
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_ModelsSelector,C_Settings componentStyle
|
||||||
class C_Attach componentStyle
|
class C_Attach componentStyle
|
||||||
class H1,H2,H3 methodStyle
|
class H1,H2,H3 methodStyle
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@
|
||||||
"@chromatic-com/storybook": "^4.1.2",
|
"@chromatic-com/storybook": "^4.1.2",
|
||||||
"@eslint/compat": "^1.2.5",
|
"@eslint/compat": "^1.2.5",
|
||||||
"@eslint/js": "^9.18.0",
|
"@eslint/js": "^9.18.0",
|
||||||
"@internationalized/date": "^3.8.2",
|
"@internationalized/date": "^3.10.1",
|
||||||
"@lucide/svelte": "^0.515.0",
|
"@lucide/svelte": "^0.515.0",
|
||||||
"@playwright/test": "^1.49.1",
|
"@playwright/test": "^1.49.1",
|
||||||
"@storybook/addon-a11y": "^10.0.7",
|
"@storybook/addon-a11y": "^10.0.7",
|
||||||
|
|
@ -862,9 +862,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@internationalized/date": {
|
"node_modules/@internationalized/date": {
|
||||||
"version": "3.8.2",
|
"version": "3.10.1",
|
||||||
"resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.8.2.tgz",
|
"resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.10.1.tgz",
|
||||||
"integrity": "sha512-/wENk7CbvLbkUvX1tu0mwq49CVkkWpkXubGel6birjRPyo6uQ4nQpnq5xZu823zRCwwn82zgHrvgF1vZyvmVgA==",
|
"integrity": "sha512-oJrXtQiAXLvT9clCf1K4kxp3eKsQhIaZqxEyowkBcsvZDdZkbWrVmnGknxs5flTD0VGsxrxKgBCZty1EzoiMzA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@
|
||||||
"@chromatic-com/storybook": "^4.1.2",
|
"@chromatic-com/storybook": "^4.1.2",
|
||||||
"@eslint/compat": "^1.2.5",
|
"@eslint/compat": "^1.2.5",
|
||||||
"@eslint/js": "^9.18.0",
|
"@eslint/js": "^9.18.0",
|
||||||
"@internationalized/date": "^3.8.2",
|
"@internationalized/date": "^3.10.1",
|
||||||
"@lucide/svelte": "^0.515.0",
|
"@lucide/svelte": "^0.515.0",
|
||||||
"@playwright/test": "^1.49.1",
|
"@playwright/test": "^1.49.1",
|
||||||
"@storybook/addon-a11y": "^10.0.7",
|
"@storybook/addon-a11y": "^10.0.7",
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
ChatFormTextarea
|
ChatFormTextarea
|
||||||
} from '$lib/components/app';
|
} from '$lib/components/app';
|
||||||
import { INPUT_CLASSES } from '$lib/constants/input-classes';
|
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 { config } from '$lib/stores/settings.svelte';
|
||||||
import { modelsStore, modelOptions, selectedModelId } from '$lib/stores/models.svelte';
|
import { modelsStore, modelOptions, selectedModelId } from '$lib/stores/models.svelte';
|
||||||
import { isRouterMode } from '$lib/stores/server.svelte';
|
import { isRouterMode } from '$lib/stores/server.svelte';
|
||||||
|
|
@ -66,7 +67,7 @@
|
||||||
let message = $state('');
|
let message = $state('');
|
||||||
let pasteLongTextToFileLength = $derived.by(() => {
|
let pasteLongTextToFileLength = $derived.by(() => {
|
||||||
const n = Number(currentConfig.pasteLongTextToFileLen);
|
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 previousIsLoading = $state(isLoading);
|
||||||
let recordingSupported = $state(false);
|
let recordingSupported = $state(false);
|
||||||
|
|
|
||||||
|
|
@ -12,13 +12,21 @@
|
||||||
onCopy?: (message: DatabaseMessage) => void;
|
onCopy?: (message: DatabaseMessage) => void;
|
||||||
onContinueAssistantMessage?: (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,
|
||||||
|
newExtras?: DatabaseMessageExtra[]
|
||||||
|
) => void;
|
||||||
onEditWithReplacement?: (
|
onEditWithReplacement?: (
|
||||||
message: DatabaseMessage,
|
message: DatabaseMessage,
|
||||||
newContent: string,
|
newContent: string,
|
||||||
shouldBranch: boolean
|
shouldBranch: boolean
|
||||||
) => void;
|
) => void;
|
||||||
onEditUserMessagePreserveResponses?: (message: DatabaseMessage, newContent: string) => void;
|
onEditUserMessagePreserveResponses?: (
|
||||||
|
message: DatabaseMessage,
|
||||||
|
newContent: string,
|
||||||
|
newExtras?: DatabaseMessageExtra[]
|
||||||
|
) => void;
|
||||||
onNavigateToSibling?: (siblingId: string) => void;
|
onNavigateToSibling?: (siblingId: string) => void;
|
||||||
onRegenerateWithBranching?: (message: DatabaseMessage, modelOverride?: string) => void;
|
onRegenerateWithBranching?: (message: DatabaseMessage, modelOverride?: string) => void;
|
||||||
siblingInfo?: ChatMessageSiblingInfo | null;
|
siblingInfo?: ChatMessageSiblingInfo | null;
|
||||||
|
|
@ -45,6 +53,8 @@
|
||||||
messageTypes: string[];
|
messageTypes: string[];
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
let editedContent = $state(message.content);
|
let editedContent = $state(message.content);
|
||||||
|
let editedExtras = $state<DatabaseMessageExtra[]>(message.extra ? [...message.extra] : []);
|
||||||
|
let editedUploadedFiles = $state<ChatUploadedFile[]>([]);
|
||||||
let isEditing = $state(false);
|
let isEditing = $state(false);
|
||||||
let showDeleteDialog = $state(false);
|
let showDeleteDialog = $state(false);
|
||||||
let shouldBranchAfterEdit = $state(false);
|
let shouldBranchAfterEdit = $state(false);
|
||||||
|
|
@ -85,6 +95,16 @@
|
||||||
function handleCancelEdit() {
|
function handleCancelEdit() {
|
||||||
isEditing = false;
|
isEditing = false;
|
||||||
editedContent = message.content;
|
editedContent = message.content;
|
||||||
|
editedExtras = message.extra ? [...message.extra] : [];
|
||||||
|
editedUploadedFiles = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEditedExtrasChange(extras: DatabaseMessageExtra[]) {
|
||||||
|
editedExtras = extras;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEditedUploadedFilesChange(files: ChatUploadedFile[]) {
|
||||||
|
editedUploadedFiles = files;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleCopy() {
|
async function handleCopy() {
|
||||||
|
|
@ -107,6 +127,8 @@
|
||||||
function handleEdit() {
|
function handleEdit() {
|
||||||
isEditing = true;
|
isEditing = true;
|
||||||
editedContent = message.content;
|
editedContent = message.content;
|
||||||
|
editedExtras = message.extra ? [...message.extra] : [];
|
||||||
|
editedUploadedFiles = [];
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (textareaElement) {
|
if (textareaElement) {
|
||||||
|
|
@ -143,9 +165,10 @@
|
||||||
onContinueAssistantMessage?.(message);
|
onContinueAssistantMessage?.(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSaveEdit() {
|
async function handleSaveEdit() {
|
||||||
if (message.role === 'user' || message.role === 'system') {
|
if (message.role === 'user' || message.role === 'system') {
|
||||||
onEditWithBranching?.(message, editedContent.trim());
|
const finalExtras = await getMergedExtras();
|
||||||
|
onEditWithBranching?.(message, editedContent.trim(), finalExtras);
|
||||||
} else {
|
} else {
|
||||||
// For assistant messages, preserve exact content including trailing whitespace
|
// For assistant messages, preserve exact content including trailing whitespace
|
||||||
// This is important for the Continue feature to work properly
|
// This is important for the Continue feature to work properly
|
||||||
|
|
@ -154,15 +177,30 @@
|
||||||
|
|
||||||
isEditing = false;
|
isEditing = false;
|
||||||
shouldBranchAfterEdit = false;
|
shouldBranchAfterEdit = false;
|
||||||
|
editedUploadedFiles = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSaveEditOnly() {
|
async function handleSaveEditOnly() {
|
||||||
if (message.role === 'user') {
|
if (message.role === 'user') {
|
||||||
// For user messages, trim to avoid accidental whitespace
|
// For user messages, trim to avoid accidental whitespace
|
||||||
onEditUserMessagePreserveResponses?.(message, editedContent.trim());
|
const finalExtras = await getMergedExtras();
|
||||||
|
onEditUserMessagePreserveResponses?.(message, editedContent.trim(), finalExtras);
|
||||||
}
|
}
|
||||||
|
|
||||||
isEditing = false;
|
isEditing = false;
|
||||||
|
editedUploadedFiles = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getMergedExtras(): Promise<DatabaseMessageExtra[]> {
|
||||||
|
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) {
|
function handleShowDeleteDialogChange(show: boolean) {
|
||||||
|
|
@ -197,6 +235,8 @@
|
||||||
class={className}
|
class={className}
|
||||||
{deletionInfo}
|
{deletionInfo}
|
||||||
{editedContent}
|
{editedContent}
|
||||||
|
{editedExtras}
|
||||||
|
{editedUploadedFiles}
|
||||||
{isEditing}
|
{isEditing}
|
||||||
{message}
|
{message}
|
||||||
onCancelEdit={handleCancelEdit}
|
onCancelEdit={handleCancelEdit}
|
||||||
|
|
@ -206,6 +246,8 @@
|
||||||
onEdit={handleEdit}
|
onEdit={handleEdit}
|
||||||
onEditKeydown={handleEditKeydown}
|
onEditKeydown={handleEditKeydown}
|
||||||
onEditedContentChange={handleEditedContentChange}
|
onEditedContentChange={handleEditedContentChange}
|
||||||
|
onEditedExtrasChange={handleEditedExtrasChange}
|
||||||
|
onEditedUploadedFilesChange={handleEditedUploadedFilesChange}
|
||||||
{onNavigateToSibling}
|
{onNavigateToSibling}
|
||||||
onSaveEdit={handleSaveEdit}
|
onSaveEdit={handleSaveEdit}
|
||||||
onSaveEditOnly={handleSaveEditOnly}
|
onSaveEditOnly={handleSaveEditOnly}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,391 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { X, ArrowUp, Paperclip, AlertTriangle } from '@lucide/svelte';
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import { Switch } from '$lib/components/ui/switch';
|
||||||
|
import { ChatAttachmentsList, DialogConfirmation, ModelsSelector } from '$lib/components/app';
|
||||||
|
import { INPUT_CLASSES } from '$lib/constants/input-classes';
|
||||||
|
import { SETTING_CONFIG_DEFAULT } from '$lib/constants/settings-config';
|
||||||
|
import { AttachmentType, FileTypeCategory, MimeTypeText } from '$lib/enums';
|
||||||
|
import { config } from '$lib/stores/settings.svelte';
|
||||||
|
import { useModelChangeValidation } from '$lib/hooks/use-model-change-validation.svelte';
|
||||||
|
import { setEditModeActive, clearEditMode } from '$lib/stores/chat.svelte';
|
||||||
|
import { conversationsStore } from '$lib/stores/conversations.svelte';
|
||||||
|
import { modelsStore } from '$lib/stores/models.svelte';
|
||||||
|
import { isRouterMode } from '$lib/stores/server.svelte';
|
||||||
|
import {
|
||||||
|
autoResizeTextarea,
|
||||||
|
getFileTypeCategory,
|
||||||
|
getFileTypeCategoryByExtension,
|
||||||
|
parseClipboardContent
|
||||||
|
} from '$lib/utils';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
messageId: string;
|
||||||
|
editedContent: string;
|
||||||
|
editedExtras?: DatabaseMessageExtra[];
|
||||||
|
editedUploadedFiles?: ChatUploadedFile[];
|
||||||
|
originalContent: string;
|
||||||
|
originalExtras?: DatabaseMessageExtra[];
|
||||||
|
showSaveOnlyOption?: boolean;
|
||||||
|
onCancelEdit: () => void;
|
||||||
|
onSaveEdit: () => void;
|
||||||
|
onSaveEditOnly?: () => void;
|
||||||
|
onEditKeydown: (event: KeyboardEvent) => void;
|
||||||
|
onEditedContentChange: (content: string) => void;
|
||||||
|
onEditedExtrasChange?: (extras: DatabaseMessageExtra[]) => void;
|
||||||
|
onEditedUploadedFilesChange?: (files: ChatUploadedFile[]) => void;
|
||||||
|
textareaElement?: HTMLTextAreaElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
messageId,
|
||||||
|
editedContent,
|
||||||
|
editedExtras = [],
|
||||||
|
editedUploadedFiles = [],
|
||||||
|
originalContent,
|
||||||
|
originalExtras = [],
|
||||||
|
showSaveOnlyOption = false,
|
||||||
|
onCancelEdit,
|
||||||
|
onSaveEdit,
|
||||||
|
onSaveEditOnly,
|
||||||
|
onEditKeydown,
|
||||||
|
onEditedContentChange,
|
||||||
|
onEditedExtrasChange,
|
||||||
|
onEditedUploadedFilesChange,
|
||||||
|
textareaElement = $bindable()
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let fileInputElement: HTMLInputElement | undefined = $state();
|
||||||
|
let saveWithoutRegenerate = $state(false);
|
||||||
|
let showDiscardDialog = $state(false);
|
||||||
|
let isRouter = $derived(isRouterMode());
|
||||||
|
let currentConfig = $derived(config());
|
||||||
|
|
||||||
|
let pasteLongTextToFileLength = $derived.by(() => {
|
||||||
|
const n = Number(currentConfig.pasteLongTextToFileLen);
|
||||||
|
|
||||||
|
return Number.isNaN(n) ? Number(SETTING_CONFIG_DEFAULT.pasteLongTextToFileLen) : n;
|
||||||
|
});
|
||||||
|
|
||||||
|
let hasUnsavedChanges = $derived.by(() => {
|
||||||
|
if (editedContent !== originalContent) return true;
|
||||||
|
if (editedUploadedFiles.length > 0) return true;
|
||||||
|
|
||||||
|
const extrasChanged =
|
||||||
|
editedExtras.length !== originalExtras.length ||
|
||||||
|
editedExtras.some((extra, i) => extra !== originalExtras[i]);
|
||||||
|
|
||||||
|
if (extrasChanged) return true;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
let hasAttachments = $derived(
|
||||||
|
(editedExtras && editedExtras.length > 0) ||
|
||||||
|
(editedUploadedFiles && editedUploadedFiles.length > 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
let canSubmit = $derived(editedContent.trim().length > 0 || hasAttachments);
|
||||||
|
|
||||||
|
function getEditedAttachmentsModalities(): ModelModalities {
|
||||||
|
const modalities: ModelModalities = { vision: false, audio: false };
|
||||||
|
|
||||||
|
for (const extra of editedExtras) {
|
||||||
|
if (extra.type === AttachmentType.IMAGE) {
|
||||||
|
modalities.vision = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
extra.type === AttachmentType.PDF &&
|
||||||
|
'processedAsImages' in extra &&
|
||||||
|
extra.processedAsImages
|
||||||
|
) {
|
||||||
|
modalities.vision = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (extra.type === AttachmentType.AUDIO) {
|
||||||
|
modalities.audio = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const file of editedUploadedFiles) {
|
||||||
|
const category = getFileTypeCategory(file.type) || getFileTypeCategoryByExtension(file.name);
|
||||||
|
if (category === FileTypeCategory.IMAGE) {
|
||||||
|
modalities.vision = true;
|
||||||
|
}
|
||||||
|
if (category === FileTypeCategory.AUDIO) {
|
||||||
|
modalities.audio = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return modalities;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRequiredModalities(): ModelModalities {
|
||||||
|
const beforeModalities = conversationsStore.getModalitiesUpToMessage(messageId);
|
||||||
|
const editedModalities = getEditedAttachmentsModalities();
|
||||||
|
|
||||||
|
return {
|
||||||
|
vision: beforeModalities.vision || editedModalities.vision,
|
||||||
|
audio: beforeModalities.audio || editedModalities.audio
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const { handleModelChange } = useModelChangeValidation({
|
||||||
|
getRequiredModalities,
|
||||||
|
onValidationFailure: async (previousModelId) => {
|
||||||
|
if (previousModelId) {
|
||||||
|
await modelsStore.selectModelById(previousModelId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleFileInputChange(event: Event) {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
if (!input.files || input.files.length === 0) return;
|
||||||
|
|
||||||
|
const files = Array.from(input.files);
|
||||||
|
|
||||||
|
processNewFiles(files);
|
||||||
|
input.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleGlobalKeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
event.preventDefault();
|
||||||
|
attemptCancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function attemptCancel() {
|
||||||
|
if (hasUnsavedChanges) {
|
||||||
|
showDiscardDialog = true;
|
||||||
|
} else {
|
||||||
|
onCancelEdit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRemoveExistingAttachment(index: number) {
|
||||||
|
if (!onEditedExtrasChange) return;
|
||||||
|
|
||||||
|
const newExtras = [...editedExtras];
|
||||||
|
|
||||||
|
newExtras.splice(index, 1);
|
||||||
|
onEditedExtrasChange(newExtras);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRemoveUploadedFile(fileId: string) {
|
||||||
|
if (!onEditedUploadedFilesChange) return;
|
||||||
|
|
||||||
|
const newFiles = editedUploadedFiles.filter((f) => f.id !== fileId);
|
||||||
|
|
||||||
|
onEditedUploadedFilesChange(newFiles);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSubmit() {
|
||||||
|
if (!canSubmit) return;
|
||||||
|
|
||||||
|
if (saveWithoutRegenerate && onSaveEditOnly) {
|
||||||
|
onSaveEditOnly();
|
||||||
|
} else {
|
||||||
|
onSaveEdit();
|
||||||
|
}
|
||||||
|
|
||||||
|
saveWithoutRegenerate = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processNewFiles(files: File[]) {
|
||||||
|
if (!onEditedUploadedFilesChange) return;
|
||||||
|
|
||||||
|
const { processFilesToChatUploaded } = await import('$lib/utils/browser-only');
|
||||||
|
const processed = await processFilesToChatUploaded(files);
|
||||||
|
|
||||||
|
onEditedUploadedFilesChange([...editedUploadedFiles, ...processed]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePaste(event: ClipboardEvent) {
|
||||||
|
if (!event.clipboardData) return;
|
||||||
|
|
||||||
|
const files = Array.from(event.clipboardData.items)
|
||||||
|
.filter((item) => item.kind === 'file')
|
||||||
|
.map((item) => item.getAsFile())
|
||||||
|
.filter((file): file is File => file !== null);
|
||||||
|
|
||||||
|
if (files.length > 0) {
|
||||||
|
event.preventDefault();
|
||||||
|
processNewFiles(files);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = event.clipboardData.getData(MimeTypeText.PLAIN);
|
||||||
|
|
||||||
|
if (text.startsWith('"')) {
|
||||||
|
const parsed = parseClipboardContent(text);
|
||||||
|
|
||||||
|
if (parsed.textAttachments.length > 0) {
|
||||||
|
event.preventDefault();
|
||||||
|
onEditedContentChange(parsed.message);
|
||||||
|
|
||||||
|
const attachmentFiles = parsed.textAttachments.map(
|
||||||
|
(att) =>
|
||||||
|
new File([att.content], att.name, {
|
||||||
|
type: MimeTypeText.PLAIN
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
processNewFiles(attachmentFiles);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
textareaElement?.focus();
|
||||||
|
}, 10);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
text.length > 0 &&
|
||||||
|
pasteLongTextToFileLength > 0 &&
|
||||||
|
text.length > pasteLongTextToFileLength
|
||||||
|
) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const textFile = new File([text], 'Pasted', {
|
||||||
|
type: MimeTypeText.PLAIN
|
||||||
|
});
|
||||||
|
|
||||||
|
processNewFiles([textFile]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (textareaElement) {
|
||||||
|
autoResizeTextarea(textareaElement);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
setEditModeActive(processNewFiles);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearEditMode();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={handleGlobalKeydown} />
|
||||||
|
|
||||||
|
<input
|
||||||
|
bind:this={fileInputElement}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
class="hidden"
|
||||||
|
onchange={handleFileInputChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="{INPUT_CLASSES} w-full max-w-[80%] overflow-hidden rounded-3xl backdrop-blur-md"
|
||||||
|
data-slot="edit-form"
|
||||||
|
>
|
||||||
|
<ChatAttachmentsList
|
||||||
|
attachments={editedExtras}
|
||||||
|
uploadedFiles={editedUploadedFiles}
|
||||||
|
readonly={false}
|
||||||
|
onFileRemove={(fileId) => {
|
||||||
|
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;"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="relative min-h-[48px] px-5 py-3">
|
||||||
|
<textarea
|
||||||
|
bind:this={textareaElement}
|
||||||
|
bind:value={editedContent}
|
||||||
|
class="field-sizing-content max-h-80 min-h-10 w-full resize-none bg-transparent text-sm outline-none"
|
||||||
|
onkeydown={onEditKeydown}
|
||||||
|
oninput={(e) => {
|
||||||
|
autoResizeTextarea(e.currentTarget);
|
||||||
|
onEditedContentChange(e.currentTarget.value);
|
||||||
|
}}
|
||||||
|
onpaste={handlePaste}
|
||||||
|
placeholder="Edit your message..."
|
||||||
|
></textarea>
|
||||||
|
|
||||||
|
<div class="flex w-full items-center gap-3" style="container-type: inline-size">
|
||||||
|
<Button
|
||||||
|
class="h-8 w-8 shrink-0 rounded-full bg-transparent p-0 text-muted-foreground hover:bg-foreground/10 hover:text-foreground"
|
||||||
|
onclick={() => fileInputElement?.click()}
|
||||||
|
type="button"
|
||||||
|
title="Add attachment"
|
||||||
|
>
|
||||||
|
<span class="sr-only">Attach files</span>
|
||||||
|
|
||||||
|
<Paperclip class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div class="flex-1"></div>
|
||||||
|
|
||||||
|
{#if isRouter}
|
||||||
|
<ModelsSelector
|
||||||
|
forceForegroundText={true}
|
||||||
|
useGlobalSelection={true}
|
||||||
|
onModelChange={handleModelChange}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
class="h-8 w-8 shrink-0 rounded-full p-0"
|
||||||
|
onclick={handleSubmit}
|
||||||
|
disabled={!canSubmit}
|
||||||
|
type="button"
|
||||||
|
title={saveWithoutRegenerate ? 'Save changes' : 'Send and regenerate'}
|
||||||
|
>
|
||||||
|
<span class="sr-only">{saveWithoutRegenerate ? 'Save' : 'Send'}</span>
|
||||||
|
|
||||||
|
<ArrowUp class="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-2 flex w-full max-w-[80%] items-center justify-between">
|
||||||
|
{#if showSaveOnlyOption && onSaveEditOnly}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Switch id="save-only-switch" bind:checked={saveWithoutRegenerate} class="scale-75" />
|
||||||
|
|
||||||
|
<label for="save-only-switch" class="cursor-pointer text-xs text-muted-foreground">
|
||||||
|
Update without re-sending
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div></div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<Button class="h-7 px-3 text-xs" onclick={attemptCancel} size="sm" variant="ghost">
|
||||||
|
<X class="mr-1 h-3 w-3" />
|
||||||
|
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogConfirmation
|
||||||
|
bind:open={showDiscardDialog}
|
||||||
|
title="Discard changes?"
|
||||||
|
description="You have unsaved changes. Are you sure you want to discard them?"
|
||||||
|
confirmText="Discard"
|
||||||
|
cancelText="Keep editing"
|
||||||
|
variant="destructive"
|
||||||
|
icon={AlertTriangle}
|
||||||
|
onConfirm={onCancelEdit}
|
||||||
|
onCancel={() => (showDiscardDialog = false)}
|
||||||
|
/>
|
||||||
|
|
@ -1,18 +1,17 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
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 { ChatAttachmentsList, MarkdownContent } from '$lib/components/app';
|
import { ChatAttachmentsList, MarkdownContent } from '$lib/components/app';
|
||||||
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';
|
|
||||||
import ChatMessageActions from './ChatMessageActions.svelte';
|
import ChatMessageActions from './ChatMessageActions.svelte';
|
||||||
|
import ChatMessageEditForm from './ChatMessageEditForm.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
class?: string;
|
class?: string;
|
||||||
message: DatabaseMessage;
|
message: DatabaseMessage;
|
||||||
isEditing: boolean;
|
isEditing: boolean;
|
||||||
editedContent: string;
|
editedContent: string;
|
||||||
|
editedExtras?: DatabaseMessageExtra[];
|
||||||
|
editedUploadedFiles?: ChatUploadedFile[];
|
||||||
siblingInfo?: ChatMessageSiblingInfo | null;
|
siblingInfo?: ChatMessageSiblingInfo | null;
|
||||||
showDeleteDialog: boolean;
|
showDeleteDialog: boolean;
|
||||||
deletionInfo: {
|
deletionInfo: {
|
||||||
|
|
@ -26,6 +25,8 @@
|
||||||
onSaveEditOnly?: () => void;
|
onSaveEditOnly?: () => void;
|
||||||
onEditKeydown: (event: KeyboardEvent) => void;
|
onEditKeydown: (event: KeyboardEvent) => void;
|
||||||
onEditedContentChange: (content: string) => void;
|
onEditedContentChange: (content: string) => void;
|
||||||
|
onEditedExtrasChange?: (extras: DatabaseMessageExtra[]) => void;
|
||||||
|
onEditedUploadedFilesChange?: (files: ChatUploadedFile[]) => void;
|
||||||
onCopy: () => void;
|
onCopy: () => void;
|
||||||
onEdit: () => void;
|
onEdit: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
|
|
@ -40,6 +41,8 @@
|
||||||
message,
|
message,
|
||||||
isEditing,
|
isEditing,
|
||||||
editedContent,
|
editedContent,
|
||||||
|
editedExtras = [],
|
||||||
|
editedUploadedFiles = [],
|
||||||
siblingInfo = null,
|
siblingInfo = null,
|
||||||
showDeleteDialog,
|
showDeleteDialog,
|
||||||
deletionInfo,
|
deletionInfo,
|
||||||
|
|
@ -48,6 +51,8 @@
|
||||||
onSaveEditOnly,
|
onSaveEditOnly,
|
||||||
onEditKeydown,
|
onEditKeydown,
|
||||||
onEditedContentChange,
|
onEditedContentChange,
|
||||||
|
onEditedExtrasChange,
|
||||||
|
onEditedUploadedFilesChange,
|
||||||
onCopy,
|
onCopy,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
|
@ -61,12 +66,6 @@
|
||||||
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;
|
||||||
|
|
||||||
|
|
@ -98,44 +97,23 @@
|
||||||
role="group"
|
role="group"
|
||||||
>
|
>
|
||||||
{#if isEditing}
|
{#if isEditing}
|
||||||
<div class="w-full max-w-[80%]">
|
<ChatMessageEditForm
|
||||||
<textarea
|
bind:textareaElement
|
||||||
bind:this={textareaElement}
|
messageId={message.id}
|
||||||
bind:value={editedContent}
|
{editedContent}
|
||||||
class="min-h-[60px] w-full resize-none rounded-2xl px-3 py-2 text-sm {INPUT_CLASSES}"
|
{editedExtras}
|
||||||
onkeydown={onEditKeydown}
|
{editedUploadedFiles}
|
||||||
oninput={(e) => {
|
originalContent={message.content}
|
||||||
autoResizeTextarea(e.currentTarget);
|
originalExtras={message.extra}
|
||||||
onEditedContentChange(e.currentTarget.value);
|
showSaveOnlyOption={!!onSaveEditOnly}
|
||||||
}}
|
{onCancelEdit}
|
||||||
placeholder="Edit your message..."
|
{onSaveEdit}
|
||||||
></textarea>
|
{onSaveEditOnly}
|
||||||
|
{onEditKeydown}
|
||||||
<div class="mt-2 flex justify-end gap-2">
|
{onEditedContentChange}
|
||||||
<Button class="h-8 px-3" onclick={onCancelEdit} size="sm" variant="ghost">
|
{onEditedExtrasChange}
|
||||||
<X class="mr-1 h-3 w-3" />
|
{onEditedUploadedFilesChange}
|
||||||
Cancel
|
/>
|
||||||
</Button>
|
|
||||||
|
|
||||||
{#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>
|
|
||||||
</div>
|
|
||||||
{:else}
|
{:else}
|
||||||
{#if message.extra && message.extra.length > 0}
|
{#if message.extra && message.extra.length > 0}
|
||||||
<div class="mb-2 max-w-[80%]">
|
<div class="mb-2 max-w-[80%]">
|
||||||
|
|
|
||||||
|
|
@ -66,10 +66,14 @@
|
||||||
await conversationsStore.navigateToSibling(siblingId);
|
await conversationsStore.navigateToSibling(siblingId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleEditWithBranching(message: DatabaseMessage, newContent: string) {
|
async function handleEditWithBranching(
|
||||||
|
message: DatabaseMessage,
|
||||||
|
newContent: string,
|
||||||
|
newExtras?: DatabaseMessageExtra[]
|
||||||
|
) {
|
||||||
onUserAction?.();
|
onUserAction?.();
|
||||||
|
|
||||||
await chatStore.editMessageWithBranching(message.id, newContent);
|
await chatStore.editMessageWithBranching(message.id, newContent, newExtras);
|
||||||
|
|
||||||
refreshAllMessages();
|
refreshAllMessages();
|
||||||
}
|
}
|
||||||
|
|
@ -104,11 +108,12 @@
|
||||||
|
|
||||||
async function handleEditUserMessagePreserveResponses(
|
async function handleEditUserMessagePreserveResponses(
|
||||||
message: DatabaseMessage,
|
message: DatabaseMessage,
|
||||||
newContent: string
|
newContent: string,
|
||||||
|
newExtras?: DatabaseMessageExtra[]
|
||||||
) {
|
) {
|
||||||
onUserAction?.();
|
onUserAction?.();
|
||||||
|
|
||||||
await chatStore.editUserMessagePreserveResponses(message.id, newContent);
|
await chatStore.editUserMessagePreserveResponses(message.id, newContent, newExtras);
|
||||||
|
|
||||||
refreshAllMessages();
|
refreshAllMessages();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,13 @@
|
||||||
AUTO_SCROLL_INTERVAL,
|
AUTO_SCROLL_INTERVAL,
|
||||||
INITIAL_SCROLL_DELAY
|
INITIAL_SCROLL_DELAY
|
||||||
} from '$lib/constants/auto-scroll';
|
} from '$lib/constants/auto-scroll';
|
||||||
import { chatStore, errorDialog, isLoading } from '$lib/stores/chat.svelte';
|
import {
|
||||||
|
chatStore,
|
||||||
|
errorDialog,
|
||||||
|
isLoading,
|
||||||
|
isEditing,
|
||||||
|
getAddFilesHandler
|
||||||
|
} from '$lib/stores/chat.svelte';
|
||||||
import {
|
import {
|
||||||
conversationsStore,
|
conversationsStore,
|
||||||
activeMessages,
|
activeMessages,
|
||||||
|
|
@ -181,7 +187,18 @@
|
||||||
dragCounter = 0;
|
dragCounter = 0;
|
||||||
|
|
||||||
if (event.dataTransfer?.files) {
|
if (event.dataTransfer?.files) {
|
||||||
processFiles(Array.from(event.dataTransfer.files));
|
const files = Array.from(event.dataTransfer.files);
|
||||||
|
|
||||||
|
if (isEditing()) {
|
||||||
|
const handler = getAddFilesHandler();
|
||||||
|
|
||||||
|
if (handler) {
|
||||||
|
handler(files);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
processFiles(files);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -410,7 +427,7 @@
|
||||||
|
|
||||||
<div class="conversation-chat-form pointer-events-auto rounded-t-3xl pb-4">
|
<div class="conversation-chat-form pointer-events-auto rounded-t-3xl pb-4">
|
||||||
<ChatForm
|
<ChatForm
|
||||||
disabled={hasPropsError}
|
disabled={hasPropsError || isEditing()}
|
||||||
isLoading={isCurrentConversationLoading}
|
isLoading={isCurrentConversationLoading}
|
||||||
onFileRemove={handleFileRemove}
|
onFileRemove={handleFileRemove}
|
||||||
onFileUpload={handleFileUpload}
|
onFileUpload={handleFileUpload}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
import Root from './switch.svelte';
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
//
|
||||||
|
Root as Switch
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { Switch as SwitchPrimitive } from 'bits-ui';
|
||||||
|
import { cn, type WithoutChildrenOrChild } from '$lib/components/ui/utils.js';
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
checked = $bindable(false),
|
||||||
|
...restProps
|
||||||
|
}: WithoutChildrenOrChild<SwitchPrimitive.RootProps> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<SwitchPrimitive.Root
|
||||||
|
bind:ref
|
||||||
|
bind:checked
|
||||||
|
data-slot="switch"
|
||||||
|
class={cn(
|
||||||
|
'peer inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input dark:data-[state=unchecked]:bg-input/80',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
<SwitchPrimitive.Thumb
|
||||||
|
data-slot="switch-thumb"
|
||||||
|
class={cn(
|
||||||
|
'pointer-events-none block size-4 rounded-full bg-background ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0 dark:data-[state=checked]:bg-primary-foreground dark:data-[state=unchecked]:bg-foreground'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SwitchPrimitive.Root>
|
||||||
|
|
@ -74,6 +74,8 @@ class ChatStore {
|
||||||
private processingStates = new SvelteMap<string, ApiProcessingState | null>();
|
private processingStates = new SvelteMap<string, ApiProcessingState | null>();
|
||||||
private activeConversationId = $state<string | null>(null);
|
private activeConversationId = $state<string | null>(null);
|
||||||
private isStreamingActive = $state(false);
|
private isStreamingActive = $state(false);
|
||||||
|
private isEditModeActive = $state(false);
|
||||||
|
private addFilesHandler: ((files: File[]) => void) | null = $state(null);
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
// Loading State
|
// Loading State
|
||||||
|
|
@ -965,230 +967,9 @@ class ChatStore {
|
||||||
// Editing
|
// Editing
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async editAssistantMessage(
|
clearEditMode(): void {
|
||||||
messageId: string,
|
this.isEditModeActive = false;
|
||||||
newContent: string,
|
this.addFilesHandler = null;
|
||||||
shouldBranch: boolean
|
|
||||||
): Promise<void> {
|
|
||||||
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<void> {
|
|
||||||
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<void> {
|
|
||||||
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<void> {
|
|
||||||
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<void> {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async continueAssistantMessage(messageId: string): Promise<void> {
|
async continueAssistantMessage(messageId: string): Promise<void> {
|
||||||
|
|
@ -1340,19 +1121,284 @@ class ChatStore {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public isChatLoadingPublic(convId: string): boolean {
|
async editAssistantMessage(
|
||||||
return this.isChatLoading(convId);
|
messageId: string,
|
||||||
|
newContent: string,
|
||||||
|
shouldBranch: boolean
|
||||||
|
): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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<DatabaseMessage> = {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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(
|
public getChatStreamingPublic(
|
||||||
convId: string
|
convId: string
|
||||||
): { response: string; messageId: string } | undefined {
|
): { response: string; messageId: string } | undefined {
|
||||||
return this.getChatStreaming(convId);
|
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 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 currentResponse = () => chatStore.currentResponse;
|
||||||
export const errorDialog = () => chatStore.errorDialogState;
|
export const errorDialog = () => chatStore.errorDialogState;
|
||||||
export const activeProcessingState = () => chatStore.activeProcessingState;
|
export const getAddFilesHandler = () => chatStore.getAddFilesHandler();
|
||||||
export const isChatStreaming = () => chatStore.isStreaming();
|
|
||||||
|
|
||||||
export const isChatLoading = (convId: string) => chatStore.isChatLoadingPublic(convId);
|
|
||||||
export const getChatStreaming = (convId: string) => chatStore.getChatStreamingPublic(convId);
|
|
||||||
export const getAllLoadingChats = () => chatStore.getAllLoadingChats();
|
export const getAllLoadingChats = () => chatStore.getAllLoadingChats();
|
||||||
export const getAllStreamingChats = () => chatStore.getAllStreamingChats();
|
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);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue