mirror of
https://github.com/andy-stack/vaultkeeper-ai.git
synced 2026-07-22 06:42:03 +00:00
Add support for DOCX, PPTX, XLSX, ODT, ODP, and ODS document formats by converting them to plain text. Standardize attachment introduction messages across all AI providers from "Binary data for X follows" to "The contents of the file 'X' are provided below."
490 lines
No EOL
14 KiB
Svelte
490 lines
No EOL
14 KiB
Svelte
<script lang="ts">
|
|
import { Resolve } from "Services/DependencyService";
|
|
import { Services } from "Services/Services";
|
|
import type { StreamingMarkdownService } from "Services/StreamingMarkdownService";
|
|
import ThoughtIndicator from "./ThoughtIndicator.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";
|
|
import { Selector } from "Enums/Selector";
|
|
import { Exception } from "Helpers/Exception";
|
|
import { getOuterHeight, setElementIcon } from "Helpers/ElementHelper";
|
|
|
|
export let messages: ConversationContent[] = [];
|
|
export let currentThought: string | null = null;
|
|
export let isSubmitting: boolean = false;
|
|
export let chatContainer: HTMLDivElement;
|
|
export let currentStreamingMessageId: string | null = null;
|
|
export let editModeActive: boolean = false;
|
|
|
|
export function resetChatArea() {
|
|
autoScroll = true;
|
|
messageElements = [];
|
|
lastProcessedContent.clear();
|
|
currentStreamFinalized = false;
|
|
if (chatAreaPaddingElement) {
|
|
chatAreaPaddingElement.style.padding = "0px";
|
|
}
|
|
chatContainer.scroll({ top: 0, behavior: "instant" });
|
|
}
|
|
|
|
export function resetAutoScroll() {
|
|
autoScroll = true;
|
|
}
|
|
|
|
export async function updateChatAreaLayout(behavior: ScrollBehavior | undefined, shouldSettle: boolean = false) {
|
|
await tick();
|
|
|
|
if (!chatAreaPaddingElement) {
|
|
return;
|
|
}
|
|
|
|
if (messageElements.length <= 0) {
|
|
chatAreaPaddingElement.style.paddingBottom = "0px";
|
|
return;
|
|
}
|
|
|
|
requestAnimationFrame(() => {
|
|
applyLayout(behavior, shouldSettle);
|
|
});
|
|
}
|
|
|
|
function applyLayout(behavior: ScrollBehavior | undefined, shouldSettle: boolean) {
|
|
if (!chatAreaPaddingElement || messageElements.length <= 0) {
|
|
return;
|
|
}
|
|
|
|
const styles = getComputedStyle(chatContainer);
|
|
const gap = parseFloat(styles.gap) || 0;
|
|
const paddingTop = parseFloat(styles.paddingTop) || 0;
|
|
const paddingBottom = parseFloat(styles.paddingBottom) || 0;
|
|
|
|
const sortedMessages = messageElements.sort((a, b) => a.index - b.index);
|
|
const lastMessage = sortedMessages[sortedMessages.length - 1];
|
|
let contentHeight = getOuterHeight(lastMessage.element);
|
|
|
|
if (!shouldSettle) {
|
|
if (thoughtIndicatorElement) {
|
|
contentHeight += getOuterHeight(thoughtIndicatorElement) + gap;
|
|
}
|
|
if (streamingIndicatorElement) {
|
|
contentHeight += getOuterHeight(streamingIndicatorElement) + gap;
|
|
}
|
|
}
|
|
|
|
const availableHeight = chatContainer.offsetHeight - paddingTop - paddingBottom;
|
|
let padding = shouldSettle
|
|
? Math.max(0, availableHeight - contentHeight)
|
|
: Math.max(25, availableHeight - contentHeight);
|
|
|
|
chatAreaPaddingElement.style.paddingBottom = `${padding}px`;
|
|
|
|
if (behavior && autoScroll) {
|
|
chatContainer.scroll({ top: chatContainer.scrollHeight, behavior });
|
|
}
|
|
}
|
|
|
|
let autoScroll: boolean = true;
|
|
let lastScrollTop: number = 0;
|
|
|
|
let chatAreaPaddingElement: HTMLElement | undefined;
|
|
let thoughtIndicatorElement: HTMLElement | undefined;
|
|
let streamingIndicatorElement: HTMLElement | undefined;
|
|
|
|
let streamingMarkdownService: StreamingMarkdownService = Resolve<StreamingMarkdownService>(Services.StreamingMarkdownService);
|
|
|
|
let messageElements: { index: number, element: HTMLElement }[] = [];
|
|
let lastProcessedContent: Map<string, string> = new Map<string, string>();
|
|
let currentStreamFinalized: boolean = false;
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
function updateMessageContent(messageId: string, content: string, isCurrentlyStreaming: boolean) {
|
|
if (isCurrentlyStreaming) {
|
|
streamingMarkdownService.streamChunk(messageId, content);
|
|
currentStreamFinalized = false;
|
|
} else if (!currentStreamFinalized) {
|
|
streamingMarkdownService.finalizeStream(messageId, content);
|
|
currentStreamFinalized = true;
|
|
}
|
|
}
|
|
|
|
// Process static messages (user messages and initial load)
|
|
function getStaticHTML(message: ConversationContent): string {
|
|
if (message.role === Role.User) {
|
|
return `<div>${message.content}</div>`;
|
|
}
|
|
|
|
// For assistant messages, check if this specific message is currently streaming
|
|
const messageId = message.timestamp.getTime().toString();
|
|
const isCurrentlyStreaming = currentStreamingMessageId === messageId;
|
|
|
|
// For assistant messages that aren't streaming, use traditional parsing
|
|
if (!isCurrentlyStreaming) {
|
|
const content = message.getDisplayContent();
|
|
if (message.errorType) {
|
|
return `<div class="${Selector.ErrorSelector}">${content}</div>`;
|
|
}
|
|
|
|
try {
|
|
return streamingMarkdownService.formatText(content) || `<div>${content}</div>`;
|
|
} catch (error) {
|
|
Exception.log(error);
|
|
return `<div>${content}</div>`;
|
|
}
|
|
}
|
|
|
|
return ""; // Streaming messages will be handled by the streaming service
|
|
}
|
|
|
|
function streamingAction(element: HTMLElement, messageId: string) {
|
|
streamingMarkdownService.initializeStream(messageId, element);
|
|
}
|
|
|
|
function trackingAction(element: HTMLElement, index: number) {
|
|
messageElements.push({ index: index, element: element });
|
|
}
|
|
|
|
function observeResize(element: HTMLElement) {
|
|
const observer = new ResizeObserver(() => {
|
|
updateChatAreaLayout("smooth");
|
|
});
|
|
|
|
observer.observe(element);
|
|
|
|
return {
|
|
destroy() {
|
|
observer.disconnect();
|
|
}
|
|
};
|
|
}
|
|
|
|
// decide if we should be auto scrolling
|
|
function handleScroll() {
|
|
if (!chatContainer) {
|
|
return;
|
|
}
|
|
|
|
const scrollTop = chatContainer.scrollTop;
|
|
const scrollHeight = chatContainer.scrollHeight;
|
|
const clientHeight = chatContainer.clientHeight;
|
|
|
|
// Only process if the user actually scrolled (scrollTop changed)
|
|
// This prevents false triggers when content grows and pushes things down
|
|
if (scrollTop === lastScrollTop) {
|
|
return;
|
|
}
|
|
|
|
const previousScrollTop = lastScrollTop;
|
|
lastScrollTop = scrollTop;
|
|
|
|
// Check if we're at the bottom (with a small tolerance for rounding errors)
|
|
const isAtBottom = scrollHeight - scrollTop - clientHeight < 5;
|
|
|
|
if (isAtBottom) {
|
|
autoScroll = true;
|
|
} else if (scrollTop < previousScrollTop) {
|
|
autoScroll = false; // user scrolled up
|
|
}
|
|
}
|
|
|
|
// Track streaming messages and update them incrementally
|
|
$: {
|
|
messages.forEach((message) => {
|
|
if (message.role !== Role.User) {
|
|
const messageId = message.timestamp.getTime().toString();
|
|
const lastContent = lastProcessedContent.get(messageId) || "";
|
|
|
|
// Only update if content has changed
|
|
if (message.content !== lastContent) {
|
|
// Check if this specific message is currently streaming
|
|
const isCurrentlyStreaming = currentStreamingMessageId === messageId;
|
|
|
|
const content = message.getDisplayContent();
|
|
// Only process through streaming service if actively streaming
|
|
if (isCurrentlyStreaming) {
|
|
updateMessageContent(messageId, content, isCurrentlyStreaming);
|
|
}
|
|
lastProcessedContent.set(messageId, content);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
$: {
|
|
if (messages.length === 0 && chatAreaPaddingElement) {
|
|
chatAreaPaddingElement.style.padding = "0px";
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<div class="chat-area-wrapper">
|
|
{#if messages.length > 0}
|
|
<div class="top-fade"></div>
|
|
{/if}
|
|
<div class="chat-area" bind:this={chatContainer} on:scroll={handleScroll} use:observeResize>
|
|
{#each messages as message, index}
|
|
{@const content = message.getDisplayContent()}
|
|
{#if message.shouldDisplayContent && content.trim() !== ""}
|
|
{#if message.role === Role.User}
|
|
<div class="message-container {Role.User}" use:trackingAction={index}>
|
|
<div class="message-bubble {Role.User}">
|
|
<div class="message-text-user-container content-fade-in" contenteditable="false">
|
|
<div class="message-text-user">
|
|
{@html content}
|
|
</div>
|
|
</div>
|
|
{#if message.references.length > 0}
|
|
<hr class="message-attachment-break"/>
|
|
<div class="message-attachments-container">
|
|
{#each message.references as reference}
|
|
<div class="message-attachmanet" aria-label="{reference.fileName}">
|
|
<div
|
|
class="message-attachment-icon"
|
|
use:setElementIcon={reference.getIconName()}
|
|
></div>
|
|
<div class="message-attachment-info">
|
|
<div class="message-attachment-name">{reference.fileName}</div>
|
|
<div class="message-attachment-size">{reference.size}MB</div>
|
|
</div>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
{:else}
|
|
{@const messageId = message.timestamp.getTime().toString()}
|
|
<div class="message-container {Role.Assistant}" use:trackingAction={index}>
|
|
<div class="message-bubble {Role.Assistant}">
|
|
<div class="markdown-content content-fade-in {currentStreamingMessageId === messageId ? "streaming" : ""}">
|
|
{#if currentStreamingMessageId === messageId}
|
|
<div use:streamingAction={messageId} class="streaming-content"></div>
|
|
{:else}
|
|
{@html getStaticHTML(message)}
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
{/each}
|
|
|
|
<ThoughtIndicator thought={currentThought} {editModeActive} bind:thoughtIndicatorElement={thoughtIndicatorElement}/>
|
|
{#if isSubmitting}
|
|
<StreamingIndicator editModeActive={editModeActive} bind:streamingIndicatorElement={streamingIndicatorElement}/>
|
|
{/if}
|
|
|
|
<div bind:this={chatAreaPaddingElement} style:user-select=none></div>
|
|
|
|
{#if messages.length === 0}
|
|
<div class="conversation-empty-state">
|
|
<div class="typing-in">{getGreetingByTime()}</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
{#if messages.length > 0}
|
|
<div class="bottom-fade"></div>
|
|
{/if}
|
|
</div>
|
|
|
|
<style>
|
|
.chat-area-wrapper {
|
|
position: relative;
|
|
height: 100%;
|
|
width: 100%;
|
|
}
|
|
|
|
.top-fade {
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
height: var(--size-4-4);
|
|
background-image: linear-gradient(to bottom, var(--background-secondary), transparent);
|
|
z-index: 10;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.bottom-fade {
|
|
position: absolute;
|
|
bottom: 0;
|
|
left: 0;
|
|
right: 0;
|
|
height: var(--size-4-4);
|
|
background-image: linear-gradient(to top, var(--background-secondary), transparent);
|
|
z-index: 10;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.chat-area {
|
|
position: relative;
|
|
display: flex;
|
|
flex-direction: column;
|
|
height: 100%;
|
|
overflow: auto;
|
|
padding: var(--size-4-4) var(--size-4-3) var(--size-4-3) var(--size-4-3);
|
|
gap: var(--size-4-2);
|
|
scroll-behavior: smooth;
|
|
}
|
|
|
|
.chat-area::-webkit-scrollbar {
|
|
display: none;
|
|
}
|
|
|
|
.message-container {
|
|
display: flex;
|
|
text-align: left;
|
|
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);
|
|
}
|
|
|
|
.message-bubble.assistant {
|
|
word-wrap: break-word;
|
|
max-width: 100%;
|
|
}
|
|
|
|
.message-text-user-container {
|
|
max-height: 15vh;
|
|
overflow: scroll;
|
|
padding-top: var(--size-4-2);
|
|
white-space: pre-wrap;
|
|
}
|
|
|
|
.message-text-user-container::-webkit-scrollbar {
|
|
display: none;
|
|
}
|
|
|
|
.message-text-user-container {
|
|
padding-bottom: 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 {
|
|
justify-content: left;
|
|
min-height: 1em; /* Ensure the element exists for binding */
|
|
}
|
|
|
|
/* Streaming message styles */
|
|
.content-fade-in {
|
|
animation: reveal-fade 0.5s ease-in-out forwards;
|
|
}
|
|
|
|
@keyframes reveal-fade {
|
|
0% {
|
|
opacity: 0;
|
|
transform: translateY(10px);
|
|
}
|
|
100% {
|
|
opacity: 1;
|
|
transform: translateY(0);
|
|
}
|
|
}
|
|
|
|
/* Message attachments styles */
|
|
.message-attachment-break {
|
|
color: var(--background-secondary-alt);
|
|
margin: 0 0 var(--size-4-2) 0;
|
|
opacity: 0.5;
|
|
}
|
|
|
|
.message-attachments-container {
|
|
display: flex;
|
|
overflow-x: auto;
|
|
overflow-y: hidden;
|
|
scroll-behavior: smooth;
|
|
gap: var(--size-4-2);
|
|
margin-bottom: var(--size-4-2);
|
|
}
|
|
|
|
.message-attachments-container::-webkit-scrollbar {
|
|
display: none;
|
|
}
|
|
|
|
.message-attachmanet {
|
|
display: grid;
|
|
grid-template-rows: var(--size-4-2) auto var(--size-4-2);
|
|
grid-template-columns: var(--size-4-2) auto var(--size-4-2) auto var(--size-4-2);
|
|
background-color: var(--background-secondary-alt);
|
|
border: var(--border-width) solid var(--background-modifier-border);
|
|
border-radius: var(--radius-m);
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.message-attachment-icon {
|
|
grid-row: 2;
|
|
grid-column: 2;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.message-attachment-info {
|
|
grid-row: 2;
|
|
grid-column: 4;
|
|
min-width: 40px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.message-attachment-name {
|
|
display: inline-block;
|
|
white-space: nowrap;
|
|
width: 100%;
|
|
padding: 0;
|
|
font-size: var(--font-smaller);
|
|
}
|
|
|
|
.message-attachment-size {
|
|
padding: 0;
|
|
font-size: var(--font-smallest);
|
|
color: var(--text-muted);
|
|
}
|
|
</style> |