Add Markdown support
This commit is contained in:
273
internal/service/static/js/conversation.js
Normal file
273
internal/service/static/js/conversation.js
Normal file
@@ -0,0 +1,273 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user