mirror of
https://github.com/andy-stack/vaultkeeper-ai.git
synced 2026-07-22 06:42:03 +00:00
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:
parent
68dda10852
commit
b99c2062dd
4 changed files with 54 additions and 39 deletions
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue