Files
homarr/packages/api/src/assistant-message-order.ts
T

102 lines
4.0 KiB
TypeScript

interface OrderableMessage {
id: string;
parentId: string | null;
}
/**
* Orders thread messages so that a parent always precedes its children.
*
* The database can only order by `created_at`, which is second-granular on MySQL and SQLite. Two
* messages written in the same second therefore tie, and the client rebuilds the conversation tree
* from `parent_id`, so a child arriving before its parent corrupts the thread. Sorting here keeps
* the relative order of siblings from the incoming (already deterministic) list.
*/
export const orderMessagesByParent = <TMessage extends OrderableMessage>(messages: TMessage[]): TMessage[] => {
if (messages.length <= 1) return messages;
const messagesById = new Map<string, TMessage>();
const incomingIndexById = new Map<string, number>();
messages.forEach((message, index) => {
messagesById.set(message.id, message);
incomingIndexById.set(message.id, index);
});
const childrenByParent = new Map<string | null, TMessage[]>();
const resolvedParentIds = new Map<string, string | null>();
for (const message of messages) {
// Treat a dangling parent reference as a root so the message is never dropped.
const parentId = message.parentId !== null && messagesById.has(message.parentId) ? message.parentId : null;
resolvedParentIds.set(message.id, parentId);
const siblings = childrenByParent.get(parentId);
if (siblings) siblings.push(message);
else childrenByParent.set(parentId, [message]);
}
const ordered: TMessage[] = [];
const visited = new Set<string>();
// A conversation is one long parent chain, so the traversal depth grows with the message count.
// An explicit stack keeps a long thread from exhausting the call stack.
const visitFrom = (start: TMessage) => {
const stack: TMessage[] = [start];
while (stack.length > 0) {
const message = stack.pop();
if (!message || visited.has(message.id)) continue;
visited.add(message.id);
ordered.push(message);
const children = childrenByParent.get(message.id);
if (!children) continue;
// Pushed in reverse so the stack pops siblings back in their incoming order.
for (let index = children.length - 1; index >= 0; index--) {
const child = children[index];
if (child) stack.push(child);
}
}
};
for (const root of childrenByParent.get(null) ?? []) {
visitFrom(root);
}
// Whatever is left belongs to a parent cycle, which by definition has no root to start from.
// Corrupted data should still come back complete and in a sensible order, so each cycle is
// broken at its earliest member in the incoming order and traversed from there. Only that one
// back-edge can violate parent-before-child; every other message in the component is fine.
const findCycleEntry = (start: TMessage) => {
const path: TMessage[] = [start];
const positionInPath = new Map<string, number>([[start.id, 0]]);
let current = start;
for (;;) {
const parentId = resolvedParentIds.get(current.id) ?? null;
if (parentId === null || visited.has(parentId)) return current;
const parent = messagesById.get(parentId);
if (!parent) return current;
const loopStart = positionInPath.get(parent.id);
if (loopStart !== undefined) {
// Closed the loop: the cycle is the tail of the walked path. Start from whichever of its
// members came first in the incoming list so the choice is deterministic.
return path.slice(loopStart).reduce((earliest, member) => {
const memberIndex = incomingIndexById.get(member.id) ?? 0;
const earliestIndex = incomingIndexById.get(earliest.id) ?? 0;
return memberIndex < earliestIndex ? member : earliest;
});
}
positionInPath.set(parent.id, path.length);
path.push(parent);
current = parent;
}
};
for (const message of messages) {
if (visited.has(message.id)) continue;
visitFrom(findCycleEntry(message));
}
return ordered;
};