diff --git a/Components/ChatArea.svelte b/Components/ChatArea.svelte index 9534163..fe36729 100644 --- a/Components/ChatArea.svelte +++ b/Components/ChatArea.svelte @@ -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 `
${message.content}
`; } - // 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 @@
- {#each messages as message, messageIndex (`${message.role}-${messageIndex}`)} + {#each messages as message (message.id)} {#if !message.isFunctionCall && !message.isFunctionCallResponse && message.content}
{#if message.role === Role.User}
{message.content}
{:else} -
- {#if isStreaming && messageIndex === messages.length - 1} -
+
+ {#if currentStreamingMessageId === message.id} +
{:else} - {@html getStaticHTML(message, messageIndex)} + {@html getStaticHTML(message)} {/if}
{/if} diff --git a/Components/ChatWindow.svelte b/Components/ChatWindow.svelte index a44596c..de34177 100644 --- a/Components/ChatWindow.svelte +++ b/Components/ChatWindow.svelte @@ -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 @@
- +
diff --git a/Conversations/ConversationContent.ts b/Conversations/ConversationContent.ts index c7f17c9..b19f9e2 100644 --- a/Conversations/ConversationContent.ts +++ b/Conversations/ConversationContent.ts @@ -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; diff --git a/Services/ConversationFileSystemService.ts b/Services/ConversationFileSystemService.ts index 0eaa520..49d348b 100644 --- a/Services/ConversationFileSystemService.ts +++ b/Services/ConversationFileSystemService.ts @@ -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); }