274 lines
7.4 KiB
JavaScript
274 lines
7.4 KiB
JavaScript
/**
|
|
* 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);
|
|
}
|
|
}
|