/** * ConversationTree is a pure data model for the conversation message tree. * It owns the message map, tree structure (parent/children), and localStorage * persistence. No DOM, no rendering. */ const STORAGE_KEY = 'odidere_history'; /** * extractText extracts plain text from a message content field. * Content can be a string or an array of content-part objects. * @param {string|Array} content * @returns {string} */ export function extractText(content) { if (typeof content === 'string') return content; if (Array.isArray(content)) { return content .filter((p) => p.type === 'text') .map((p) => p.text) .join('\n'); } return ''; } export class ConversationTree { constructor() { this.messagesMap = new Map(); this.leafId = null; this.rootId = null; } /** * get returns a message by ID. * @param {string} id * @returns {Object|undefined} */ get(id) { return this.messagesMap.get(id); } /** * 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; } /** @returns {number} number of messages in the tree */ get size() { return this.messagesMap.size; } /** * append 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. Returns the same messages, now enriched. * @param {Object[]} messages * @param {Object} [meta] * @returns {Object[]} the same message objects, now enriched */ append(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.save(); return messages; } /** * delete 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 */ delete(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. 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. 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. if (idsToDelete.has(this.leafId)) { if (parentId) { this.updateLeafId(parentId); } else if (childrenToReparent.length > 0) { this.updateLeafId(childrenToReparent[0]); } else { this.leafId = null; } } this.save(); } /** * updateLeafId walks the tree from a starting node to find the deepest * leaf by following the first child at each step. * @param {string} startId */ updateLeafId(startId) { let current = startId; while (current) { const msg = this.messagesMap.get(current); if (!msg?.children || msg.children.length === 0) break; current = msg.children[0]; } this.leafId = current; } /** * load reads chat history from localStorage and populates the map. */ load() { try { const stored = localStorage.getItem(STORAGE_KEY); if (!stored) return; 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; } catch (e) { console.error('failed to load history:', e); this.messagesMap.clear(); this.leafId = null; this.rootId = null; } } /** * save persists the tree to localStorage. */ save() { 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); } } /** * clear wipes the map and localStorage. */ clear() { this.messagesMap.clear(); this.leafId = null; this.rootId = null; localStorage.removeItem(STORAGE_KEY); } }