andy-stack_vaultkeeper-ai/Components/ChatWindow.svelte
Andrew Beal d36815c214 feat: add Gemini thought signature support and OpenAI Responses API migration
Implement thought signature tracking for Gemini function calls to support Gemini 3 requirements. Migrate OpenAI integration from Chat Completions to Responses API with proper input/output item handling. Add cross-provider compatibility via legacy text format fallback for conversations without thought signatures or tool IDs. Improve chat auto-scroll behavior and conversation validation. Add and update AI class and conversation tests.
2025-12-10 21:27:58 +00:00

209 lines
No EOL
6.2 KiB
Svelte

<script lang="ts">
import { Resolve } from "Services/DependencyService";
import { Services } from "Services/Services";
import ChatArea from "./ChatArea.svelte";
import ChatInput from "./ChatInput.svelte";
import { tick, onMount } from "svelte";
import { conversationStore } from "../Stores/ConversationStore";
import { Conversation } from "Conversations/Conversation";
import type VaultkeeperAIPlugin from "main";
import { openPluginSettings } from "Helpers/Helpers";
import { Selector } from "Enums/Selector";
import type { WorkSpaceService } from "Services/WorkSpaceService";
import type { ChatService } from "Services/ChatService";
import type { ConversationFileSystemService } from "Services/ConversationFileSystemService";
import type { SettingsService } from "Services/SettingsService";
import { Copy } from "Enums/Copy";
import { AbortService } from "Services/AbortService";
const plugin: VaultkeeperAIPlugin = Resolve<VaultkeeperAIPlugin>(Services.VaultkeeperAIPlugin);
const settingsService: SettingsService = Resolve<SettingsService>(Services.SettingsService);
const chatService: ChatService = Resolve<ChatService>(Services.ChatService);
const workSpaceService: WorkSpaceService = Resolve<WorkSpaceService>(Services.WorkSpaceService);
const conversationService: ConversationFileSystemService = Resolve<ConversationFileSystemService>(Services.ConversationFileSystemService);
const abortService: AbortService = Resolve<AbortService>(Services.AbortService);
let chatContainer: HTMLDivElement;
let chatArea: ChatArea;
let chatInput: ChatInput;
let cancelling = false;
let hasNoApiKey = false;
let isSubmitting = false;
let editModeActive = false;
let currentStreamingMessageId: string | null = null;
let conversation: Conversation = new Conversation();
let currentThought: string | null = null;
export function focusInput() {
chatInput?.focusInput();
}
export function resetChatArea() {
chatArea.resetChatArea();
}
onMount(() => {
if (chatContainer) {
plugin.registerDomEvent(chatContainer, 'click', handleLinkClick);
}
chatService.setStatusBarTokens(0, 0);
});
async function handleLinkClick(evt: MouseEvent) {
const target = evt.target as HTMLElement;
const link = target.closest(`.${Selector.MarkDownLink}`) as HTMLAnchorElement | null;
if (!link) {
return;
}
const href = link.getAttribute('href');
if (!href || !href.startsWith('#/page/')) {
return;
}
evt.preventDefault();
evt.stopPropagation();
const encodedPath = href.replace('#/page/', '');
const notePath = decodeURIComponent(encodedPath);
await workSpaceService.openNote(notePath);
}
function handleNoApiKey(): boolean {
hasNoApiKey = settingsService.getApiKeyForCurrentModel().trim() == "";
if (hasNoApiKey) {
openPluginSettings(plugin);
}
return hasNoApiKey;
}
function toggleEditMode() {
editModeActive = !editModeActive;
focusInput();
}
function handleStop() {
chatService.stop();
currentThought = null;
chatArea.updateChatAreaLayout("smooth");
}
async function handleSubmit(userRequest: string, formattedRequest: string) {
if (handleNoApiKey()) {
return;
}
const currentRequest = userRequest;
await chatService.submit(conversation, editModeActive, currentRequest, formattedRequest, {
onSubmit: () => {
chatArea.updateChatAreaLayout("smooth");
isSubmitting = true;
},
onStreamingUpdate: (streamingId) => {
conversation = conversation;
currentStreamingMessageId = streamingId;
chatArea.updateChatAreaLayout("smooth");
},
onThoughtUpdate: (thought) => {
if (thought !== Copy.AIThoughtMessage) {
currentThought = thought;
} else if (currentThought !== null) {
// we are in-between thoughts so use generic copy
currentThought = thought;
}
},
onComplete: async () => {
cancelling = false;
isSubmitting = false;
abortService.reset();
chatArea.updateChatAreaLayout("smooth");
await chatService.updateTokenDisplay(conversation);
},
onCancel: () => {
cancelling = true;
}
});
}
$: if ($conversationStore.shouldReset) {
conversation = new Conversation();
chatService.setStatusBarTokens(0, 0);
isSubmitting = false;
currentStreamingMessageId = null;
currentThought = null;
conversationStore.clearResetFlag();
}
$: if ($conversationStore.conversationToLoad) {
conversation.contents = [];
isSubmitting = false;
currentStreamingMessageId = null;
currentThought = null;
chatArea.resetChatArea();
tick().then(() => {
if ($conversationStore.conversationToLoad) {
const { conversation: loadedConversation, filePath } = $conversationStore.conversationToLoad;
conversation = loadedConversation;
conversationService.setCurrentConversationPath(filePath);
chatService.onNameChanged?.(loadedConversation.title);
chatService.updateTokenDisplay(loadedConversation);
conversationStore.clearLoadFlag();
chatArea.updateChatAreaLayout("instant");
}
});
}
$: if ($conversationStore.shouldDeactivateEditMode) {
conversationStore.clearEditModeFlag();
editModeActive = false;
}
</script>
<main class="container">
<div id="chat-container">
<ChatArea messages={conversation.contents} bind:this={chatArea} bind:currentThought bind:isSubmitting bind:chatContainer bind:cancelling
currentStreamingMessageId={currentStreamingMessageId} editModeActive={editModeActive}/>
</div>
<ChatInput
bind:this={chatInput}
{hasNoApiKey}
{isSubmitting}
{editModeActive}
onsubmit={handleSubmit}
ontoggleeditmode={toggleEditMode}
onstop={handleStop}
/>
</main>
<style>
.container {
display: grid;
grid-template-rows: 1fr auto var(--size-2-1);
grid-template-columns: 1fr;
height: calc(100% - var(--size-4-16));
border-radius: var(--radius-m);
color: var(--font-interface-theme);
}
#chat-container {
height: 100%;
width: 100%;
max-width: 1000px;
justify-self: center;
user-select: text;
grid-row: 1;
grid-column: 1;
overflow: hidden;
}
</style>