mirror of
https://github.com/andy-stack/vaultkeeper-ai.git
synced 2026-07-22 06:42:03 +00:00
Restructure system prompt to enforce mandatory complexity evaluation before action. Replace verbose multi-step planning framework with concise gate-based decision model. Add UI for execution plan visibility. Improve planning/execution separation by blocking execution tools during planning phase. Remove cancellation indicator component. Fix conversation deletion bug preventing saves after delete. Strengthen type safety for AIFunction names. Simplify function summary format for planning agent. Update test mocks for new callback signatures.
531 lines
15 KiB
Svelte
531 lines
15 KiB
Svelte
<script lang="ts">
|
|
import { onDestroy, tick } from "svelte";
|
|
import { Platform, setIcon, type EventRef } from "obsidian";
|
|
import type { UserInputService } from "Services/UserInputService";
|
|
import type { ISearchState, SearchStateStore } from "Stores/SearchStateStore";
|
|
import { Resolve } from "Services/DependencyService";
|
|
import { Services } from "Services/Services";
|
|
import { isSearchTrigger, fromInput, toNode, triggerToText } from "Enums/SearchTrigger";
|
|
import ChatSearchResults from "./ChatSearchResults.svelte";
|
|
import type { Writable } from "svelte/store";
|
|
import type { InputService } from "Services/InputService";
|
|
import UserInstruction from "./UserInstruction.svelte";
|
|
import DiffControls from "./DiffControls.svelte";
|
|
import type { EventService } from "Services/EventService";
|
|
import { Event } from "Enums/Event";
|
|
import type { DiffService } from "Services/DiffService";
|
|
import type { Attachment } from "Conversations/Attachment";
|
|
import ChatAttachments from "./ChatAttachments.svelte";
|
|
|
|
export let attachments: Attachment[] = [];
|
|
|
|
export let hasNoApiKey: boolean;
|
|
export let isSubmitting: boolean;
|
|
export let editModeActive: boolean;
|
|
export let onSubmit: (userRequest: string, formattedRequest: string) => void;
|
|
export let onTogglEeditMode: () => void;
|
|
export let onStop: () => void;
|
|
|
|
const inputService: InputService = Resolve<InputService>(Services.InputService);
|
|
const userInputService: UserInputService = Resolve<UserInputService>(Services.UserInputService);
|
|
const searchStateStore: SearchStateStore = Resolve<SearchStateStore>(Services.SearchStateStore);
|
|
const diffService: DiffService = Resolve<DiffService>(Services.DiffService);
|
|
const eventService: EventService = Resolve<EventService>(Services.EventService);
|
|
|
|
const searchState: Writable<ISearchState> = searchStateStore.searchState;
|
|
|
|
let textareaElement: HTMLDivElement;
|
|
let userInstructionButton: HTMLButtonElement;
|
|
let submitButton: HTMLButtonElement;
|
|
let editModeButton: HTMLButtonElement;
|
|
|
|
let userInstructionActive: boolean = false;
|
|
|
|
let userRequest: string = "";
|
|
|
|
let diffOpen: boolean = false;
|
|
|
|
const diffOpenedRef: EventRef = eventService.on(Event.DiffOpened, () => { diffOpen = true; focusInput(); });
|
|
const diffClosedRef: EventRef = eventService.on(Event.DiffClosed, () => { diffOpen = false; focusInput(); });
|
|
|
|
onDestroy(() => {
|
|
eventService.offref(diffOpenedRef);
|
|
eventService.offref(diffClosedRef);
|
|
});
|
|
|
|
export function focusInput(onMobile: boolean = false) {
|
|
// don't focus on mobile, it's annoying
|
|
if (onMobile || !Platform.isMobile) {
|
|
tick().then(() => {
|
|
textareaElement?.focus();
|
|
});
|
|
}
|
|
}
|
|
|
|
$: if (userInstructionButton) {
|
|
setIcon(userInstructionButton, "user-round-pen");
|
|
}
|
|
|
|
$: if (submitButton) {
|
|
if (diffOpen) {
|
|
setIcon(submitButton, userRequest.trim() === "" ? "square" : "send-horizontal");
|
|
} else {
|
|
setIcon(submitButton, isSubmitting ? "square" : "send-horizontal");
|
|
}
|
|
}
|
|
|
|
$: if (editModeButton) {
|
|
setIcon(editModeButton, editModeActive ? "pencil" : "pencil-off");
|
|
}
|
|
|
|
function handleStop() {
|
|
onStop();
|
|
}
|
|
|
|
function handleSubmit() {
|
|
if (userRequest.trim() === "" || isSubmitting) {
|
|
return;
|
|
}
|
|
const result = requestFromInput();
|
|
onSubmit(result.request, result.formattedRequest);
|
|
}
|
|
|
|
function handleSuggestion() {
|
|
if (userRequest.trim() === "" || !diffOpen) {
|
|
return;
|
|
}
|
|
const suggestion = requestFromInput();
|
|
diffService.onSuggest(suggestion.formattedRequest);
|
|
}
|
|
|
|
function requestFromInput() {
|
|
const request = textareaElement.innerHTML;
|
|
const formattedRequest = triggerToText(request);
|
|
|
|
textareaElement.textContent = "";
|
|
userRequest = "";
|
|
|
|
if (Platform.isMobile) {
|
|
textareaElement.blur();
|
|
} else {
|
|
focusInput();
|
|
}
|
|
|
|
return { request: request, formattedRequest: formattedRequest };
|
|
}
|
|
|
|
function toggleEditMode() {
|
|
onTogglEeditMode();
|
|
}
|
|
|
|
async function handleKeydown(e: KeyboardEvent) {
|
|
userInstructionActive = false;
|
|
if ($searchState.active) {
|
|
await continueSearch(e);
|
|
return;
|
|
}
|
|
|
|
if (e.key === "Enter") {
|
|
if (e.shiftKey || Platform.isMobile) {
|
|
return;
|
|
}
|
|
e.preventDefault();
|
|
diffOpen ? handleSuggestion() : handleSubmit();
|
|
}
|
|
}
|
|
|
|
// Detect search triggers on character insertion
|
|
function handleBeforeInput(e: InputEvent) {
|
|
// This works reliably on both desktop and mobile (including virtual keyboards)
|
|
if (e.inputType === "insertText" && e.data && isSearchTrigger(e.data)) {
|
|
const position = inputService.getCursorPosition(textareaElement);
|
|
const trigger = fromInput(e.data);
|
|
searchStateStore.initializeSearch(trigger, position);
|
|
}
|
|
// Let the character insert, handleInput() will synchronize the search query
|
|
}
|
|
|
|
async function continueSearch(e: KeyboardEvent) {
|
|
if (!$searchState.trigger) {
|
|
searchStateStore.resetSearch();
|
|
return;
|
|
}
|
|
|
|
if (e.key === "Escape") {
|
|
searchStateStore.resetSearch();
|
|
e.preventDefault();
|
|
return;
|
|
}
|
|
|
|
if (e.key === "Enter") {
|
|
e.preventDefault();
|
|
handleSearchResultAcceptance();
|
|
return;
|
|
}
|
|
|
|
if (e.key === "Backspace" || e.key === "Delete") {
|
|
return;
|
|
}
|
|
|
|
if (e.key.startsWith("Arrow")) {
|
|
if (e.key === "ArrowUp") {
|
|
e.preventDefault();
|
|
searchStateStore.setSelectedResultToPrevious();
|
|
}
|
|
if (e.key === "ArrowDown") {
|
|
e.preventDefault();
|
|
searchStateStore.setSelectedResultToNext();
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Only append printable characters to the query
|
|
if (inputService.isPrintableKey(e.key, e.ctrlKey, e.metaKey)) {
|
|
searchStateStore.appendToQuery(e.key);
|
|
userInputService.performSearch();
|
|
}
|
|
}
|
|
|
|
function handleSearchResultAcceptance(e?: MouseEvent) {
|
|
if ($searchState.selectedResult !== "" && $searchState.position != null && $searchState.trigger != null) {
|
|
const node = toNode($searchState.trigger, $searchState.selectedResult);
|
|
|
|
inputService.deleteTextRange($searchState.position, inputService.getCursorPosition(textareaElement), textareaElement);
|
|
inputService.insertElementAtCursor(node, textareaElement);
|
|
|
|
searchStateStore.resetSearch();
|
|
}
|
|
|
|
e?.preventDefault();
|
|
focusInput(true);
|
|
}
|
|
|
|
function handleInput() {
|
|
if (textareaElement) {
|
|
userRequest = textareaElement.textContent || "";
|
|
|
|
if (textareaElement.innerHTML !== textareaElement.textContent) {
|
|
if (inputService.hasUnauthorizedHTML(textareaElement)) {
|
|
inputService.sanitizeToPlainText(textareaElement);
|
|
}
|
|
}
|
|
|
|
// If in search mode, synchronize the query with actual text content
|
|
if ($searchState.active && $searchState.position !== null) {
|
|
const fullText = textareaElement.textContent || "";
|
|
const triggerPos = $searchState.position;
|
|
|
|
// Extract the query portion (from trigger to cursor position)
|
|
const currentCursorPos = inputService.getCursorPosition(textareaElement);
|
|
const actualQuery = fullText.substring(triggerPos + 1, currentCursorPos);
|
|
|
|
// Only update if the query has changed
|
|
if (actualQuery !== $searchState.query) {
|
|
searchStateStore.setQuery(actualQuery);
|
|
userInputService.performSearch();
|
|
}
|
|
}
|
|
|
|
if (userRequest.trim() === "") {
|
|
textareaElement.textContent = "";
|
|
}
|
|
}
|
|
}
|
|
|
|
function handleCopy(e: ClipboardEvent) {
|
|
e.preventDefault();
|
|
|
|
const selection = window.getSelection();
|
|
|
|
if (!selection) {
|
|
return;
|
|
}
|
|
|
|
const selectedText = selection.toString();
|
|
e.clipboardData?.setData("text/plain", selectedText);
|
|
}
|
|
|
|
async function handleDataTransfer(e: ClipboardEvent | DragEvent) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
const dataTransfer = e instanceof ClipboardEvent ? e.clipboardData : e.dataTransfer;
|
|
|
|
const files = await inputService.getFilesFromDataTransfer(dataTransfer);
|
|
const plainText = inputService.getTextFromDataTransfer(dataTransfer);
|
|
|
|
const newAttachments = files.filter(file => !attachments.some(attachment => attachment.base64 === file.base64));
|
|
attachments = [...attachments, ...newAttachments];
|
|
|
|
if (!plainText || plainText.includes("obsidian:")) {
|
|
return;
|
|
}
|
|
|
|
inputService.insertTextAtCursor(plainText);
|
|
handleInput();
|
|
}
|
|
|
|
function handleDragOver(e: DragEvent) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (e.dataTransfer) {
|
|
e.dataTransfer.dropEffect = "copy";
|
|
e.dataTransfer.effectAllowed = "copy";
|
|
}
|
|
}
|
|
|
|
function handleDragEnter(e: DragEvent) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
}
|
|
|
|
function handleDragLeave(e: DragEvent) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
}
|
|
|
|
function handleCursorPositionChange() {
|
|
if (!$searchState.active || $searchState.position === null) {
|
|
return;
|
|
}
|
|
|
|
const currentPosition = inputService.getCursorPosition(textareaElement);
|
|
|
|
if (!inputService.isInSearchZone(currentPosition, $searchState.position)) {
|
|
searchStateStore.resetSearch();
|
|
}
|
|
}
|
|
|
|
function handleFocusOut() {
|
|
searchStateStore.resetSearch();
|
|
}
|
|
</script>
|
|
|
|
<div id="input-container" class:edit-mode={editModeActive}>
|
|
<div id="input-attachments-container" style:padding-top={attachments.length > 0 ? "var(--size-4-2)" : 0}>
|
|
<ChatAttachments bind:attachments={attachments}/>
|
|
</div>
|
|
|
|
<div id="diff-controls-container" style:padding-top={diffOpen ? "var(--size-4-2)" : 0}>
|
|
<DiffControls {diffOpen}/>
|
|
</div>
|
|
|
|
<div id="input-search-results-container" style:padding-top={$searchState.results.length > 0 ? "var(--size-4-2)" : 0}>
|
|
<ChatSearchResults searchState={$searchState} onResultAccept={handleSearchResultAcceptance}/>
|
|
</div>
|
|
|
|
<div id="user-instruction-container" style:padding-top={userInstructionActive ? "var(--size-4-2)" : 0}>
|
|
<UserInstruction focusInput={focusInput} bind:userInstructionActive={userInstructionActive}/>
|
|
</div>
|
|
|
|
<button
|
|
id="user-instruction-button"
|
|
class:instruction-active={userInstructionActive}
|
|
bind:this={userInstructionButton}
|
|
on:click={() => { userInstructionActive = !userInstructionActive; searchStateStore.resetSearch() }}
|
|
aria-label="User Instruction">
|
|
</button>
|
|
|
|
<div
|
|
id="input-field"
|
|
class:error={hasNoApiKey}
|
|
class:edit-mode={editModeActive && !hasNoApiKey}
|
|
bind:this={textareaElement}
|
|
contenteditable="plaintext-only"
|
|
on:keydown={handleKeydown}
|
|
on:beforeinput={handleBeforeInput}
|
|
on:input={handleInput}
|
|
on:copy={handleCopy}
|
|
on:paste={handleDataTransfer}
|
|
on:drop={handleDataTransfer}
|
|
on:dragover={handleDragOver}
|
|
on:dragenter={handleDragEnter}
|
|
on:dragleave={handleDragLeave}
|
|
on:click={handleCursorPositionChange}
|
|
on:keyup={handleCursorPositionChange}
|
|
on:focusout={handleFocusOut}
|
|
data-placeholder={diffOpen ? "Make a suggestion..." : "Type a message..."}
|
|
role="textbox"
|
|
aria-multiline="true"
|
|
tabindex="0">
|
|
</div>
|
|
|
|
<button
|
|
id="edit-mode-button"
|
|
class:edit-mode={editModeActive}
|
|
bind:this={editModeButton}
|
|
on:click={() => { toggleEditMode() }}
|
|
disabled={isSubmitting}
|
|
aria-label={editModeActive ? "Turn off Agent Mode" : "Turn on Agent Mode"}>
|
|
</button>
|
|
|
|
<button
|
|
id="submit-button"
|
|
class:edit-mode={editModeActive}
|
|
bind:this={submitButton}
|
|
on:click={() => {
|
|
if (diffOpen) {
|
|
userRequest.trim() === "" ? handleStop() : handleSuggestion();
|
|
} else {
|
|
isSubmitting ? handleStop() : handleSubmit();
|
|
}
|
|
}}
|
|
disabled={diffOpen ? false : !isSubmitting && userRequest.trim() === ""}
|
|
aria-label={diffOpen ? (userRequest.trim() === "" ? "Cancel" : "Make Suggestion") : (isSubmitting ? "Cancel" : "Send Message")}>
|
|
</button>
|
|
</div>
|
|
|
|
<style>
|
|
#input-container {
|
|
grid-row: 3;
|
|
grid-column: 1;
|
|
display: grid;
|
|
grid-template-rows: auto auto auto var(--size-4-3) 1fr var(--size-4-3);
|
|
grid-template-columns: var(--size-4-3) auto var(--size-4-2) 1fr var(--size-4-2) auto var(--size-4-2) auto var(--size-4-3);
|
|
border-radius: var(--modal-radius);
|
|
background-color: var(--background-primary);
|
|
}
|
|
|
|
#input-container.edit-mode {
|
|
border-color: var(--alt-interactive-accent);
|
|
transition: border-color 0.5s ease-out;
|
|
}
|
|
|
|
#input-attachments-container {
|
|
grid-row: 1;
|
|
grid-column: 2 / 9;
|
|
}
|
|
|
|
#diff-controls-container {
|
|
grid-row: 2;
|
|
grid-column: 2 / 9;
|
|
}
|
|
|
|
#input-search-results-container {
|
|
grid-row: 3;
|
|
grid-column: 2 / 9;
|
|
}
|
|
|
|
#user-instruction-container {
|
|
grid-row: 3;
|
|
grid-column: 2 / 9;
|
|
}
|
|
|
|
#user-instruction-button {
|
|
grid-row: 5;
|
|
grid-column: 2;
|
|
border-radius: var(--button-radius);
|
|
align-self: end;
|
|
transition-duration: 0.5s;
|
|
}
|
|
|
|
:global(.is-mobile) #user-instruction-button {
|
|
max-height: 2rem;
|
|
}
|
|
|
|
#user-instruction-button.instruction-active {
|
|
box-shadow: 0px 0px 4px 1px var(--color-accent);
|
|
}
|
|
|
|
#input-field {
|
|
grid-row: 5;
|
|
grid-column: 4;
|
|
height: 100%;
|
|
max-height: 30vh;
|
|
border-radius: var(--input-radius);
|
|
font-weight: var(--input-font-weight);
|
|
border-width: var(--input-border-width);
|
|
border-style: solid;
|
|
border-color: var(--background-modifier-border);
|
|
padding: var(--size-2-2) var(--size-2-3);
|
|
background-color: var(--background-primary);
|
|
font-family: var(--font-interface-theme);
|
|
resize: none;
|
|
overflow-y: auto;
|
|
overflow-x: hidden;
|
|
scroll-behavior: smooth;
|
|
color: var(--font-interface-theme);
|
|
transition: border-color 0.5s ease-out;
|
|
word-wrap: break-word;
|
|
white-space: pre-wrap;
|
|
}
|
|
|
|
:global(.is-mobile) #input-field {
|
|
align-content: end;
|
|
}
|
|
|
|
#input-field:focus {
|
|
border-color: var(--color-accent);
|
|
box-shadow: 0px 0px 4px 1px var(--color-accent);
|
|
transition: border-color 0.5s ease-out;
|
|
}
|
|
|
|
#input-field.edit-mode:focus {
|
|
border-color: var(--alt-interactive-accent);
|
|
box-shadow: 0px 0px 3px 1px var(--alt-interactive-accent);
|
|
transition: border-color 0.5s ease-out;
|
|
}
|
|
|
|
#input-field.error,
|
|
#input-field.error:focus {
|
|
border-color: var(--color-red);
|
|
box-shadow: 0px 0px 4px 1px var(--color-red);
|
|
transition: border-color 0.5s ease-out;
|
|
}
|
|
|
|
#input-field::-webkit-scrollbar {
|
|
display: none;
|
|
}
|
|
|
|
#input-field:empty::before {
|
|
content: attr(data-placeholder);
|
|
color: var(--text-muted);
|
|
opacity: 0.75;
|
|
pointer-events: none;
|
|
}
|
|
|
|
#input-field[contenteditable]:focus {
|
|
outline: none;
|
|
}
|
|
|
|
#edit-mode-button {
|
|
grid-row: 5;
|
|
grid-column: 6;
|
|
border-radius: var(--button-radius);
|
|
align-self: end;
|
|
transition-duration: 0.5s;
|
|
}
|
|
|
|
:global(.is-mobile) #edit-mode-button {
|
|
max-height: 2rem;
|
|
}
|
|
|
|
#submit-button {
|
|
grid-row: 5;
|
|
grid-column: 8;
|
|
border-radius: var(--button-radius);
|
|
padding-left: var(--size-4-5);
|
|
padding-right: var(--size-4-5);
|
|
align-self: end;
|
|
transition-duration: 0.5s;
|
|
background-color: var(--interactive-accent);
|
|
}
|
|
|
|
:global(.is-mobile) #submit-button {
|
|
max-height: 2rem;
|
|
}
|
|
|
|
#submit-button:not(:disabled):hover {
|
|
cursor: pointer;
|
|
background-color: var(--interactive-accent-hover);
|
|
}
|
|
|
|
#submit-button.edit-mode {
|
|
background-color: var(--alt-interactive-accent);
|
|
}
|
|
|
|
#submit-button.edit-mode:not(:disabled):hover {
|
|
cursor: pointer;
|
|
background-color: var(--alt-interactive-accent-hover);
|
|
}
|
|
</style>
|