diff --git a/src/interface/obsidian/src/chat_view.ts b/src/interface/obsidian/src/chat_view.ts index 86d5e1f94..ebf2ad8dc 100644 --- a/src/interface/obsidian/src/chat_view.ts +++ b/src/interface/obsidian/src/chat_view.ts @@ -31,6 +31,7 @@ interface ChatMessageState { rawResponse: string; rawQuery: string; isVoice: boolean; + turnId: string; } interface Location { @@ -41,6 +42,17 @@ interface Location { timezone: string; } +interface RenderMessageOptions { + chatBodyEl: Element; + message: string; + sender: string; + turnId?: string; + dt?: Date; + raw?: boolean; + willReplace?: boolean; + isSystemMessage?: boolean; +} + export class KhojChatView extends KhojPaneView { result: string; setting: KhojSetting; @@ -480,6 +492,7 @@ export class KhojChatView extends KhojPaneView { chatEl: Element, message: string, sender: string, + turnId: string, context?: string[], onlineContext?: object, dt?: Date, @@ -487,7 +500,7 @@ export class KhojChatView extends KhojPaneView { inferredQueries?: string[], conversationId?: string, images?: string[], - excalidrawDiagram?: string + excalidrawDiagram?: string, ) { if (!message) return; @@ -498,9 +511,21 @@ export class KhojChatView extends KhojPaneView { (images && images.length > 0) || excalidrawDiagram) { let imageMarkdown = this.generateImageMarkdown(message, intentType ?? "", inferredQueries, conversationId, images, excalidrawDiagram); - chatMessageEl = this.renderMessage(chatEl, imageMarkdown, sender, dt); + chatMessageEl = this.renderMessage({ + chatBodyEl: chatEl, + message: imageMarkdown, + sender, + dt, + turnId + }); } else { - chatMessageEl = this.renderMessage(chatEl, message, sender, dt); + chatMessageEl = this.renderMessage({ + chatBodyEl: chatEl, + message, + sender, + dt, + turnId + }); } // If no document or online context is provided, skip rendering the reference section @@ -550,7 +575,7 @@ export class KhojChatView extends KhojPaneView { return imageMarkdown; } - renderMessage(chatBodyEl: Element, message: string, sender: string, dt?: Date, raw: boolean = false, willReplace: boolean = true): Element { + renderMessage({ chatBodyEl, message, sender, dt, turnId, raw = false, willReplace = true, isSystemMessage = false }: RenderMessageOptions): Element { let message_time = this.formatDate(dt ?? new Date()); // Append message to conversation history HTML element. @@ -558,7 +583,8 @@ export class KhojChatView extends KhojPaneView { let chatMessageEl = chatBodyEl.createDiv({ attr: { "data-meta": message_time, - class: `khoj-chat-message ${sender}` + class: `khoj-chat-message ${sender}`, + ...(turnId && { "data-turnId": turnId }) }, }) let chatMessageBodyEl = chatMessageEl.createDiv(); @@ -577,7 +603,7 @@ export class KhojChatView extends KhojPaneView { // Add action buttons to each chat message element if (willReplace === true) { - this.renderActionButtons(message, chatMessageBodyTextEl); + this.renderActionButtons(message, chatMessageBodyTextEl, isSystemMessage); } // Remove user-select: none property to make text selectable @@ -621,7 +647,7 @@ export class KhojChatView extends KhojPaneView { this.scrollChatToBottom(); } - renderActionButtons(message: string, chatMessageBodyTextEl: HTMLElement) { + renderActionButtons(message: string, chatMessageBodyTextEl: HTMLElement, isSystemMessage: boolean = false) { let copyButton = this.contentEl.createEl('button'); copyButton.classList.add("chat-action-button"); copyButton.title = "Copy Message to Clipboard"; @@ -635,6 +661,25 @@ export class KhojChatView extends KhojPaneView { setIcon(pasteToFile, "clipboard-paste"); pasteToFile.addEventListener('click', (event) => { pasteTextAtCursor(createCopyParentText(message, 'clipboard-paste')(event)); }); + + // Add delete button + let deleteButton = null; + if (!isSystemMessage) { + deleteButton = this.contentEl.createEl('button'); + deleteButton.classList.add("chat-action-button"); + deleteButton.title = "Delete Message"; + setIcon(deleteButton, "trash-2"); + deleteButton.addEventListener('click', () => { + const messageEl = chatMessageBodyTextEl.closest('.khoj-chat-message'); + if (messageEl) { + // Ask for confirmation before deleting + if (confirm('Are you sure you want to delete this message?')) { + this.deleteMessage(messageEl as HTMLElement); + } + } + }); + } + // Only enable the speech feature if the user is subscribed let speechButton = null; @@ -649,7 +694,9 @@ export class KhojChatView extends KhojPaneView { // Append buttons to parent element chatMessageBodyTextEl.append(copyButton, pasteToFile); - + if (deleteButton) { + chatMessageBodyTextEl.append(deleteButton); + } if (speechButton) { chatMessageBodyTextEl.append(speechButton); } @@ -675,7 +722,7 @@ export class KhojChatView extends KhojPaneView { if (chatInput) { chatInput.placeholder = this.startingMessage; } - this.renderMessage(chatBodyEl, "Hey 👋🏾, what's up?", "khoj"); + this.renderMessage({chatBodyEl, message: "Hey 👋🏾, what's up?", sender: "khoj", isSystemMessage: true}); } async toggleChatSessions(forceShow: boolean = false): Promise { @@ -886,7 +933,12 @@ export class KhojChatView extends KhojPaneView { if (responseJson.detail) { // If the server returns error details in response, render a setup hint. let setupMsg = "Hi 👋🏾, to start chatting add available chat models options via [the Django Admin panel](/server/admin) on the Server"; - this.renderMessage(chatBodyEl, setupMsg, "khoj", undefined); + this.renderMessage({ + chatBodyEl, + message: setupMsg, + sender: "khoj", + isSystemMessage: true + }); return false; } else if (responseJson.response) { @@ -900,6 +952,7 @@ export class KhojChatView extends KhojPaneView { chatBodyEl, chatLog.message, chatLog.by, + chatLog.turnId, chatLog.context, chatLog.onlineContext, new Date(chatLog.created), @@ -929,7 +982,12 @@ export class KhojChatView extends KhojPaneView { } } catch (err) { let errorMsg = "Unable to get response from Khoj server ❤️‍🩹. Ensure server is running or contact developers for help at [team@khoj.dev](mailto:team@khoj.dev) or in [Discord](https://discord.gg/BDgyabRM6e)"; - this.renderMessage(chatBodyEl, errorMsg, "khoj", undefined); + this.renderMessage({ + chatBodyEl, + message: errorMsg, + sender: "khoj", + isSystemMessage: true + }); return false; } return true; @@ -974,7 +1032,7 @@ export class KhojChatView extends KhojPaneView { this.textToSpeech(this.chatMessageState.rawResponse); // Append any references after all the data has been streamed - this.finalizeChatBodyResponse(this.chatMessageState.references, this.chatMessageState.newResponseTextEl); + this.finalizeChatBodyResponse(this.chatMessageState.references, this.chatMessageState.newResponseTextEl, this.chatMessageState.turnId); const liveQuery = this.chatMessageState.rawQuery; // Reset variables @@ -987,6 +1045,7 @@ export class KhojChatView extends KhojPaneView { rawQuery: liveQuery, isVoice: false, generatedAssets: "", + turnId: "", }; } else if (chunk.type === "references") { this.chatMessageState.references = { "notes": chunk.data.context, "online": chunk.data.onlineContext }; @@ -1008,6 +1067,12 @@ export class KhojChatView extends KhojPaneView { this.chatMessageState.rawResponse += chunkData; this.handleStreamResponse(this.chatMessageState.newResponseTextEl, this.chatMessageState.rawResponse + this.chatMessageState.generatedAssets, this.chatMessageState.loadingEllipsis); } + } else if (chunk.type === "metadata") { + const { turnId } = chunk.data; + if (turnId) { + // Append turnId to chatMessageState + this.chatMessageState.turnId = turnId; + } } } @@ -1067,7 +1132,7 @@ export class KhojChatView extends KhojPaneView { // Render user query as chat message let chatBodyEl = this.contentEl.getElementsByClassName("khoj-chat-body")[0] as HTMLElement; - this.renderMessage(chatBodyEl, query, "you"); + this.renderMessage({chatBodyEl, message: query, sender: "you"}); let conversationId = chatBodyEl.dataset.conversationId; if (!conversationId) { @@ -1113,6 +1178,7 @@ export class KhojChatView extends KhojPaneView { rawResponse: "", isVoice: isVoice, generatedAssets: "", + turnId: "", }; let response = await fetch(chatUrl, { @@ -1415,10 +1481,15 @@ export class KhojChatView extends KhojPaneView { return rawResponse; } - finalizeChatBodyResponse(references: object, newResponseElement: HTMLElement | null) { + finalizeChatBodyResponse(references: object, newResponseElement: HTMLElement | null, turnId: string) { if (!!newResponseElement && references != null && Object.keys(references).length > 0) { newResponseElement.appendChild(this.createReferenceSection(references)); } + if (!!newResponseElement && turnId) { + // Set the turnId for the new response and the previous user message + newResponseElement.parentElement?.setAttribute("data-turnId", turnId); + newResponseElement.parentElement?.previousElementSibling?.setAttribute("data-turnId", turnId); + } this.scrollChatToBottom(); let chatInput = this.contentEl.getElementsByClassName("khoj-chat-input")[0]; if (chatInput) chatInput.removeAttribute("disabled"); @@ -1487,4 +1558,49 @@ export class KhojChatView extends KhojPaneView { } } } + + // Add this new method to handle message deletion + async deleteMessage(messageEl: HTMLElement) { + const chatBodyEl = this.contentEl.getElementsByClassName("khoj-chat-body")[0] as HTMLElement; + const conversationId = chatBodyEl.dataset.conversationId; + + // Get the turnId from the message's data-turn attribute + const turnId = messageEl.getAttribute("data-turnId"); + if (!turnId || !conversationId) return; + + try { + const response = await fetch(`${this.setting.khojUrl}/api/chat/conversation/message`, { + method: "DELETE", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${this.setting.khojApiKey}` + }, + body: JSON.stringify({ + conversation_id: conversationId, + turn_id: turnId + }) + }); + + if (response.ok) { + // Remove both the user message and Khoj response (the conversation turn) + const isKhojMessage = messageEl.classList.contains("khoj"); + const messages = Array.from(chatBodyEl.getElementsByClassName("khoj-chat-message")); + const messageIndex = messages.indexOf(messageEl); + + if (isKhojMessage && messageIndex > 0) { + // If it is a Khoj message, remove the previous user message too + messages[messageIndex - 1].remove(); + } else if (!isKhojMessage && messageIndex < messages.length - 1) { + // If it is a user message, remove the next Khoj message too + messages[messageIndex + 1].remove(); + } + messageEl.remove(); + } else { + this.flashStatusInChatInput("Failed to delete message"); + } + } catch (error) { + console.error("Error deleting message:", error); + this.flashStatusInChatInput("Error deleting message"); + } + } } diff --git a/src/interface/obsidian/styles.css b/src/interface/obsidian/styles.css index 23113c906..f7c067ed3 100644 --- a/src/interface/obsidian/styles.css +++ b/src/interface/obsidian/styles.css @@ -858,4 +858,4 @@ img.copy-icon { 100% { transform: rotate(360deg); } -} \ No newline at end of file +}