Files
odidere/internal/service/static/conversation.js
2026-07-05 00:48:56 +00:00

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);
}
}