pluralchat / src /lib /utils /message /ConversationTreeManager.ts
Andrew
feat(tree): Add ELK port-based layout and persona-specific branching
cb5990d
import type { Message, MessageFile } from "$lib/types/Message";
import { MessageRole } from "$lib/types/Message";
import { addChildren } from "$lib/utils/tree/addChildren";
import { addSibling } from "$lib/utils/tree/addSibling";
import type { WriteMessageContext, WriteMessageParams } from "$lib/types/MessageContext";
export class ConversationTreeManager {
constructor(private ctx: WriteMessageContext) {}
public prepareMessageForWrite(
params: WriteMessageParams,
base64Files: MessageFile[] = []
): {
messageToWriteToId: string;
navigateToMessageId: string | null;
} {
const {
prompt,
messageId = this.ctx.messagesPath.at(-1)?.id ?? undefined,
isRetry = false,
isContinue = false,
personaId,
} = params;
let messageToWriteToId: string | undefined;
let navigateToMessageId: string | null = null;
if (isContinue && messageId) {
const msg = this.ctx.messages.find((m) => m.id === messageId);
if ((msg?.children?.length ?? 0) > 0) {
throw new Error("Can only continue the last message");
}
messageToWriteToId = messageId;
} else if (isRetry && messageId) {
const messageToRetry = this.ctx.messages.find((m) => m.id === messageId);
if (!messageToRetry) {
throw new Error("Message not found");
}
if (messageToRetry.from === MessageRole.User && prompt !== undefined) {
const newUserMessageId = addSibling(
{
messages: this.ctx.messages,
rootMessageId: this.ctx.data.rootMessageId,
},
{
from: MessageRole.User,
content: prompt,
files: messageToRetry.files,
...(messageToRetry.branchedFrom && {
branchedFrom: messageToRetry.branchedFrom,
}),
},
messageId
);
const targetPersonaId =
this.ctx.branchState?.personaId || messageToRetry.branchedFrom?.personaId;
const initialResponses: Message["personaResponses"] = [];
if (targetPersonaId) {
const persona = this.ctx.settings.personas?.find((p) => p.id === targetPersonaId);
initialResponses.push({
personaId: targetPersonaId,
personaName: this.ctx.branchState?.personaName || persona?.name || targetPersonaId,
content: "",
});
}
messageToWriteToId = addChildren(
{
messages: this.ctx.messages,
rootMessageId: this.ctx.data.rootMessageId,
},
{
from: MessageRole.Assistant,
content: "",
personaResponses: initialResponses,
...((this.ctx.branchState || messageToRetry.branchedFrom) && {
branchedFrom: this.ctx.branchState
? {
messageId: this.ctx.branchState.messageId,
personaId: this.ctx.branchState.personaId,
}
: messageToRetry.branchedFrom,
}),
},
newUserMessageId
);
if (messageToRetry.branchedFrom && !this.ctx.branchState) {
const persona = this.ctx.settings.personas?.find(
(p) => p.id === messageToRetry.branchedFrom?.personaId
);
this.ctx.updateBranchState({
messageId: messageToRetry.branchedFrom.messageId,
personaId: messageToRetry.branchedFrom.personaId,
personaName: persona?.name || messageToRetry.branchedFrom.personaId,
});
navigateToMessageId = newUserMessageId;
}
this.ctx.onMessageCreated?.(messageToWriteToId);
} else if (messageToRetry.from === MessageRole.User && prompt === undefined) {
messageToWriteToId = addChildren(
{
messages: this.ctx.messages,
rootMessageId: this.ctx.data.rootMessageId,
},
{
from: MessageRole.Assistant,
content: "",
personaResponses: [],
...(this.ctx.branchState && {
branchedFrom: {
messageId: this.ctx.branchState.messageId,
personaId: this.ctx.branchState.personaId,
},
}),
},
messageId
);
navigateToMessageId = messageToWriteToId;
this.ctx.onMessageCreated?.(messageToWriteToId);
} else if (messageToRetry.from === MessageRole.Assistant) {
let initialPersonaResponses: Message["personaResponses"] = [];
if (personaId && messageToRetry.personaResponses) {
initialPersonaResponses = messageToRetry.personaResponses.map((p) => {
if (p.personaId === personaId) {
return {
...p,
content: "",
interrupted: undefined,
reasoning: undefined,
updates: undefined,
routerMetadata: undefined,
};
}
// Defensive copy using structuredClone
return structuredClone(p);
});
}
messageToWriteToId = addSibling(
{
messages: this.ctx.messages,
rootMessageId: this.ctx.data.rootMessageId,
},
{
from: MessageRole.Assistant,
content: "",
personaResponses: initialPersonaResponses,
...((this.ctx.branchState || messageToRetry.branchedFrom) && {
branchedFrom: this.ctx.branchState
? {
messageId: this.ctx.branchState.messageId,
personaId: this.ctx.branchState.personaId,
}
: messageToRetry.branchedFrom,
}),
},
messageId
);
if (messageToRetry.branchedFrom && !this.ctx.branchState) {
const persona = this.ctx.settings.personas?.find(
(p) => p.id === messageToRetry.branchedFrom?.personaId
);
this.ctx.updateBranchState({
messageId: messageToRetry.branchedFrom.messageId,
personaId: messageToRetry.branchedFrom.personaId,
personaName: persona?.name || messageToRetry.branchedFrom.personaId,
});
navigateToMessageId = messageToWriteToId;
}
this.ctx.onMessageCreated?.(messageToWriteToId);
}
} else {
// New message
const newUserMessageId = addChildren(
{
messages: this.ctx.messages,
rootMessageId: this.ctx.data.rootMessageId,
},
{
from: MessageRole.User,
content: prompt ?? "",
files: base64Files,
...(this.ctx.branchState && {
branchedFrom: {
messageId: this.ctx.branchState.messageId,
personaId: this.ctx.branchState.personaId,
},
}),
},
messageId
);
if (!this.ctx.data.rootMessageId) {
this.ctx.data.rootMessageId = newUserMessageId;
}
messageToWriteToId = addChildren(
{
messages: this.ctx.messages,
rootMessageId: this.ctx.data.rootMessageId,
},
{
from: MessageRole.Assistant,
content: "",
personaResponses: [],
...(this.ctx.branchState && {
branchedFrom: {
messageId: this.ctx.branchState.messageId,
personaId: this.ctx.branchState.personaId,
},
}),
},
newUserMessageId
);
this.ctx.onMessageCreated?.(messageToWriteToId);
}
if (!messageToWriteToId) {
throw new Error("Failed to determine message ID to write to");
}
return { messageToWriteToId, navigateToMessageId };
}
/**
* Safely updates a message ID in the tree, ensuring parent linkage is maintained.
*/
public syncMessageId(oldId: string, newId: string): void {
const message = this.ctx.messages.find((m) => m.id === oldId || m.id === newId);
if (!message) return;
// If ID is already updated, just return
if (message.id === newId) return;
message.id = newId;
if (message.ancestors && message.ancestors.length > 0) {
const parentId = message.ancestors[message.ancestors.length - 1];
const parent = this.ctx.messages.find((m) => m.id === parentId);
if (parent) {
if (!parent.children) parent.children = [];
const childIndex = parent.children.indexOf(oldId);
if (childIndex !== -1) {
parent.children[childIndex] = newId;
} else {
// Fallback: append if not found
console.warn(
`[TreeManager] Parent ${parentId} missing child ${oldId}, appending ${newId}`
);
parent.children.push(newId);
}
} else {
console.error(
`[TreeManager] Parent ${parentId} not found for message ${oldId} -> ${newId}`
);
}
}
console.debug(`[TreeManager] Synced message ID: ${oldId} -> ${newId}`);
}
}