Add delete message button
This commit is contained in:
@@ -105,6 +105,12 @@
|
|||||||
<path d="m5 12 7-7 7 7"/>
|
<path d="m5 12 7-7 7 7"/>
|
||||||
</symbol>
|
</symbol>
|
||||||
|
|
||||||
|
<symbol id="trash" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M3 6h18"/>
|
||||||
|
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/>
|
||||||
|
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/>
|
||||||
|
</symbol>
|
||||||
|
|
||||||
<symbol id="settings" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<symbol id="settings" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/>
|
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/>
|
||||||
<circle cx="12" cy="12" r="3"/>
|
<circle cx="12" cy="12" r="3"/>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 6.7 KiB |
@@ -579,6 +579,23 @@ body {
|
|||||||
color: var(--color-primary);
|
color: var(--color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (hover: hover) {
|
||||||
|
.message__action-btn--delete:hover {
|
||||||
|
color: var(--color-red);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pending delete: yellow highlight, click again to confirm */
|
||||||
|
.message__action-btn--delete.pending {
|
||||||
|
color: var(--color-yellow);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: hover) {
|
||||||
|
.message__action-btn--delete.pending:hover {
|
||||||
|
color: var(--color-yellow);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.message__actions {
|
.message__actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ class Odidere {
|
|||||||
this.currentAudio = null;
|
this.currentAudio = null;
|
||||||
this.currentAudioUrl = null;
|
this.currentAudioUrl = null;
|
||||||
this.currentController = null;
|
this.currentController = null;
|
||||||
this.history = [];
|
|
||||||
this.isProcessing = false;
|
this.isProcessing = false;
|
||||||
this.isRecording = false;
|
this.isRecording = false;
|
||||||
this.isMuted = false;
|
this.isMuted = false;
|
||||||
@@ -33,6 +32,11 @@ class Odidere {
|
|||||||
this.isStopPending = false;
|
this.isStopPending = false;
|
||||||
this.mediaRecorder = null;
|
this.mediaRecorder = null;
|
||||||
this.wakeLock = 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,
|
// Auto-scroll: enabled by default, disabled when user scrolls up,
|
||||||
// re-enabled when they scroll back to bottom or send a new message.
|
// re-enabled when they scroll back to bottom or send a new message.
|
||||||
@@ -105,7 +109,10 @@ class Odidere {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.#setLoadingState(false);
|
this.#setLoadingState(false);
|
||||||
this.history = [];
|
this.pendingDeleteId = null;
|
||||||
|
this.messagesMap.clear();
|
||||||
|
this.leafId = null;
|
||||||
|
this.rootId = null;
|
||||||
this.isAutoScrollEnabled = true;
|
this.isAutoScrollEnabled = true;
|
||||||
this._lastScrollTop = 0;
|
this._lastScrollTop = 0;
|
||||||
localStorage.removeItem(STORAGE_KEY);
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
@@ -167,6 +174,7 @@ class Odidere {
|
|||||||
// scroll-to-top.
|
// scroll-to-top.
|
||||||
this.$scroll.addEventListener('click', () => this.#handleScrollClick());
|
this.$scroll.addEventListener('click', () => this.#handleScrollClick());
|
||||||
this.document.addEventListener('click', (e) => this.#handleScrollCancel(e));
|
this.document.addEventListener('click', (e) => this.#handleScrollCancel(e));
|
||||||
|
this.document.addEventListener('click', (e) => this.#handleDeleteCancel(e));
|
||||||
|
|
||||||
// File attachment.
|
// File attachment.
|
||||||
this.$attach.addEventListener('click', () => this.$fileInput.click());
|
this.$attach.addEventListener('click', () => this.$fileInput.click());
|
||||||
@@ -223,6 +231,10 @@ class Odidere {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ====================
|
||||||
|
// CONVERSATION TREE
|
||||||
|
// =====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* #loadHistory loads chat history from localStorage and renders it.
|
* #loadHistory loads chat history from localStorage and renders it.
|
||||||
*/
|
*/
|
||||||
@@ -231,14 +243,232 @@ class Odidere {
|
|||||||
const stored = localStorage.getItem(STORAGE_KEY);
|
const stored = localStorage.getItem(STORAGE_KEY);
|
||||||
if (!stored) return;
|
if (!stored) return;
|
||||||
|
|
||||||
this.history = JSON.parse(stored);
|
const parsed = JSON.parse(stored);
|
||||||
this.#renderMessages(this.history);
|
|
||||||
|
// 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) {
|
} catch (e) {
|
||||||
console.error('failed to load history:', 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
|
// EVENT HANDLERS
|
||||||
// ====================
|
// ====================
|
||||||
@@ -519,6 +749,57 @@ class Odidere {
|
|||||||
this.$send.classList.remove('pending');
|
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.
|
* #stopConversation aborts the current streaming request and audio.
|
||||||
*/
|
*/
|
||||||
@@ -787,9 +1068,10 @@ class Odidere {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build messages array: history + current user message.
|
// Build messages array from active path + current user message.
|
||||||
|
const activePath = this.getActivePath();
|
||||||
const messages = [
|
const messages = [
|
||||||
...this.history.map(({ id, meta, ...msg }) => msg),
|
...activePath.map(({ parent, children, id, meta, ...msg }) => msg),
|
||||||
userMessage,
|
userMessage,
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -810,10 +1092,10 @@ class Odidere {
|
|||||||
this.#clearAttachments();
|
this.#clearAttachments();
|
||||||
|
|
||||||
// For text-only requests (no audio), add to history and render
|
// For text-only requests (no audio), add to history and render
|
||||||
// immediately.
|
// immediately. Use the returned messages (with IDs) for rendering.
|
||||||
if (!audio) {
|
if (!audio) {
|
||||||
this.#appendHistory([userMessage]);
|
const appended = this.#appendHistory([userMessage]);
|
||||||
this.#renderMessages([userMessage]);
|
this.#renderMessages(appended);
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await fetch(STREAM_ENDPOINT, {
|
const res = await fetch(STREAM_ENDPOINT, {
|
||||||
@@ -908,13 +1190,12 @@ class Odidere {
|
|||||||
message.tool_calls?.length > 0 &&
|
message.tool_calls?.length > 0 &&
|
||||||
!message.content
|
!message.content
|
||||||
) {
|
) {
|
||||||
|
const appended = this.#appendHistory([message]);
|
||||||
pendingTools = {
|
pendingTools = {
|
||||||
assistant: message,
|
assistant: appended[0],
|
||||||
results: [],
|
results: [],
|
||||||
expected: message.tool_calls.length,
|
expected: message.tool_calls.length,
|
||||||
};
|
};
|
||||||
// Add to history (server needs it) but don't render.
|
|
||||||
this.#appendHistory([message]);
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -924,9 +1205,8 @@ class Odidere {
|
|||||||
pendingTools &&
|
pendingTools &&
|
||||||
pendingTools.assistant
|
pendingTools.assistant
|
||||||
) {
|
) {
|
||||||
pendingTools.results.push(message);
|
const appended = this.#appendHistory([message]);
|
||||||
// Add to history (server needs it) but don't render yet.
|
pendingTools.results.push(appended[0]);
|
||||||
this.#appendHistory([message]);
|
|
||||||
|
|
||||||
// Once all tool results are in, render the combined message.
|
// Once all tool results are in, render the combined message.
|
||||||
if (pendingTools.results.length >= pendingTools.expected) {
|
if (pendingTools.results.length >= pendingTools.expected) {
|
||||||
@@ -943,8 +1223,8 @@ class Odidere {
|
|||||||
const meta = {};
|
const meta = {};
|
||||||
if (event.voice) meta.voice = event.voice;
|
if (event.voice) meta.voice = event.voice;
|
||||||
|
|
||||||
this.#appendHistory([message], meta);
|
const appended = this.#appendHistory([message], meta);
|
||||||
this.#renderMessages([message], meta);
|
this.#renderMessages(appended, meta);
|
||||||
|
|
||||||
// For the final assistant message with audio, play it.
|
// For the final assistant message with audio, play it.
|
||||||
if (event.audio) {
|
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.
|
* #setLoadingState updates UI to reflect loading state.
|
||||||
* @param {boolean} loading
|
* @param {boolean} loading
|
||||||
@@ -1426,6 +1676,11 @@ class Odidere {
|
|||||||
this.#copyToClipboard($copy, content),
|
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.$chat.appendChild($msg);
|
||||||
this.#scrollToBottom();
|
this.#scrollToBottom();
|
||||||
}
|
}
|
||||||
@@ -1502,6 +1757,11 @@ class Odidere {
|
|||||||
this.#copyToClipboard($copy, copyText),
|
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.$chat.appendChild($msg);
|
||||||
this.#scrollToBottom();
|
this.#scrollToBottom();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,14 @@
|
|||||||
<div class="message__actions">
|
<div class="message__actions">
|
||||||
<svg class="message__icon" aria-hidden="true"><use href="/static/icons.svg#user"></use></svg>
|
<svg class="message__icon" aria-hidden="true"><use href="/static/icons.svg#user"></use></svg>
|
||||||
<div class="message__actions-buttons">
|
<div class="message__actions-buttons">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="message__action-btn message__action-btn--delete"
|
||||||
|
data-action="delete"
|
||||||
|
aria-label="Delete message"
|
||||||
|
>
|
||||||
|
<svg class="icon"><use href="/static/icons.svg#trash"></use></svg>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="message__action-btn"
|
class="message__action-btn"
|
||||||
@@ -39,6 +47,14 @@
|
|||||||
<div class="message__actions">
|
<div class="message__actions">
|
||||||
<svg class="message__icon" aria-hidden="true"><use href="/static/icons.svg#assistant"></use></svg>
|
<svg class="message__icon" aria-hidden="true"><use href="/static/icons.svg#assistant"></use></svg>
|
||||||
<div class="message__actions-buttons">
|
<div class="message__actions-buttons">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="message__action-btn message__action-btn--delete"
|
||||||
|
data-action="delete"
|
||||||
|
aria-label="Delete message"
|
||||||
|
>
|
||||||
|
<svg class="icon"><use href="/static/icons.svg#trash"></use></svg>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="message__action-btn"
|
class="message__action-btn"
|
||||||
|
|||||||
Reference in New Issue
Block a user