mirror of
https://github.com/andy-stack/vaultkeeper-ai.git
synced 2026-07-22 06:42:03 +00:00
294 lines
No EOL
7.5 KiB
Svelte
294 lines
No EOL
7.5 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";
|
|
|
|
export let messages: Array<{id: string, content: string, isUser: boolean, isStreaming: boolean}> = [];
|
|
export let chatContainer: HTMLDivElement;
|
|
|
|
let streamingMarkdownService: StreamingMarkdownService = Resolve(Services.StreamingMarkdownService);
|
|
|
|
let messageElements = new Map<string, HTMLElement>();
|
|
let lastProcessedContent = new Map<string, string>();
|
|
let scrollInterval: number | null = null;
|
|
let userScrolledUp = false;
|
|
let lastScrollTop = 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) => {
|
|
if (!message.isUser) {
|
|
const lastContent = lastProcessedContent.get(message.id) || '';
|
|
|
|
// Only update if content has changed
|
|
if (message.content !== lastContent) {
|
|
if (message.isStreaming && lastContent === '') {
|
|
userScrolledUp = false;
|
|
}
|
|
|
|
updateMessageContent(message);
|
|
lastProcessedContent.set(message.id, message.content);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
function handleScroll() {
|
|
if (!chatContainer) return;
|
|
|
|
if (chatContainer.scrollTop < lastScrollTop) {
|
|
userScrolledUp = true;
|
|
}
|
|
|
|
lastScrollTop = chatContainer.scrollTop;
|
|
}
|
|
|
|
function startScrolling() {
|
|
if (scrollInterval || userScrolledUp) return;
|
|
|
|
scrollInterval = window.setInterval(() => {
|
|
if (chatContainer && !userScrolledUp) {
|
|
chatContainer.scrollTop = chatContainer.scrollHeight;
|
|
}
|
|
}, 50);
|
|
|
|
setTimeout(() => {
|
|
if (scrollInterval) {
|
|
clearInterval(scrollInterval);
|
|
scrollInterval = null;
|
|
}
|
|
}, 500);
|
|
}
|
|
|
|
function updateMessageContent(message: {id: string, content: string, isUser: boolean, isStreaming: boolean}) {
|
|
const element = messageElements.get(message.id);
|
|
if (!element) return;
|
|
|
|
if (message.isStreaming) {
|
|
streamingMarkdownService.streamChunk(message.id, message.content);
|
|
} else {
|
|
streamingMarkdownService.finalizeStream(message.id, message.content);
|
|
}
|
|
startScrolling();
|
|
}
|
|
|
|
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: {id: string, content: string, isUser: boolean, isStreaming: boolean}): string {
|
|
if (message.isUser) {
|
|
return `<p>${message.content}</p>`;
|
|
}
|
|
|
|
// For assistant messages that aren't streaming, use traditional parsing
|
|
if (!message.isStreaming) {
|
|
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(m => m.id));
|
|
|
|
// Remove tracking for messages that no longer exist
|
|
for (const [id] of messageElements) {
|
|
if (!currentMessageIds.has(id)) {
|
|
messageElements.delete(id);
|
|
lastProcessedContent.delete(id);
|
|
}
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<div class="chat-area" bind:this={chatContainer} on:scroll={handleScroll}>
|
|
{#each messages as message (message.id)}
|
|
<div class="message-container" class:user={message.isUser} class:assistant={!message.isUser}>
|
|
<div class="message-bubble" class:user={message.isUser} class:assistant={!message.isUser}>
|
|
{#if message.isUser}
|
|
<p class="message-text-user fade-in-fast">{message.content}</p>
|
|
{:else}
|
|
<div class="markdown-content fade-in-fast" class:streaming={message.isStreaming}>
|
|
<!-- Streaming message: use action for initialization -->
|
|
{#if message.isStreaming}
|
|
<div use:streamingAction={message.id} class="streaming-content"></div>
|
|
<StreamingIndicator/>
|
|
<ChatAreaThought/>
|
|
{:else}
|
|
<!-- Static message: use traditional rendering -->
|
|
{@html getStaticHTML(message)}
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
{/each}
|
|
|
|
{#if messages.length === 0}
|
|
<div class="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);
|
|
}
|
|
|
|
.empty-state {
|
|
justify-content: center;
|
|
align-items: center;
|
|
font-style: italic;
|
|
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 3s ease-in-out forwards;
|
|
max-width: 0;
|
|
margin: 0 auto;
|
|
padding: 5px;
|
|
}
|
|
|
|
@keyframes reveal-center {
|
|
0% {
|
|
max-width: 0;
|
|
opacity: 0;
|
|
filter: blur(1px);
|
|
}
|
|
50% {
|
|
max-width: 50%;
|
|
opacity: 0.9;
|
|
filter: blur(0.5px)
|
|
}
|
|
70% {
|
|
max-width: 60%;
|
|
opacity: 0.95;
|
|
filter: blur(0px)
|
|
}
|
|
90% {
|
|
max-width: 80%;
|
|
opacity: 0.95;
|
|
filter: blur(0px)
|
|
}
|
|
100% {
|
|
max-width: 100%;
|
|
opacity: 1;
|
|
filter: blur(0px);
|
|
}
|
|
}
|
|
|
|
</style> |