mirror of
https://github.com/andy-stack/vaultkeeper-ai.git
synced 2026-07-22 06:42:03 +00:00
Move common OpenAI-compatible API code to ChatCompletionsAIClass base, unify retry delay extraction and tool-choice mapping across providers (Claude, Gemini, Mistral, OpenAI), fix Gemini model labels, and correct scroll state when content is not scrollable Fix Gemini model naming in settings. Fix CHat area scroll to bottom state when clearing a conversation.
450 lines
No EOL
12 KiB
Svelte
450 lines
No EOL
12 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 { getOuterHeight, setElementIcon } from "Helpers/ElementHelper";
|
|
import { setIcon } from "obsidian";
|
|
import { fade } from "svelte/transition";
|
|
|
|
export let messages: ConversationContent[] = [];
|
|
export let currentThought: string | null = null;
|
|
export let isSubmitting: boolean = false;
|
|
export let chatContainer: HTMLDivElement;
|
|
|
|
export function resetChatArea() {
|
|
messageElements = [];
|
|
if (chatAreaPaddingElement) {
|
|
chatAreaPaddingElement.style.padding = "0px";
|
|
}
|
|
chatContainer.scroll({ top: 0, behavior: "instant" });
|
|
tick().then(updateScrolledState);
|
|
}
|
|
|
|
export async function updateChatAreaLayout(behavior: ScrollBehavior | undefined = undefined, shouldSettle: boolean = false) {
|
|
await tick();
|
|
|
|
if (!chatAreaPaddingElement) {
|
|
return;
|
|
}
|
|
|
|
if (messageElements.length <= 0) {
|
|
chatAreaPaddingElement.style.paddingBottom = "0px";
|
|
return;
|
|
}
|
|
|
|
requestAnimationFrame(() => {
|
|
applyLayout(behavior, shouldSettle);
|
|
updateScrolledState();
|
|
});
|
|
}
|
|
|
|
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);
|
|
|
|
let result = calculateMessageHeight(sortedMessages);
|
|
let contentHeight = result.height + (gap * (result.count - 1));
|
|
|
|
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) {
|
|
chatContainer.scroll({ top: chatContainer.scrollHeight, behavior });
|
|
}
|
|
}
|
|
|
|
function calculateMessageHeight(sortedMessages: { element: HTMLElement, index: number, role: Role }[]): { count: number, height: number } {
|
|
const lastMessage = sortedMessages[sortedMessages.length - 1];
|
|
if (lastMessage.role === Role.User) {
|
|
return { count: 1, height: getOuterHeight(lastMessage.element) };
|
|
}
|
|
|
|
let count = 0;
|
|
let height = 0;
|
|
|
|
for (const message of sortedMessages.reverse()) {
|
|
if (message.role === Role.User) {
|
|
break;
|
|
}
|
|
height += getOuterHeight(message.element);
|
|
count++;
|
|
}
|
|
return { count: count, height: height };
|
|
}
|
|
|
|
function updateScrolledState() {
|
|
if (!chatContainer) {
|
|
return;
|
|
}
|
|
const isScrollable = chatContainer.scrollHeight > chatContainer.clientHeight;
|
|
scrolledToBottom = !isScrollable || Math.abs(chatContainer.scrollHeight - chatContainer.scrollTop - chatContainer.clientHeight) < 100;
|
|
}
|
|
|
|
let scrolledToBottom: boolean = true;
|
|
|
|
let scrollToBottomButton: HTMLButtonElement;
|
|
let chatAreaPaddingElement: HTMLElement | undefined;
|
|
let thoughtIndicatorElement: HTMLElement | undefined;
|
|
let streamingIndicatorElement: HTMLElement | undefined;
|
|
|
|
let streamingMarkdownService: StreamingMarkdownService = Resolve<StreamingMarkdownService>(Services.StreamingMarkdownService);
|
|
|
|
let messageElements: { element: HTMLElement, index: number, role: Role }[] = [];
|
|
|
|
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 messageRenderAction(element: HTMLElement, message: ConversationContent) {
|
|
streamingMarkdownService.render(message.getDisplayContent(), element);
|
|
return {
|
|
update(newMessage: ConversationContent) {
|
|
streamingMarkdownService.render(newMessage.getDisplayContent(), element, !isSubmitting);
|
|
}
|
|
};
|
|
}
|
|
|
|
function trackingAction(element: HTMLElement, { index, role }: { index: number, role: Role }) {
|
|
messageElements.push({ index: index, element: element, role: role });
|
|
}
|
|
|
|
$: if (scrollToBottomButton) {
|
|
setIcon(scrollToBottomButton, "arrow-down");
|
|
}
|
|
|
|
$: {
|
|
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={updateScrolledState}>
|
|
{#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, role: Role.User }}>
|
|
<div class="message-bubble {Role.User}">
|
|
<div class="message-text-user-container" 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, role: Role.Assistant }}>
|
|
<div class="message-bubble {Role.Assistant}">
|
|
<div class="markdown-content">
|
|
<div use:messageRenderAction={message} class="streaming-content"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
{/each}
|
|
|
|
<ThoughtIndicator thought={currentThought} bind:thoughtIndicatorElement={thoughtIndicatorElement}/>
|
|
{#if isSubmitting}
|
|
<StreamingIndicator 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}
|
|
|
|
{#if !scrolledToBottom}
|
|
<div class="scroll-to-bottom-container" transition:fade>
|
|
<button
|
|
id="scroll-to-bottom-button"
|
|
bind:this={scrollToBottomButton}
|
|
on:click={() => updateChatAreaLayout("smooth")}
|
|
aria-label="Scroll to bottom">
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<style>
|
|
.scroll-to-bottom-container {
|
|
background-color: color-mix(in srgb, var(--background-primary) 70%, transparent);
|
|
border-radius: var(--radius-l);
|
|
padding: var(--size-4-2);
|
|
position: absolute;
|
|
bottom: var(--size-4-2);
|
|
right: 0px;
|
|
}
|
|
|
|
#scroll-to-bottom-button {
|
|
background-color: var(--interactive-accent);
|
|
}
|
|
|
|
.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-container {
|
|
animation: fadeIn 0.5s ease-out forwards;
|
|
}
|
|
|
|
@keyframes fadeIn {
|
|
from {
|
|
opacity: 0;
|
|
transform: translateY(5px);
|
|
}
|
|
to {
|
|
opacity: 1;
|
|
transform: translateY(0);
|
|
}
|
|
}
|
|
|
|
.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> |