File size: 8,482 Bytes
4d35814 |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 |
/**
* Message branching utilities for conversation tree navigation.
*
* Conversation branching allows users to edit messages and create alternate paths
* while preserving the original conversation flow. Each message has parent/children
* relationships forming a tree structure.
*
* Example tree:
* root
* βββ message 1 (user)
* β βββ message 2 (assistant)
* β βββ message 3 (user)
* β βββ message 6 (user) β new branch
* βββ message 4 (user)
* βββ message 5 (assistant)
*/
/**
* Filters messages to get the conversation path from root to a specific leaf node.
* If the leafNodeId doesn't exist, returns the path with the latest timestamp.
*
* @param messages - All messages in the conversation
* @param leafNodeId - The target leaf node ID to trace back from
* @param includeRoot - Whether to include root messages in the result
* @returns Array of messages from root to leaf, sorted by timestamp
*/
export function filterByLeafNodeId(
messages: readonly DatabaseMessage[],
leafNodeId: string,
includeRoot: boolean = false
): readonly DatabaseMessage[] {
const result: DatabaseMessage[] = [];
const nodeMap = new Map<string, DatabaseMessage>();
// Build node map for quick lookups
for (const msg of messages) {
nodeMap.set(msg.id, msg);
}
// Find the starting node (leaf node or latest if not found)
let startNode: DatabaseMessage | undefined = nodeMap.get(leafNodeId);
if (!startNode) {
// If leaf node not found, use the message with latest timestamp
let latestTime = -1;
for (const msg of messages) {
if (msg.timestamp > latestTime) {
startNode = msg;
latestTime = msg.timestamp;
}
}
}
// Traverse from leaf to root, collecting messages
let currentNode: DatabaseMessage | undefined = startNode;
while (currentNode) {
// Include message if it's not root, or if we want to include root
if (currentNode.type !== 'root' || includeRoot) {
result.push(currentNode);
}
// Stop traversal if parent is null (reached root)
if (currentNode.parent === null) {
break;
}
currentNode = nodeMap.get(currentNode.parent);
}
// Sort by timestamp to get chronological order (root to leaf)
result.sort((a, b) => a.timestamp - b.timestamp);
return result;
}
/**
* Finds the leaf node (message with no children) for a given message branch.
* Traverses down the tree following the last child until reaching a leaf.
*
* @param messages - All messages in the conversation
* @param messageId - Starting message ID to find leaf for
* @returns The leaf node ID, or the original messageId if no children
*/
export function findLeafNode(messages: readonly DatabaseMessage[], messageId: string): string {
const nodeMap = new Map<string, DatabaseMessage>();
// Build node map for quick lookups
for (const msg of messages) {
nodeMap.set(msg.id, msg);
}
let currentNode: DatabaseMessage | undefined = nodeMap.get(messageId);
while (currentNode && currentNode.children.length > 0) {
// Follow the last child (most recent branch)
const lastChildId = currentNode.children[currentNode.children.length - 1];
currentNode = nodeMap.get(lastChildId);
}
return currentNode?.id ?? messageId;
}
/**
* Finds all descendant messages (children, grandchildren, etc.) of a given message.
* This is used for cascading deletion to remove all messages in a branch.
*
* @param messages - All messages in the conversation
* @param messageId - The root message ID to find descendants for
* @returns Array of all descendant message IDs
*/
export function findDescendantMessages(
messages: readonly DatabaseMessage[],
messageId: string
): string[] {
const nodeMap = new Map<string, DatabaseMessage>();
// Build node map for quick lookups
for (const msg of messages) {
nodeMap.set(msg.id, msg);
}
const descendants: string[] = [];
const queue: string[] = [messageId];
while (queue.length > 0) {
const currentId = queue.shift()!;
const currentNode = nodeMap.get(currentId);
if (currentNode) {
// Add all children to the queue and descendants list
for (const childId of currentNode.children) {
descendants.push(childId);
queue.push(childId);
}
}
}
return descendants;
}
/**
* Gets sibling information for a message, including all sibling IDs and current position.
* Siblings are messages that share the same parent.
*
* @param messages - All messages in the conversation
* @param messageId - The message to get sibling info for
* @returns Sibling information including leaf node IDs for navigation
*/
export function getMessageSiblings(
messages: readonly DatabaseMessage[],
messageId: string
): ChatMessageSiblingInfo | null {
const nodeMap = new Map<string, DatabaseMessage>();
// Build node map for quick lookups
for (const msg of messages) {
nodeMap.set(msg.id, msg);
}
const message = nodeMap.get(messageId);
if (!message) {
return null;
}
// Handle null parent (root message) case
if (message.parent === null) {
// No parent means this is likely a root node with no siblings
return {
message,
siblingIds: [messageId],
currentIndex: 0,
totalSiblings: 1
};
}
const parentNode = nodeMap.get(message.parent);
if (!parentNode) {
// Parent not found - treat as single message
return {
message,
siblingIds: [messageId],
currentIndex: 0,
totalSiblings: 1
};
}
// Get all sibling IDs (including self)
const siblingIds = parentNode.children;
// Convert sibling message IDs to their corresponding leaf node IDs
// This allows navigation between different conversation branches
const siblingLeafIds = siblingIds.map((siblingId: string) => findLeafNode(messages, siblingId));
// Find current message's position among siblings
const currentIndex = siblingIds.indexOf(messageId);
return {
message,
siblingIds: siblingLeafIds,
currentIndex,
totalSiblings: siblingIds.length
};
}
/**
* Creates a display-ready list of messages with sibling information for UI rendering.
* This is the main function used by chat components to render conversation branches.
*
* @param messages - All messages in the conversation
* @param leafNodeId - Current leaf node being viewed
* @returns Array of messages with sibling navigation info
*/
export function getMessageDisplayList(
messages: readonly DatabaseMessage[],
leafNodeId: string
): ChatMessageSiblingInfo[] {
// Get the current conversation path
const currentPath = filterByLeafNodeId(messages, leafNodeId, true);
const result: ChatMessageSiblingInfo[] = [];
// Add sibling info for each message in the current path
for (const message of currentPath) {
if (message.type === 'root') {
continue; // Skip root messages in display
}
const siblingInfo = getMessageSiblings(messages, message.id);
if (siblingInfo) {
result.push(siblingInfo);
}
}
return result;
}
/**
* Checks if a message has multiple siblings (indicating branching at that point).
*
* @param messages - All messages in the conversation
* @param messageId - The message to check
* @returns True if the message has siblings
*/
export function hasMessageSiblings(
messages: readonly DatabaseMessage[],
messageId: string
): boolean {
const siblingInfo = getMessageSiblings(messages, messageId);
return siblingInfo ? siblingInfo.totalSiblings > 1 : false;
}
/**
* Gets the next sibling message ID for navigation.
*
* @param messages - All messages in the conversation
* @param messageId - Current message ID
* @returns Next sibling's leaf node ID, or null if at the end
*/
export function getNextSibling(
messages: readonly DatabaseMessage[],
messageId: string
): string | null {
const siblingInfo = getMessageSiblings(messages, messageId);
if (!siblingInfo || siblingInfo.currentIndex >= siblingInfo.totalSiblings - 1) {
return null;
}
return siblingInfo.siblingIds[siblingInfo.currentIndex + 1];
}
/**
* Gets the previous sibling message ID for navigation.
*
* @param messages - All messages in the conversation
* @param messageId - Current message ID
* @returns Previous sibling's leaf node ID, or null if at the beginning
*/
export function getPreviousSibling(
messages: readonly DatabaseMessage[],
messageId: string
): string | null {
const siblingInfo = getMessageSiblings(messages, messageId);
if (!siblingInfo || siblingInfo.currentIndex <= 0) {
return null;
}
return siblingInfo.siblingIds[siblingInfo.currentIndex - 1];
}
|