refactor: use stable message IDs instead of index-based tracking

Replace index-based message identification with stable UUID-based IDs for proper streaming state management and message updates across chat components
This commit is contained in:
Andrew Beal 2025-10-12 13:07:54 +01:00
parent 68dda10852
commit b99c2062dd
4 changed files with 54 additions and 39 deletions

View file

@ -11,9 +11,9 @@
export let messages: ConversationContent[] = [];
export let currentThought: string | null = null;
export let isStreaming: boolean = false;
export let isSubmitting: boolean = false;
export let chatContainer: HTMLDivElement;
export let currentStreamingMessageId: string | null = null;
export function onFinishedSubmitting() {
tick().then(() => {
@ -64,18 +64,17 @@
// Track streaming messages and update them incrementally
$: {
messages.forEach((message, messageIndex) => {
messages.forEach((message) => {
if (message.role !== Role.User) {
const messageId = `${message.role}-${messageIndex}`;
const lastContent = lastProcessedContent.get(messageId) || "";
const lastContent = lastProcessedContent.get(message.id) || "";
// Only update if content has changed
if (message.content !== lastContent) {
// Check if this is the last message and we're currently streaming
const isLastMessage = messageIndex === messages.length - 1;
// Check if this specific message is currently streaming
const isCurrentlyStreaming = currentStreamingMessageId === message.id;
updateMessageContent({ ...message, id: messageId, isCurrentlyStreaming: isStreaming && isLastMessage });
lastProcessedContent.set(messageId, message.content);
updateMessageContent({ ...message, isCurrentlyStreaming });
lastProcessedContent.set(message.id, message.content);
}
}
});
@ -113,14 +112,13 @@
}
// Process static messages (user messages and initial load)
function getStaticHTML(message: ConversationContent, messageIndex: number): string {
function getStaticHTML(message: ConversationContent): string {
if (message.role === Role.User) {
return `<div>${message.content}</div>`;
}
// For assistant messages, check if this is the last message and we're streaming
const isLastMessage = messageIndex === messages.length - 1;
const isCurrentlyStreaming = isStreaming && isLastMessage;
// For assistant messages, check if this specific message is currently streaming
const isCurrentlyStreaming = currentStreamingMessageId === message.id;
// For assistant messages that aren't streaming, use traditional parsing
if (!isCurrentlyStreaming) {
@ -137,7 +135,7 @@
// Make sure to clean up when messages are removed
$: {
const currentMessageIds = new Set(messages.map((msg, messageIndex) => `${msg.role}-${messageIndex}`));
const currentMessageIds = new Set(messages.map((msg) => msg.id));
// Remove tracking for messages that no longer exist
for (const [id] of messageElements) {
@ -244,18 +242,18 @@
</script>
<div class="chat-area" bind:this={chatContainer}>
{#each messages as message, messageIndex (`${message.role}-${messageIndex}`)}
{#each messages as message (message.id)}
{#if !message.isFunctionCall && !message.isFunctionCallResponse && message.content}
<div class="message-container {message.role === Role.User ? Role.User : Role.Assistant}" use:messageContainerAction>
<div class="message-bubble {message.role === Role.User ? Role.User : Role.Assistant}">
{#if message.role === Role.User}
<div class="message-text-user fade-in-fast">{message.content}</div>
{:else}
<div class="markdown-content fade-in-fast {isStreaming && messageIndex === messages.length - 1 ? 'streaming' : ''}">
{#if isStreaming && messageIndex === messages.length - 1}
<div use:streamingAction={`${message.role}-${messageIndex}`} class="streaming-content"></div>
<div class="markdown-content fade-in-fast {currentStreamingMessageId === message.id ? 'streaming' : ''}">
{#if currentStreamingMessageId === message.id}
<div use:streamingAction={message.id} class="streaming-content"></div>
{:else}
{@html getStaticHTML(message, messageIndex)}
{@html getStaticHTML(message)}
{/if}
</div>
{/if}

View file

@ -36,6 +36,7 @@
let hasNoApiKey = false;
let isSubmitting = false;
let isStreaming = false;
let currentStreamingMessageId: string | null = null;
let conversation = new Conversation();
@ -138,9 +139,12 @@
}
async function streamRequestResponse(): Promise<{ functionCall: AIFunctionCall | null, shouldContinue: boolean }> {
// Create AI message placeholder
const aiMessageIndex = conversation.contents.length;
conversation.contents = [...conversation.contents, new ConversationContent(Role.Assistant, "")];
// Create AI message with stable ID
const aiMessage = new ConversationContent(Role.Assistant, "");
conversation.contents = [...conversation.contents, aiMessage];
// Track which message is currently streaming
currentStreamingMessageId = aiMessage.id;
isStreaming = true;
let accumulatedContent = "";
@ -150,12 +154,13 @@
for await (const chunk of ai.streamRequest(conversation)) {
if (chunk.error) {
console.error("Streaming error:", chunk.error);
conversation.contents = conversation.contents.map((msg, messageIndex) =>
messageIndex === aiMessageIndex
conversation.contents = conversation.contents.map((msg) =>
msg.id === aiMessage.id
? { ...msg, content: "Error: " + chunk.error }
: msg
);
isStreaming = false;
currentStreamingMessageId = null;
await conversationService.saveConversation(conversation);
break;
}
@ -163,8 +168,8 @@
if (chunk.content) {
currentThought = null;
accumulatedContent += chunk.content;
conversation.contents = conversation.contents.map((msg, messageIndex) =>
messageIndex === aiMessageIndex
conversation.contents = conversation.contents.map((msg) =>
msg.id === aiMessage.id
? { ...msg, content: accumulatedContent }
: msg
);
@ -179,25 +184,32 @@
}
if (chunk.isComplete) {
// Mark streaming as complete for this message
isStreaming = false;
// If there's a function call, remove the placeholder message
if (capturedFunctionCall) {
conversation.contents = conversation.contents.filter((_, messageIndex) => messageIndex !== aiMessageIndex);
} else if (accumulatedContent.trim() !== "") {
// Only save the message if it has content and no function call
conversation.contents = conversation.contents.map((msg, messageIndex) =>
messageIndex === aiMessageIndex
currentStreamingMessageId = null;
// Handle message based on content and function call presence
if (accumulatedContent.trim() !== "") {
console.log(`acc: ${accumulatedContent}, func: ${capturedFunctionCall}, cont: ${capturedShouldContinue}`)
// We have content - always keep the message
conversation.contents = conversation.contents.map((msg) =>
msg.id === aiMessage.id
? { ...msg, content: accumulatedContent }
: msg
);
} else if (capturedFunctionCall) {
// No content but there's a function call - remove the empty placeholder
conversation.contents = conversation.contents.filter((msg) => msg.id !== aiMessage.id);
} else {
// Remove the empty placeholder message
conversation.contents = conversation.contents.filter((_, messageIndex) => messageIndex !== aiMessageIndex);
// No content and no function call - remove empty message
conversation.contents = conversation.contents.filter((msg) => msg.id !== aiMessage.id);
}
await conversationService.saveConversation(conversation);
}
}
console.log(`shouldContinue: ${capturedShouldContinue}`)
return { functionCall: capturedFunctionCall, shouldContinue: capturedShouldContinue };
}
@ -250,7 +262,7 @@
<main class="container">
<div id="chat-container">
<ChatArea messages={conversation.contents} bind:this={chatArea} bind:currentThought bind:isStreaming bind:isSubmitting bind:chatContainer/>
<ChatArea messages={conversation.contents} bind:this={chatArea} bind:currentThought bind:isSubmitting bind:chatContainer currentStreamingMessageId={currentStreamingMessageId}/>
</div>
<div id="input-container">

View file

@ -1,19 +1,22 @@
export class ConversationContent {
id: string;
role: string;
content: string
timestamp: Date;
isFunctionCall: boolean;
isFunctionCallResponse: boolean;
public static isConversationContentData(data: unknown): data is { role: string; content: string; timestamp: string } {
public static isConversationContentData(data: unknown): data is { id: string; role: string; content: string; timestamp: string } {
return (
typeof data === 'object' &&
data !== null &&
'id' in data &&
'role' in data &&
'content' in data &&
'timestamp' in data &&
'isFunctionCall' in data &&
'isFunctionCallResponse' in data &&
typeof data.id === 'string' &&
typeof data.role === 'string' &&
typeof data.content === 'string' &&
typeof data.timestamp === 'string' &&
@ -21,8 +24,9 @@ export class ConversationContent {
typeof data.isFunctionCallResponse == 'boolean'
);
}
constructor(role: string, content: string, timestamp: Date = new Date(), isFunctionCall = false, isFunctionCallResponse = false) {
constructor(role: string, content: string, timestamp: Date = new Date(), isFunctionCall = false, isFunctionCallResponse = false, id?: string) {
this.id = id || `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
this.role = role;
this.content = content;
this.timestamp = timestamp;

View file

@ -27,6 +27,7 @@ export class ConversationFileSystemService {
title: conversation.title,
created: conversation.created.toISOString(),
contents: conversation.contents.map(content => ({
id: content.id,
role: content.role,
content: content.content,
timestamp: content.timestamp.toISOString(),
@ -77,7 +78,7 @@ export class ConversationFileSystemService {
conversation.created = new Date(data.created);
conversation.contents = data.contents.map(content => {
return new ConversationContent(
content.role, content.content, new Date(content.timestamp), content.isFunctionCall, content.isFunctionCallResponse);
content.role, content.content, new Date(content.timestamp), content.isFunctionCall, content.isFunctionCallResponse, content.id);
});
conversations.push(conversation);
}