Add delete message button
This commit is contained in:
@@ -24,7 +24,6 @@ class Odidere {
|
||||
this.currentAudio = null;
|
||||
this.currentAudioUrl = null;
|
||||
this.currentController = null;
|
||||
this.history = [];
|
||||
this.isProcessing = false;
|
||||
this.isRecording = false;
|
||||
this.isMuted = false;
|
||||
@@ -33,6 +32,11 @@ class Odidere {
|
||||
this.isStopPending = false;
|
||||
this.mediaRecorder = null;
|
||||
this.wakeLock = null;
|
||||
this.pendingDeleteId = null;
|
||||
// Conversation state
|
||||
this.messagesMap = new Map();
|
||||
this.leafId = null;
|
||||
this.rootId = null;
|
||||
|
||||
// Auto-scroll: enabled by default, disabled when user scrolls up,
|
||||
// re-enabled when they scroll back to bottom or send a new message.
|
||||
@@ -105,7 +109,10 @@ class Odidere {
|
||||
}
|
||||
|
||||
this.#setLoadingState(false);
|
||||
this.history = [];
|
||||
this.pendingDeleteId = null;
|
||||
this.messagesMap.clear();
|
||||
this.leafId = null;
|
||||
this.rootId = null;
|
||||
this.isAutoScrollEnabled = true;
|
||||
this._lastScrollTop = 0;
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
@@ -167,6 +174,7 @@ class Odidere {
|
||||
// scroll-to-top.
|
||||
this.$scroll.addEventListener('click', () => this.#handleScrollClick());
|
||||
this.document.addEventListener('click', (e) => this.#handleScrollCancel(e));
|
||||
this.document.addEventListener('click', (e) => this.#handleDeleteCancel(e));
|
||||
|
||||
// File attachment.
|
||||
this.$attach.addEventListener('click', () => this.$fileInput.click());
|
||||
@@ -223,6 +231,10 @@ class Odidere {
|
||||
);
|
||||
}
|
||||
|
||||
// ====================
|
||||
// CONVERSATION TREE
|
||||
// =====================
|
||||
|
||||
/**
|
||||
* #loadHistory loads chat history from localStorage and renders it.
|
||||
*/
|
||||
@@ -231,14 +243,232 @@ class Odidere {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (!stored) return;
|
||||
|
||||
this.history = JSON.parse(stored);
|
||||
this.#renderMessages(this.history);
|
||||
const parsed = JSON.parse(stored);
|
||||
|
||||
// Ensure messages have parent/children fields (defensive).
|
||||
for (const msg of parsed.messages) {
|
||||
if (msg.parent === undefined) msg.parent = null;
|
||||
if (msg.children === undefined) msg.children = [];
|
||||
}
|
||||
|
||||
this.messagesMap = new Map(parsed.messages.map((m) => [m.id, m]));
|
||||
this.leafId = parsed.leafId;
|
||||
this.rootId = parsed.rootId;
|
||||
|
||||
// Render the active path.
|
||||
const activePath = this.getActivePath();
|
||||
this.#renderMessages(activePath);
|
||||
} catch (e) {
|
||||
console.error('failed to load history:', e);
|
||||
this.history = [];
|
||||
this.messagesMap.clear();
|
||||
this.leafId = null;
|
||||
this.rootId = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* #saveToStorage persists the tree to localStorage.
|
||||
*/
|
||||
#saveToStorage() {
|
||||
const messages = Array.from(this.messagesMap.values());
|
||||
const data = {
|
||||
messages,
|
||||
leafId: this.leafId,
|
||||
rootId: this.rootId,
|
||||
};
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
|
||||
} catch (e) {
|
||||
console.error('failed to save history:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* getActivePath resolves the ordered array of messages from root to leaf
|
||||
* by walking parent pointers from leafId.
|
||||
* @returns {Object[]} ordered messages
|
||||
*/
|
||||
getActivePath() {
|
||||
if (!this.leafId) return [];
|
||||
const path = [];
|
||||
let current = this.leafId;
|
||||
while (current) {
|
||||
const msg = this.messagesMap.get(current);
|
||||
if (!msg) break;
|
||||
path.push(msg);
|
||||
current = msg.parent;
|
||||
}
|
||||
path.reverse();
|
||||
return path;
|
||||
}
|
||||
|
||||
/**
|
||||
* #appendHistory adds messages to the tree. Each new message is a child
|
||||
* of the previous one (or the current leaf for the first).
|
||||
* Only the last message receives the metadata.
|
||||
* Mutates the passed-in message objects in place (adds id, parent, children,
|
||||
* meta) and stores the same references in messagesMap. Callers should use
|
||||
* the returned messages for rendering to ensure the delete handler captures
|
||||
* the correct IDs.
|
||||
* @param {Object[]} messages
|
||||
* @param {Object} [meta]
|
||||
* @returns {Object[]} the same message objects, now enriched
|
||||
*/
|
||||
#appendHistory(messages, meta = {}) {
|
||||
let prevId = this.leafId;
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const msg = messages[i];
|
||||
|
||||
// Generate ID if not present.
|
||||
if (!msg.id) {
|
||||
msg.id = crypto.randomUUID();
|
||||
}
|
||||
|
||||
// Attach metadata only to the final message.
|
||||
if (i === messages.length - 1) {
|
||||
msg.meta = meta;
|
||||
}
|
||||
|
||||
// Link to the tree.
|
||||
msg.parent = prevId;
|
||||
msg.children = [];
|
||||
|
||||
if (prevId) {
|
||||
const parent = this.messagesMap.get(prevId);
|
||||
if (parent) {
|
||||
parent.children.push(msg.id);
|
||||
}
|
||||
} else {
|
||||
// First message in the conversation.
|
||||
this.rootId = msg.id;
|
||||
}
|
||||
|
||||
this.messagesMap.set(msg.id, msg);
|
||||
this.leafId = msg.id;
|
||||
prevId = msg.id;
|
||||
}
|
||||
|
||||
this.#saveToStorage();
|
||||
return messages;
|
||||
}
|
||||
|
||||
/**
|
||||
* deleteMessage removes a message from the tree and reparents its
|
||||
* children to the grandparent. Also handles tool call / tool result
|
||||
* pairing. When deleting the root, the first child becomes the new root
|
||||
* and any remaining children are reparented under it.
|
||||
* @param {string} messageId
|
||||
*/
|
||||
deleteMessage(messageId) {
|
||||
const msg = this.messagesMap.get(messageId);
|
||||
if (!msg) return;
|
||||
|
||||
const parentId = msg.parent; // null if this is the root
|
||||
const parent = parentId ? this.messagesMap.get(parentId) : null;
|
||||
|
||||
// Identify tool result messages that belong to this assistant message.
|
||||
const idsToDelete = new Set([messageId]);
|
||||
if (msg.role === 'assistant' && msg.tool_calls?.length > 0) {
|
||||
const toolCallIds = new Set(msg.tool_calls.map((tc) => tc.id));
|
||||
for (const [id, m] of this.messagesMap) {
|
||||
if (m.role === 'tool' && toolCallIds.has(m.tool_call_id)) {
|
||||
idsToDelete.add(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Collect all children that need reparenting.
|
||||
const childrenToReparent = [];
|
||||
for (const deleteId of idsToDelete) {
|
||||
const delMsg = this.messagesMap.get(deleteId);
|
||||
if (delMsg?.children) {
|
||||
childrenToReparent.push(...delMsg.children);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove deleted messages from the map.
|
||||
for (const deleteId of idsToDelete) {
|
||||
this.messagesMap.delete(deleteId);
|
||||
}
|
||||
|
||||
// Remove deleted IDs from parent's children.
|
||||
if (parent) {
|
||||
parent.children = parent.children.filter((c) => !idsToDelete.has(c));
|
||||
}
|
||||
|
||||
// Reparent children of deleted messages to the grandparent.
|
||||
// When deleting the root, the first child becomes the new root and the
|
||||
// remaining children are reparented under it.
|
||||
if (parent) {
|
||||
for (const childId of childrenToReparent) {
|
||||
const child = this.messagesMap.get(childId);
|
||||
if (child) {
|
||||
child.parent = parentId;
|
||||
if (!parent.children.includes(childId)) {
|
||||
parent.children.push(childId);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (childrenToReparent.length > 0) {
|
||||
// Root deletion: first child becomes the new root, rest become its children.
|
||||
const newRootId = childrenToReparent[0];
|
||||
const newRoot = this.messagesMap.get(newRootId);
|
||||
if (newRoot) {
|
||||
newRoot.parent = null;
|
||||
for (let i = 1; i < childrenToReparent.length; i++) {
|
||||
const childId = childrenToReparent[i];
|
||||
const child = this.messagesMap.get(childId);
|
||||
if (child) {
|
||||
child.parent = newRootId;
|
||||
if (!newRoot.children.includes(childId)) {
|
||||
newRoot.children.push(childId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
this.rootId = newRootId;
|
||||
} else {
|
||||
this.rootId = null;
|
||||
}
|
||||
|
||||
// Update leafId if it was deleted or was a descendant of a deleted message.
|
||||
if (idsToDelete.has(this.leafId)) {
|
||||
if (parentId) {
|
||||
this.#updateLeafId(parentId);
|
||||
} else if (childrenToReparent.length > 0) {
|
||||
this.#updateLeafId(childrenToReparent[0]);
|
||||
} else {
|
||||
this.leafId = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the DOM element(s).
|
||||
for (const deleteId of idsToDelete) {
|
||||
const $el = this.$chat.querySelector(`[data-id="${deleteId}"]`);
|
||||
if ($el) $el.remove();
|
||||
}
|
||||
|
||||
this.#saveToStorage();
|
||||
}
|
||||
|
||||
/**
|
||||
* #updateLeafId walks the tree from a starting node to find the deepest
|
||||
* leaf by following the first child at each step. Used when the active
|
||||
* leaf is deleted and a new one must be chosen. With branching, this
|
||||
* picks the first branch; future work may track which branch was active.
|
||||
* @param {string} startId
|
||||
*/
|
||||
#updateLeafId(startId) {
|
||||
let current = startId;
|
||||
while (current) {
|
||||
const msg = this.messagesMap.get(current);
|
||||
if (!msg || !msg.children || msg.children.length === 0) break;
|
||||
// Follow the first child. With branching, this picks the first branch.
|
||||
current = msg.children[0];
|
||||
}
|
||||
this.leafId = current;
|
||||
}
|
||||
|
||||
// ====================
|
||||
// EVENT HANDLERS
|
||||
// ====================
|
||||
@@ -519,6 +749,57 @@ class Odidere {
|
||||
this.$send.classList.remove('pending');
|
||||
}
|
||||
|
||||
/**
|
||||
* #handleDeleteClick toggles the pending delete state on first press
|
||||
* (yellow highlight), executes the delete on second press. Clicking
|
||||
* anywhere outside the button cancels the pending state.
|
||||
* @param {MouseEvent} event
|
||||
* @param {string} messageId
|
||||
*/
|
||||
#handleDeleteClick(event, messageId) {
|
||||
event.stopPropagation();
|
||||
const $btn = event.currentTarget;
|
||||
|
||||
if (this.pendingDeleteId === messageId) {
|
||||
this.pendingDeleteId = null;
|
||||
$btn.classList.remove('pending');
|
||||
this.deleteMessage(messageId);
|
||||
} else {
|
||||
// Clear any previously pending delete button.
|
||||
this.#clearPendingDelete();
|
||||
this.pendingDeleteId = messageId;
|
||||
$btn.classList.add('pending');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* #clearPendingDelete clears the pending delete state and removes the
|
||||
* yellow highlight from the delete button.
|
||||
*/
|
||||
#clearPendingDelete() {
|
||||
if (!this.pendingDeleteId) return;
|
||||
const $msg = this.$chat.querySelector(
|
||||
`[data-id="${this.pendingDeleteId}"]`,
|
||||
);
|
||||
if ($msg) {
|
||||
const $btn = $msg.querySelector('[data-action="delete"]');
|
||||
if ($btn) $btn.classList.remove('pending');
|
||||
}
|
||||
this.pendingDeleteId = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* #handleDeleteCancel clears the pending delete state when the user
|
||||
* clicks anywhere outside the delete button.
|
||||
* @param {MouseEvent} event
|
||||
*/
|
||||
#handleDeleteCancel(event) {
|
||||
if (!this.pendingDeleteId) return;
|
||||
const $btn = event.target.closest('[data-action="delete"]');
|
||||
if ($btn) return;
|
||||
this.#clearPendingDelete();
|
||||
}
|
||||
|
||||
/**
|
||||
* #stopConversation aborts the current streaming request and audio.
|
||||
*/
|
||||
@@ -787,9 +1068,10 @@ class Odidere {
|
||||
return;
|
||||
}
|
||||
|
||||
// Build messages array: history + current user message.
|
||||
// Build messages array from active path + current user message.
|
||||
const activePath = this.getActivePath();
|
||||
const messages = [
|
||||
...this.history.map(({ id, meta, ...msg }) => msg),
|
||||
...activePath.map(({ parent, children, id, meta, ...msg }) => msg),
|
||||
userMessage,
|
||||
];
|
||||
|
||||
@@ -810,10 +1092,10 @@ class Odidere {
|
||||
this.#clearAttachments();
|
||||
|
||||
// For text-only requests (no audio), add to history and render
|
||||
// immediately.
|
||||
// immediately. Use the returned messages (with IDs) for rendering.
|
||||
if (!audio) {
|
||||
this.#appendHistory([userMessage]);
|
||||
this.#renderMessages([userMessage]);
|
||||
const appended = this.#appendHistory([userMessage]);
|
||||
this.#renderMessages(appended);
|
||||
}
|
||||
|
||||
const res = await fetch(STREAM_ENDPOINT, {
|
||||
@@ -908,13 +1190,12 @@ class Odidere {
|
||||
message.tool_calls?.length > 0 &&
|
||||
!message.content
|
||||
) {
|
||||
const appended = this.#appendHistory([message]);
|
||||
pendingTools = {
|
||||
assistant: message,
|
||||
assistant: appended[0],
|
||||
results: [],
|
||||
expected: message.tool_calls.length,
|
||||
};
|
||||
// Add to history (server needs it) but don't render.
|
||||
this.#appendHistory([message]);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -924,9 +1205,8 @@ class Odidere {
|
||||
pendingTools &&
|
||||
pendingTools.assistant
|
||||
) {
|
||||
pendingTools.results.push(message);
|
||||
// Add to history (server needs it) but don't render yet.
|
||||
this.#appendHistory([message]);
|
||||
const appended = this.#appendHistory([message]);
|
||||
pendingTools.results.push(appended[0]);
|
||||
|
||||
// Once all tool results are in, render the combined message.
|
||||
if (pendingTools.results.length >= pendingTools.expected) {
|
||||
@@ -943,8 +1223,8 @@ class Odidere {
|
||||
const meta = {};
|
||||
if (event.voice) meta.voice = event.voice;
|
||||
|
||||
this.#appendHistory([message], meta);
|
||||
this.#renderMessages([message], meta);
|
||||
const appended = this.#appendHistory([message], meta);
|
||||
this.#renderMessages(appended, meta);
|
||||
|
||||
// For the final assistant message with audio, play it.
|
||||
if (event.audio) {
|
||||
@@ -1028,38 +1308,8 @@ class Odidere {
|
||||
}
|
||||
|
||||
// ====================
|
||||
// STATE & HISTORY
|
||||
// STATE
|
||||
// ====================
|
||||
/**
|
||||
* #appendHistory adds multiple messages to history.
|
||||
* Only the last message receives the metadata.
|
||||
* @param {Object[]} messages
|
||||
* @param {Object} [meta]
|
||||
*/
|
||||
#appendHistory(messages, meta = {}) {
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const msg = { ...messages[i] };
|
||||
|
||||
// Generate ID if not present.
|
||||
if (!msg.id) {
|
||||
msg.id = crypto.randomUUID();
|
||||
}
|
||||
|
||||
// Attach metadata only to the final message.
|
||||
if (i === messages.length - 1) {
|
||||
msg.meta = meta;
|
||||
}
|
||||
|
||||
this.history.push(msg);
|
||||
}
|
||||
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(this.history));
|
||||
} catch (e) {
|
||||
console.error('failed to save history:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* #setLoadingState updates UI to reflect loading state.
|
||||
* @param {boolean} loading
|
||||
@@ -1426,6 +1676,11 @@ class Odidere {
|
||||
this.#copyToClipboard($copy, content),
|
||||
);
|
||||
|
||||
const $delete = $msg.querySelector('[data-action="delete"]');
|
||||
$delete.addEventListener('click', (e) =>
|
||||
this.#handleDeleteClick(e, message.id),
|
||||
);
|
||||
|
||||
this.$chat.appendChild($msg);
|
||||
this.#scrollToBottom();
|
||||
}
|
||||
@@ -1502,6 +1757,11 @@ class Odidere {
|
||||
this.#copyToClipboard($copy, copyText),
|
||||
);
|
||||
|
||||
const $delete = $msg.querySelector('[data-action="delete"]');
|
||||
$delete.addEventListener('click', (e) =>
|
||||
this.#handleDeleteClick(e, message.id),
|
||||
);
|
||||
|
||||
this.$chat.appendChild($msg);
|
||||
this.#scrollToBottom();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user