mirror of
https://github.com/andy-stack/vaultkeeper-ai.git
synced 2026-07-22 06:42:03 +00:00
Replace auto-scroll interval logic with ResizeObserver-based dynamic padding system that maintains scroll position during streaming by adjusting container bottom padding based on message and thought bubble heights
364 lines
No EOL
11 KiB
Svelte
364 lines
No EOL
11 KiB
Svelte
<script lang="ts">
|
|
import { Resolve } from "Services/DependencyService";
|
|
import { Services } from "Services/Services";
|
|
import type { StreamingMarkdownService } from "Services/StreamingMarkdownService";
|
|
import ChatAreaThought from "./ChatAreaThought.svelte";
|
|
import StreamingIndicator from "./StreamingIndicator.svelte";
|
|
import { Greeting } from "Enums/Greeting";
|
|
import { Role } from "Enums/Role";
|
|
import type { ConversationContent } from "Conversations/ConversationContent";
|
|
import { tick } from "svelte";
|
|
|
|
export let messages: ConversationContent[] = [];
|
|
export let currentThought: string | null = null;
|
|
export let isStreaming: boolean = false;
|
|
export let isSubmitting: boolean = false;
|
|
export let chatContainer: HTMLDivElement;
|
|
|
|
let thoughtElement: HTMLElement | undefined;
|
|
let streamingElement: HTMLElement | undefined;
|
|
|
|
let streamingMarkdownService: StreamingMarkdownService = Resolve(Services.StreamingMarkdownService);
|
|
|
|
let messageElements: Map<string, HTMLElement> = new Map<string, HTMLElement>();
|
|
let lastProcessedContent: Map<string, string> = new Map<string, string>();
|
|
let currentStreamFinalized: boolean = false;
|
|
|
|
let messagePadding: number = 0;
|
|
let staticMessagePadding: number = 0;
|
|
let responsePadding: number = 0;
|
|
|
|
function getGreetingByTime(): string {
|
|
const hour = new Date().getHours();
|
|
|
|
// Morning: 5am - 11:59am
|
|
if (hour >= 5 && hour < 12) {
|
|
return Greeting.Morning;
|
|
}
|
|
// Midday: 12pm - 4:59pm
|
|
else if (hour >= 12 && hour < 17) {
|
|
return Greeting.Midday;
|
|
}
|
|
// Evening: 5pm - 8:59pm
|
|
else if (hour >= 17 && hour < 21) {
|
|
return Greeting.Evening;
|
|
}
|
|
// Night: 9pm - 4:59am
|
|
else {
|
|
return Greeting.Night;
|
|
}
|
|
}
|
|
|
|
// Track streaming messages and update them incrementally
|
|
$: {
|
|
messages.forEach((message, messageIndex) => {
|
|
if (message.role !== Role.User) {
|
|
const messageId = `${message.role}-${messageIndex}`;
|
|
const lastContent = lastProcessedContent.get(messageId) || '';
|
|
|
|
// 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;
|
|
|
|
updateMessageContent({ ...message, id: messageId, isCurrentlyStreaming: isStreaming && isLastMessage });
|
|
lastProcessedContent.set(messageId, message.content);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
function updateMessageContent(message: {id: string, content: string, role: string, isCurrentlyStreaming: boolean}) {
|
|
const element = messageElements.get(message.id);
|
|
if (!element) {
|
|
return;
|
|
}
|
|
|
|
if (message.isCurrentlyStreaming) {
|
|
streamingMarkdownService.streamChunk(message.id, message.content);
|
|
currentStreamFinalized = false;
|
|
} else if (!currentStreamFinalized) {
|
|
streamingMarkdownService.finalizeStream(message.id, message.content);
|
|
currentStreamFinalized = true;
|
|
}
|
|
}
|
|
|
|
function initializeMessageElement(messageId: string, element: HTMLElement) {
|
|
messageElements.set(messageId, element);
|
|
streamingMarkdownService.initializeStream(messageId, element);
|
|
}
|
|
|
|
// Svelte action to handle element initialization
|
|
function streamingAction(element: HTMLElement, messageId: string) {
|
|
initializeMessageElement(messageId, element);
|
|
|
|
return {
|
|
destroy() {
|
|
messageElements.delete(messageId);
|
|
}
|
|
};
|
|
}
|
|
|
|
// Process static messages (user messages and initial load)
|
|
function getStaticHTML(message: ConversationContent, messageIndex: number): string {
|
|
if (message.role === Role.User) {
|
|
return `<p>${message.content}</p>`;
|
|
}
|
|
|
|
// 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 that aren't streaming, use traditional parsing
|
|
if (!isCurrentlyStreaming) {
|
|
try {
|
|
return streamingMarkdownService.formatText(message.content) || `<p>${message.content}</p>`;
|
|
} catch (err) {
|
|
console.error('HTML processing failed:', err);
|
|
return `<p>${message.content}</p>`;
|
|
}
|
|
}
|
|
|
|
return ''; // Streaming messages will be handled by the streaming service
|
|
}
|
|
|
|
// Make sure to clean up when messages are removed
|
|
$: {
|
|
const currentMessageIds = new Set(messages.map((msg, messageIndex) => `${msg.role}-${messageIndex}`));
|
|
|
|
// Remove tracking for messages that no longer exist
|
|
for (const [id] of messageElements) {
|
|
if (!currentMessageIds.has(id)) {
|
|
messageElements.delete(id);
|
|
lastProcessedContent.delete(id);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Chat area padding logic during message streaming
|
|
*/
|
|
|
|
function messageContainerAction(element: HTMLElement) {
|
|
tick().then(() => {
|
|
if (element.classList.contains(Role.Assistant)) {
|
|
assistantMessageAction(element);
|
|
}
|
|
else {
|
|
userMessageAction(element);
|
|
}
|
|
});
|
|
}
|
|
|
|
function userMessageAction(element: HTMLElement) {
|
|
requestAnimationFrame(() => {
|
|
const paddingTop = parseFloat(getComputedStyle(chatContainer).paddingTop) || 0;
|
|
|
|
if (element.offsetHeight > chatContainer.offsetHeight / 2) {
|
|
messagePadding = chatContainer.offsetHeight * 0.75;
|
|
staticMessagePadding = messagePadding;
|
|
} else {
|
|
staticMessagePadding = Math.max(0,
|
|
chatContainer.offsetHeight -
|
|
element.offsetHeight -
|
|
paddingTop);
|
|
|
|
messagePadding = Math.max(0,
|
|
chatContainer.offsetHeight -
|
|
element.offsetHeight -
|
|
calculateStreamingThoughtSize() -
|
|
paddingTop);
|
|
}
|
|
chatContainer.style.paddingBottom = `${messagePadding}px`;
|
|
responsePadding = staticMessagePadding;
|
|
|
|
tick().then(() => {
|
|
chatContainer.scroll({ top: chatContainer.scrollHeight, behavior: "smooth" });
|
|
});
|
|
});
|
|
}
|
|
|
|
function assistantMessageAction(element: HTMLElement) {
|
|
const resizeObserver = new ResizeObserver(() => {
|
|
requestAnimationFrame(() => {
|
|
messagePadding = Math.max(0,
|
|
staticMessagePadding -
|
|
element.offsetHeight -
|
|
calculateStreamingThoughtSize() -
|
|
parseFloat(getComputedStyle(chatContainer).gap) || 0);
|
|
|
|
chatContainer.style.paddingBottom = `${messagePadding}px`;
|
|
});
|
|
});
|
|
|
|
resizeObserver.observe(element);
|
|
|
|
return {
|
|
destroy() {
|
|
resizeObserver.disconnect();
|
|
}
|
|
}
|
|
}
|
|
|
|
$: {
|
|
if (chatContainer && messages.length == 0) {
|
|
chatContainer.style.paddingBottom = "";
|
|
}
|
|
}
|
|
|
|
function calculateStreamingThoughtSize() {
|
|
const thoughtHeight = thoughtElement?.offsetHeight || 0;
|
|
const streamingHeight = streamingElement?.offsetHeight || 0;
|
|
|
|
let thoughtMargins = 0;
|
|
if (thoughtElement) {
|
|
thoughtMargins = parseFloat(getComputedStyle(thoughtElement).marginTop) || 0 +
|
|
parseFloat(getComputedStyle(thoughtElement).marginBottom) || 0;
|
|
}
|
|
let indicatorMargins = 0;
|
|
if (streamingElement) {
|
|
indicatorMargins = parseFloat(getComputedStyle(streamingElement).marginTop) || 0 +
|
|
parseFloat(getComputedStyle(streamingElement).marginBottom) || 0;
|
|
}
|
|
|
|
const gap = parseFloat(getComputedStyle(chatContainer).gap) || 0;
|
|
const gaps = thoughtElement && streamingElement ? 2 : thoughtElement || streamingElement ? 1 : 0;
|
|
|
|
return thoughtHeight + streamingHeight + thoughtMargins + indicatorMargins + (gaps * gap);
|
|
}
|
|
</script>
|
|
|
|
<div class="chat-area" bind:this={chatContainer}>
|
|
{#each messages as message, messageIndex (`${message.role}-${messageIndex}`)}
|
|
{#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>
|
|
{:else}
|
|
{@html getStaticHTML(message, messageIndex)}
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
{/each}
|
|
|
|
<ChatAreaThought bind:thoughtElement thought={currentThought}/>
|
|
{#if isSubmitting}
|
|
<StreamingIndicator bind:streamingElement/>
|
|
{/if}
|
|
|
|
{#if messages.length === 0}
|
|
<div class="conversation-empty-state">
|
|
<div class="typing-in">{getGreetingByTime()}</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<style>
|
|
.chat-area {
|
|
display: flex;
|
|
flex-direction: column;
|
|
height: 100%;
|
|
overflow: auto;
|
|
padding: var(--size-4-3);
|
|
gap: var(--size-4-2);
|
|
scroll-behavior: smooth;
|
|
}
|
|
|
|
.chat-area::-webkit-scrollbar {
|
|
display: none;
|
|
}
|
|
|
|
.message-container {
|
|
display: flex;
|
|
margin: 0;
|
|
}
|
|
|
|
.message-container.user {
|
|
justify-content: flex-end;
|
|
}
|
|
|
|
.message-container.assistant {
|
|
justify-content: flex-start;
|
|
}
|
|
|
|
.message-bubble {
|
|
word-wrap: break-word;
|
|
}
|
|
|
|
.message-bubble.user {
|
|
word-wrap: break-word;
|
|
max-width: 70%;
|
|
border: var(--border-width) solid var(--background-modifier-border);
|
|
border-radius: var(--radius-m);
|
|
padding: 0px var(--size-4-2);
|
|
white-space: pre-wrap;
|
|
}
|
|
|
|
.message-bubble.assistant {
|
|
word-wrap: break-word;
|
|
max-width: 100%;
|
|
}
|
|
|
|
.message-text-user {
|
|
margin: var(--size-4-2);
|
|
}
|
|
|
|
.conversation-empty-state {
|
|
margin: auto;
|
|
font-style: italic;
|
|
font-size: var(--font-ui-medium);
|
|
color: var(--text-muted);
|
|
pointer-events: none;
|
|
}
|
|
|
|
.streaming-content {
|
|
min-height: 1em; /* Ensure the element exists for binding */
|
|
}
|
|
|
|
/* Streaming message styles */
|
|
.fade-in-fast {
|
|
animation: reveal-fade 0.5s ease-in-out forwards;
|
|
}
|
|
|
|
@keyframes reveal-fade {
|
|
0% {
|
|
opacity: 0;
|
|
transform: translateY(10px);
|
|
}
|
|
100% {
|
|
opacity: 1;
|
|
transform: translateY(0);
|
|
}
|
|
}
|
|
|
|
/* Welcome text animation */
|
|
.typing-in {
|
|
overflow: hidden;
|
|
white-space: nowrap;
|
|
animation: reveal-center 1.5s ease-in-out forwards;
|
|
max-width: 0;
|
|
margin: 0 auto;
|
|
padding: 5px;
|
|
}
|
|
|
|
@keyframes reveal-center {
|
|
0% {
|
|
max-width: 0;
|
|
opacity: 0;
|
|
filter: blur(1px);
|
|
}
|
|
100% {
|
|
max-width: 100%;
|
|
opacity: 1;
|
|
filter: blur(0px);
|
|
}
|
|
}
|
|
</style> |