andy-stack_vaultkeeper-ai/Components/ChatWindow.svelte
Andrew Beal 817ab3d332 fix: improve planning mode error handling and UI state management
- Add MAX_AGENT_DEPTH check to prevent infinite planning recursion
- Prompt user to submit plan if not provided instead of silent failure
- Clear planning mode when deactivating edit mode
- Remove inline overflow-y style in favor of CSS declaration
- Update error copy for clarity
- Remove unused deactivateEditMode method
2026-01-04 23:40:31 +00:00

233 lines
No EOL
7.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";
import type { Attachment } from "Conversations/Attachment";
import ChatPlanArea from "./ChatPlanArea.svelte";
import type { ExecutionPlanStore } from "Stores/ExecutionPlanStore";
const plugin: VaultkeeperAIPlugin = Resolve<VaultkeeperAIPlugin>(Services.VaultkeeperAIPlugin);
const executionPlanStore: ExecutionPlanStore = Resolve<ExecutionPlanStore>(Services.ExecutionPlanStore);
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 hasNoApiKey = false;
let isSubmitting = false;
let busyPlanning = false;
let editModeActive = false;
let planningModeActive = false;
let currentStreamingMessageId: string | null = null;
let conversation: Conversation = new Conversation();
let attachments: Attachment[] = [];
let currentThought: string | null = null;
export function focusInput() {
chatInput?.focusInput();
}
export function resetChatArea() {
chatArea.resetChatArea();
}
onMount(() => {
if (chatContainer) {
plugin.registerDomEvent(chatContainer, 'click', handleLinkClick);
}
});
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 handleStop() {
chatService.stop();
currentThought = null;
}
async function handleSubmit(userRequest: string, formattedRequest: string) {
if (handleNoApiKey()) {
return;
}
const currentRequest = userRequest;
await chatService.submit(conversation, editModeActive, planningModeActive, currentRequest, formattedRequest, attachments, {
onSubmit: () => {
isSubmitting = true;
attachments = [];
},
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;
}
},
onPlanningStarted: () => {
busyPlanning = true;
},
onPlanningFinished: () => {
busyPlanning = false;
},
onPlanningQuestion: async (question) => {
chatInput.setDisplayItem(createEl("span", { text: question }));
return new Promise<string>((resolve) => {
chatInput.enterQuestionMode(resolve);
});
},
onPlanUpdate: (executionPlan) => {
executionPlanStore.setPlan(executionPlan);
},
onPlanStepUpdate: () => {
executionPlanStore.updatePlan();
},
onPlanComplete: () => {
executionPlanStore.clearPlan();
},
onComplete: async () => {
isSubmitting = false;
busyPlanning = false;
currentThought = null;
executionPlanStore.clearPlan();
chatInput.clearDisplayItem();
abortService.reset();
tick().then(() => {
chatArea.updateChatAreaLayout("smooth", true);
});
},
});
}
$: if ($conversationStore.shouldReset) {
conversation = new Conversation();
conversationService.resetCurrentConversation();
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);
conversationStore.clearLoadFlag();
chatArea.updateChatAreaLayout("instant", true);
}
});
}
$: if ($conversationStore.shouldDeactivateEditMode) {
conversationStore.clearEditModeFlag();
planningModeActive = false;
editModeActive = false;
}
</script>
<main class="container">
<ChatPlanArea executionPlanState={executionPlanStore.executionPlanState} {editModeActive} {busyPlanning}/>
<div id="chat-container">
<ChatArea messages={conversation.contents} bind:this={chatArea} bind:currentThought bind:isSubmitting bind:chatContainer
currentStreamingMessageId={currentStreamingMessageId} editModeActive={editModeActive}/>
</div>
<ChatInput
bind:this={chatInput}
bind:attachments
bind:editModeActive
bind:planningModeActive
{hasNoApiKey}
{isSubmitting}
onSubmit={handleSubmit}
onStop={handleStop}
/>
</main>
<style>
.container {
display: grid;
grid-template-rows: auto 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: 2;
grid-column: 1;
overflow: hidden;
}
</style>