A React Native app for the ultimate thinking partner.
at main 566 lines 18 kB view raw
1/** 2 * useMessageGroups Hook 3 * 4 * Transforms raw Letta messages into unified MessageGroup objects for rendering. 5 * 6 * WHAT IT DOES: 7 * - Groups messages by ID (reasoning + assistant share ID → single group) 8 * - Pairs tool calls with tool returns automatically 9 * - Extracts compaction alerts from user messages 10 * - Parses multipart user messages (text + images) 11 * - Appends streaming group as temporary FlatList item 12 * 13 * WHY IT EXISTS: 14 * Before: Reasoning and assistant messages were separate FlatList items, 15 * requiring complex pairing logic in the render component. 16 * After: One MessageGroup per logical message turn, with reasoning co-located. 17 * 18 * STREAMING BEHAVIOR: 19 * - While streaming: Appends temporary group (id='streaming', groupKey='streaming-assistant') 20 * - Server refresh: Replaces with real messages (different groupKeys prevent flashing) 21 * 22 * This hook is pure - no side effects, just data transformation. 23 */ 24 25import { useMemo } from 'react'; 26import type { LettaMessage } from '../types/letta'; 27 28/** 29 * Unified message group for rendering 30 */ 31export interface MessageGroup { 32 // Identification 33 id: string; // Original message ID (or 'streaming') 34 groupKey: string; // Unique key for FlatList (id + type) 35 36 // Type determines rendering component 37 type: 'user' | 'assistant' | 'tool_call' | 'tool_return_orphaned' | 'compaction'; 38 39 // Universal content 40 content: string; 41 reasoning?: string; 42 43 // Tool-specific 44 toolCall?: { 45 name: string; 46 args: string; // Python-formatted: "search(query=\"foo\")" 47 }; 48 toolReturn?: string; 49 50 // User-specific (multipart messages) 51 images?: Array<{ 52 type: string; 53 source: { 54 type: string; 55 data: string; 56 mediaType?: string; 57 media_type?: string; 58 url?: string; 59 }; 60 }>; 61 62 // Compaction-specific 63 compactionMessage?: string; 64 65 // Metadata 66 created_at: string; 67 role: 'user' | 'assistant' | 'system' | 'tool'; 68 69 // Streaming indicator 70 isStreaming?: boolean; 71} 72 73/** 74 * Simple streaming message 75 */ 76interface StreamingMessage { 77 id: string; 78 reasoning: string; 79 content: string; 80 type: 'tool_call' | 'assistant' | null; 81 toolCallName?: string; 82 timestamp: string; 83} 84 85interface UseMessageGroupsParams { 86 messages: LettaMessage[]; 87 isStreaming: boolean; 88 currentStreamingMessage: StreamingMessage | null; 89 completedStreamingMessages: StreamingMessage[]; 90} 91 92/** 93 * Group messages by ID into unified MessageGroup objects 94 */ 95export function useMessageGroups({ 96 messages, 97 isStreaming, 98 currentStreamingMessage, 99 completedStreamingMessages, 100}: UseMessageGroupsParams): MessageGroup[] { 101 return useMemo(() => { 102 // Step 1: Filter out system messages and login/heartbeat 103 const filteredMessages = messages.filter((msg) => { 104 if (msg.message_type === 'system_message') return false; 105 106 // Filter login/heartbeat user messages 107 if (msg.message_type === 'user_message' && msg.content) { 108 try { 109 const contentStr = typeof msg.content === 'string' 110 ? msg.content 111 : JSON.stringify(msg.content); 112 const parsed = JSON.parse(contentStr); 113 if (parsed?.type === 'login' || parsed?.type === 'heartbeat') { 114 return false; 115 } 116 } catch { 117 // Not JSON, keep it 118 } 119 } 120 121 return true; 122 }); 123 124 // Step 2: Sort chronologically 125 const sorted = [...filteredMessages].sort((a, b) => { 126 const timeA = new Date(a.created_at || 0).getTime(); 127 const timeB = new Date(b.created_at || 0).getTime(); 128 return timeA - timeB; 129 }); 130 131 // Step 3: Group by ID 132 const groupedById = new Map<string, LettaMessage[]>(); 133 for (const msg of sorted) { 134 if (!groupedById.has(msg.id)) { 135 groupedById.set(msg.id, []); 136 } 137 groupedById.get(msg.id)!.push(msg); 138 } 139 140 // Step 4: Convert each ID group to MessageGroup 141 const groups: MessageGroup[] = []; 142 143 for (const [id, messagesInGroup] of Array.from(groupedById.entries())) { 144 const group = createMessageGroup(id, messagesInGroup); 145 if (group) { 146 groups.push(group); 147 } 148 } 149 150 // Step 4.5: Remove assistant groups that have a tool call in the same step 151 // When reasoning → assistant → tool call happen in the same step, we only want to show the tool call 152 const stepIdToGroups = new Map<string, MessageGroup[]>(); 153 for (const group of groups) { 154 const msg = sorted.find(m => m.id === group.id); 155 const stepId = extractStepId(msg); 156 if (stepId) { 157 if (!stepIdToGroups.has(stepId)) { 158 stepIdToGroups.set(stepId, []); 159 } 160 stepIdToGroups.get(stepId)!.push(group); 161 } 162 } 163 164 // Remove assistant groups if there's a tool_call group in the same step 165 const groupsToRemove = new Set<string>(); 166 for (const [stepId, stepGroups] of stepIdToGroups.entries()) { 167 const hasToolCall = stepGroups.some(g => g.type === 'tool_call'); 168 if (hasToolCall) { 169 // Remove any assistant groups in this step (tool call supersedes) 170 for (const group of stepGroups) { 171 if (group.type === 'assistant') { 172 groupsToRemove.add(group.id); 173 } 174 } 175 } 176 } 177 178 // Filter out the groups marked for removal 179 const filteredGroups = groups.filter(g => !groupsToRemove.has(g.id)); 180 181 // Step 4.6: Pair orphaned tool returns with their tool calls 182 // Letta uses different IDs for tool_call_message and tool_return_message, 183 // but they share the same step_id - that's how we link them 184 const toolCallGroups = new Map<string, MessageGroup>(); 185 const orphanedReturns = new Map<string, MessageGroup>(); 186 187 // First pass: index tool calls and orphaned returns by step_id 188 for (const group of filteredGroups) { 189 if (group.type === 'tool_call') { 190 const msg = sorted.find(m => m.id === group.id); 191 const stepId = extractStepId(msg); 192 if (stepId) { 193 toolCallGroups.set(stepId, group); 194 } 195 } else if (group.type === 'tool_return_orphaned') { 196 const msg = sorted.find(m => m.id === group.id); 197 const stepId = extractStepId(msg); 198 if (stepId) { 199 orphanedReturns.set(stepId, group); 200 } 201 } 202 } 203 204 // Second pass: pair tool calls with their returns 205 for (const [stepId, returnGroup] of orphanedReturns.entries()) { 206 const callGroup = toolCallGroups.get(stepId); 207 if (callGroup && !callGroup.toolReturn) { 208 // Merge the return into the call group 209 callGroup.toolReturn = returnGroup.content; 210 211 // Remove the orphaned return from filtered groups array 212 const returnIndex = filteredGroups.findIndex(g => g.id === returnGroup.id); 213 if (returnIndex !== -1) { 214 filteredGroups.splice(returnIndex, 1); 215 } 216 } 217 } 218 219 // Step 5: Sort groups by created_at 220 filteredGroups.sort((a, b) => { 221 const timeA = new Date(a.created_at || 0).getTime(); 222 const timeB = new Date(b.created_at || 0).getTime(); 223 return timeA - timeB; 224 }); 225 226 // Step 6: Add completed streaming messages (finished but stream still active) 227 console.log('📊 Completed streaming messages:', completedStreamingMessages.length); 228 completedStreamingMessages.forEach((msg, index) => { 229 const group: MessageGroup = { 230 id: msg.id, 231 groupKey: `streaming-completed-${msg.id}`, 232 type: msg.type === 'tool_call' ? 'tool_call' : 'assistant', 233 content: msg.content, 234 reasoning: msg.reasoning || undefined, 235 created_at: msg.timestamp, 236 role: 'assistant', 237 isStreaming: false, // It's done, just not persisted yet 238 }; 239 240 if (msg.type === 'tool_call' && msg.toolCallName) { 241 group.toolCall = { 242 name: msg.toolCallName, 243 args: msg.content, 244 }; 245 } 246 247 filteredGroups.push(group); 248 console.log(` ✅ [${index}] ${msg.type}:`, msg.content.substring(0, 40)); 249 }); 250 251 // Step 7: Add current accumulating message (if any) 252 if (currentStreamingMessage) { 253 const group: MessageGroup = { 254 id: currentStreamingMessage.id, 255 groupKey: `streaming-current-${currentStreamingMessage.id}`, 256 type: currentStreamingMessage.type === 'tool_call' ? 'tool_call' : 'assistant', 257 content: currentStreamingMessage.content, 258 reasoning: currentStreamingMessage.reasoning || undefined, 259 created_at: currentStreamingMessage.timestamp, 260 role: 'assistant', 261 isStreaming: true, // Still accumulating 262 }; 263 264 if (currentStreamingMessage.type === 'tool_call' && currentStreamingMessage.toolCallName) { 265 group.toolCall = { 266 name: currentStreamingMessage.toolCallName, 267 args: currentStreamingMessage.content, 268 }; 269 } 270 271 filteredGroups.push(group); 272 console.log(' 🔄 Current streaming:', currentStreamingMessage.type, currentStreamingMessage.content.substring(0, 40)); 273 } 274 275 console.log('📊 FINAL GROUP COUNT:', filteredGroups.length, 'groups'); 276 return filteredGroups; 277 }, [messages, isStreaming, currentStreamingMessage, completedStreamingMessages]); 278} 279 280/** 281 * Create a MessageGroup from messages with the same ID 282 */ 283function createMessageGroup( 284 id: string, 285 messagesInGroup: LettaMessage[] 286): MessageGroup | null { 287 if (messagesInGroup.length === 0) return null; 288 289 // Find message types in this group 290 const userMsg = messagesInGroup.find((m) => m.message_type === 'user_message'); 291 const assistantMsg = messagesInGroup.find((m) => m.message_type === 'assistant_message'); 292 const toolCallMsg = messagesInGroup.find((m) => m.message_type === 'tool_call_message'); 293 const toolReturnMsg = messagesInGroup.find((m) => m.message_type === 'tool_return_message'); 294 295 // CRITICAL FIX: When a group has BOTH assistant AND tool_call (with 2 reasoning messages), 296 // the tool call should get the LAST reasoning (the one right before the tool call) 297 const allReasoningMsgs = messagesInGroup.filter((m) => m.message_type === 'reasoning_message'); 298 let reasoningMsg: LettaMessage | undefined; 299 300 if (allReasoningMsgs.length === 0) { 301 reasoningMsg = undefined; 302 } else if (allReasoningMsgs.length === 1 || !toolCallMsg) { 303 // Single reasoning OR no tool call → use first reasoning 304 reasoningMsg = allReasoningMsgs[0]; 305 } else { 306 // Multiple reasoning messages AND we have a tool call → use LAST reasoning 307 reasoningMsg = allReasoningMsgs[allReasoningMsgs.length - 1]; 308 } 309 310 // Use first message for metadata 311 const firstMsg = messagesInGroup[0]; 312 313 // ======================================== 314 // USER MESSAGE 315 // ======================================== 316 if (userMsg) { 317 // Check for compaction alert 318 const compactionInfo = extractCompactionInfo(userMsg.content); 319 if (compactionInfo.isCompaction) { 320 return { 321 id, 322 groupKey: `${id}-compaction`, 323 type: 'compaction', 324 content: compactionInfo.message, 325 compactionMessage: compactionInfo.message, 326 created_at: userMsg.created_at, 327 role: userMsg.role, 328 }; 329 } 330 331 // Regular user message 332 const { textContent, images } = parseUserContent(userMsg.content); 333 334 // Skip if no content 335 if (!textContent.trim() && images.length === 0) { 336 return null; 337 } 338 339 return { 340 id, 341 groupKey: `${id}-user`, 342 type: 'user', 343 content: textContent, 344 images: images.length > 0 ? images : undefined, 345 created_at: userMsg.created_at, 346 role: userMsg.role, 347 }; 348 } 349 350 // ======================================== 351 // TOOL CALL MESSAGE 352 // ======================================== 353 if (toolCallMsg) { 354 const toolCall = parseToolCall(toolCallMsg); 355 const stepId = extractStepId(toolCallMsg); 356 357 // CRITICAL: Use step_id for groupKey, not message ID 358 // Multiple tool calls can share the same message ID but have different step_ids 359 const groupKey = stepId ? `${stepId}-tool_call` : `${id}-tool_call`; 360 361 return { 362 id, 363 groupKey, 364 type: 'tool_call', 365 content: toolCall.args, // The formatted args string 366 reasoning: reasoningMsg?.reasoning || toolCallMsg?.reasoning, 367 toolCall: { 368 name: toolCall.name, 369 args: toolCall.args, 370 }, 371 toolReturn: toolReturnMsg?.content || undefined, 372 created_at: toolCallMsg.created_at, 373 role: toolCallMsg.role, 374 }; 375 } 376 377 // ======================================== 378 // ORPHANED TOOL RETURN 379 // ======================================== 380 if (toolReturnMsg && !toolCallMsg) { 381 return { 382 id, 383 groupKey: `${id}-tool_return_orphaned`, 384 type: 'tool_return_orphaned', 385 content: toolReturnMsg.content, 386 created_at: toolReturnMsg.created_at, 387 role: toolReturnMsg.role, 388 }; 389 } 390 391 // ======================================== 392 // ASSISTANT MESSAGE 393 // ======================================== 394 if (assistantMsg) { 395 return { 396 id, 397 groupKey: `${id}-assistant`, 398 type: 'assistant', 399 content: assistantMsg.content, 400 reasoning: reasoningMsg?.reasoning || assistantMsg?.reasoning, 401 created_at: assistantMsg.created_at, 402 role: assistantMsg.role, 403 }; 404 } 405 406 // ======================================== 407 // STANDALONE REASONING (edge case) 408 // ======================================== 409 if (reasoningMsg) { 410 // Reasoning without assistant message - treat as assistant with empty content 411 return { 412 id, 413 groupKey: `${id}-assistant`, 414 type: 'assistant', 415 content: '', 416 reasoning: reasoningMsg.reasoning, 417 created_at: reasoningMsg.created_at, 418 role: 'assistant', 419 }; 420 } 421 422 // Unknown message type - skip 423 return null; 424} 425 426/** 427 * Extract compaction info from user message content 428 */ 429function extractCompactionInfo(content: any): { 430 isCompaction: boolean; 431 message: string; 432} { 433 try { 434 const contentStr = typeof content === 'string' ? content : JSON.stringify(content); 435 const parsed = JSON.parse(contentStr); 436 437 if (parsed?.type === 'system_alert') { 438 let messageText = parsed.message || ''; 439 440 // Try to extract JSON from code block 441 const jsonMatch = messageText.match(/```json\s*(\{[\s\S]*?\})\s*```/); 442 if (jsonMatch) { 443 try { 444 const innerJson = JSON.parse(jsonMatch[1]); 445 messageText = innerJson.message || messageText; 446 } catch { 447 // Use outer message 448 } 449 } 450 451 // Strip preamble (use [\s\S] instead of . with s flag for ES5 compatibility) 452 messageText = messageText.replace( 453 /^Note: prior messages have been hidden from view[\s\S]*?The following is a summary of the previous messages:\s*/i, 454 '' 455 ); 456 457 return { 458 isCompaction: true, 459 message: messageText, 460 }; 461 } 462 } catch { 463 // Not JSON 464 } 465 466 return { isCompaction: false, message: '' }; 467} 468 469/** 470 * Parse user message content (text + images) 471 */ 472function parseUserContent(content: any): { 473 textContent: string; 474 images: Array<{ 475 type: string; 476 source: { 477 type: string; 478 data: string; 479 mediaType?: string; 480 media_type?: string; 481 url?: string; 482 }; 483 }>; 484} { 485 let textContent = ''; 486 let images: any[] = []; 487 488 if (typeof content === 'object' && Array.isArray(content)) { 489 // Multipart message 490 images = content.filter((item: any) => item.type === 'image'); 491 const textParts = content.filter((item: any) => item.type === 'text'); 492 textContent = textParts 493 .map((item: any) => item.text || '') 494 .filter((t: string) => t) 495 .join('\n'); 496 } else if (typeof content === 'string') { 497 textContent = content; 498 } else { 499 textContent = String(content || ''); 500 } 501 502 return { textContent, images }; 503} 504 505/** 506 * Extract step_id from a message - this is how Letta links tool calls with their returns 507 */ 508function extractStepId(msg: LettaMessage | undefined): string | null { 509 if (!msg) return null; 510 511 const msgAny = msg as any; 512 // Letta uses step_id to group tool call and tool return messages 513 return msgAny.step_id || null; 514} 515 516/** 517 * Parse tool call message to extract name and args 518 */ 519function parseToolCall(msg: LettaMessage): { 520 name: string; 521 args: string; 522} { 523 // Try to parse from content (already formatted string like "search(query=\"foo\")") 524 if (typeof msg.content === 'string' && msg.content.includes('(')) { 525 return { 526 name: msg.content.split('(')[0], 527 args: msg.content, 528 }; 529 } 530 531 // Fallback: extract from tool_call object 532 if (msg.tool_call || msg.tool_calls?.[0]) { 533 const toolCall = msg.tool_call || msg.tool_calls![0]; 534 const callObj: any = toolCall.function || toolCall; 535 const name = callObj?.name || 'unknown_tool'; 536 let args = callObj?.arguments || callObj?.args || {}; 537 538 // If args is a JSON string, parse it first 539 if (typeof args === 'string') { 540 try { 541 args = JSON.parse(args); 542 } catch (e) { 543 // If parse fails, keep as string 544 console.warn('Failed to parse tool arguments:', args); 545 } 546 } 547 548 // Format as Python call 549 const formatArgsPython = (obj: any): string => { 550 if (!obj || typeof obj !== 'object') return ''; 551 return Object.entries(obj) 552 .map(([k, v]) => `${k}=${typeof v === 'string' ? `"${v}"` : JSON.stringify(v)}`) 553 .join(', '); 554 }; 555 556 const argsStr = `${name}(${formatArgsPython(args)})`; 557 558 return { name, args: argsStr }; 559 } 560 561 // Fallback to content as-is 562 return { 563 name: 'unknown_tool', 564 args: String(msg.content || ''), 565 }; 566}