refactor: replace edit/planning mode toggles with unified chat mode selector

Replace separate editModeActive and planningModeActive boolean flags with a single ChatMode enum (ReadOnly, Edit, Planning). Add ChatModeSelector component for mode switching. Update all components to use chatMode instead of individual flags. Refactor MainAgent and ChatService to accept ChatMode parameter. Remove edit-mode styling variants throughout UI components. Update system prompt to document user reference system. Consolidate input button styling with shared input-button-highlight class.
This commit is contained in:
Andrew Beal 2026-04-19 20:39:04 +01:00
parent d3c2a01da9
commit 82e58b52e7
16 changed files with 343 additions and 159 deletions

View file

@ -22,6 +22,7 @@ import { CreateVaultFolder } from "./Tools/CreateVaultFolder";
import { GetWebViewerContent } from "./Tools/GetWebViewerContent";
import { DeleteVaultFolder } from "./Tools/DeleteVaultFolder";
import { MoveVaultFolder } from "./Tools/MoveVaultFolder";
import { ChatMode, chatModeAllowsEdits } from "Enums/ChatMode";
export abstract class AIToolDefinitions {
@ -31,10 +32,10 @@ export abstract class AIToolDefinitions {
private static readonly definitionsList = [SearchVaultFiles, ReadVaultFiles, ListVaultFiles, GetWebViewerContent,
WriteVaultFile, PatchVaultFile, DeleteVaultFiles, MoveVaultFiles, CreateVaultFolder, DeleteVaultFolder, MoveVaultFolder];
public static agentDefinitions(destructive: boolean, planningMode: boolean, memories: boolean, updateMemories: boolean, webViewer: boolean): IAIToolDefinition[] {
public static agentDefinitions(chatMode: ChatMode, memories: boolean, updateMemories: boolean, webViewer: boolean): IAIToolDefinition[] {
this.isGated = false;
if (planningMode) {
if (chatMode === ChatMode.Planning) {
return [ExecuteWorkflow];
}
@ -56,7 +57,7 @@ export abstract class AIToolDefinitions {
actions = actions.concat([UpdateMemories]);
}
if (destructive) {
if (chatModeAllowsEdits(chatMode)) {
actions = actions.concat([
WriteVaultFile,
PatchVaultFile,
@ -110,7 +111,7 @@ export abstract class AIToolDefinitions {
}
public static executionAgentDefinitions(): IAIToolDefinition[] {
return [...this.agentDefinitions(true, false, false, false, false), CompleteTask];
return [...this.agentDefinitions(ChatMode.Edit, false, false, false), CompleteTask];
}
public static compactSummaryForPlanningAgent(): string {

View file

@ -327,6 +327,19 @@ When searches return or reference images or PDFs:
- Extract relevant information to answer the user's query
- Reference the source file with [[wiki-links]] as usual
### User-Provided References
The chat input supports an autocomplete reference system. When a user types \`@\`, \`/\`, or \`#\`, they can select matching vault items from a dropdown. Selected references are serialized into the message as:
- \`file:"path/to/note.md"\` — a specific file the user selected with \`@\`
- \`folder:"path/to/folder"\` — a specific folder the user selected with \`/\`
- \`tag:"#tagname"\` — a specific tag the user selected with \`#\`
**These references are always valid the user selected them from live vault data.** You likely do not have to verify their existence.
Example: If the user says "create a note in \`folder:"Projects/2025"\`", the folder exists and should be used directly as the destination (does not need to be created first).
---
### File Attachments
**Users can attach files directly to their messages.** When a file is attached, its content is provided inline in the conversation. Attached files are likely NOT present in the vault so use the attached content directly.

View file

@ -17,7 +17,6 @@
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;
@ -291,9 +290,9 @@
{/if}
{/each}
<ThoughtIndicator thought={currentThought} {editModeActive} bind:thoughtIndicatorElement={thoughtIndicatorElement}/>
<ThoughtIndicator thought={currentThought} bind:thoughtIndicatorElement={thoughtIndicatorElement}/>
{#if isSubmitting}
<StreamingIndicator editModeActive={editModeActive} bind:streamingIndicatorElement={streamingIndicatorElement}/>
<StreamingIndicator bind:streamingIndicatorElement={streamingIndicatorElement}/>
{/if}
<div bind:this={chatAreaPaddingElement} style:user-select=none></div>

View file

@ -10,6 +10,7 @@
import type { Writable } from "svelte/store";
import type { InputService } from "Services/InputService";
import UserInstruction from "./UserInstruction.svelte";
import ChatModeSelector from "./ChatModeSelector.svelte";
import DiffControls from "./DiffControls.svelte";
import type { EventService } from "Services/EventService";
import { Event } from "Enums/Event";
@ -22,13 +23,13 @@
import { HelpModal } from "Modals/HelpModal";
import type { IPrompt } from "AIPrompts/IPrompt";
import type { SettingsService } from "Services/SettingsService";
import { ChatMode, chatModeAllowsEdits, iconForChatMode } from "Enums/ChatMode";
export let attachments: Attachment[] = [];
export let hasNoApiKey: boolean;
export let isSubmitting: boolean;
export let editModeActive: boolean;
export let planningModeActive: boolean;
export let chatMode: ChatMode;
export let onSubmit: (userRequest: string, formattedRequest: string) => void;
export let onStop: () => void;
@ -47,9 +48,10 @@
let userInstructionButton: HTMLButtonElement;
let webSearchButton: HTMLButtonElement;
let submitButton: HTMLButtonElement;
let editModeButton: HTMLButtonElement;
let planningModeButton: HTMLButtonElement;
let attachmentButton: HTMLButtonElement;
let chatModeButton: HTMLButtonElement;
let chatModeSelectionAreaActive: boolean = false;
let userInstructionAreaActive: boolean = false;
let userInstructionActive: boolean = true;
let stacked: boolean = false;
@ -198,12 +200,12 @@
}
}
$: if (editModeButton) {
setIcon(editModeButton, editModeActive ? "pencil" : "pencil-off");
$: if (attachmentButton) {
setIcon(attachmentButton, "paperclip");
}
$: if (planningModeButton) {
setIcon(planningModeButton, planningModeActive ? "list-ordered" : "list-x");
$: if (chatModeButton) {
setIcon(chatModeButton, iconForChatMode(chatMode));
}
$: inputPlaceholder = (() => {
@ -290,24 +292,8 @@
settingsService.saveSettings();
}
function toggleEditMode() {
if (planningModeActive) {
planningModeActive = false
}
editModeActive = !editModeActive;
focusInput();
}
function togglePlanningMode() {
if (planningModeActive) {
planningModeActive = false;
} else {
if (!editModeActive) {
toggleEditMode(); // Mandatory for planning mode
}
planningModeActive = true;
}
focusInput();
function toggleChatModeSelectionArea() {
chatModeSelectionAreaActive = !chatModeSelectionAreaActive;
}
async function handleKeydown(e: KeyboardEvent) {
@ -501,9 +487,9 @@
}
</script>
<div id="input-container" class:edit-mode={editModeActive} class:stacked>
<div id="input-container" class:stacked>
<div id="input-display-container" style:padding-top={attachments.length > 0 ? "var(--size-4-2)" : 0}>
<InputDisplay bind:this={inputDisplay} {editModeActive}/>
<InputDisplay bind:this={inputDisplay}/>
</div>
<div id="input-attachments-container" style:padding-top={attachments.length > 0 ? "var(--size-4-2)" : 0}>
@ -522,9 +508,13 @@
<UserInstruction focusInput={focusInput} bind:userInstructionAreaActive={userInstructionAreaActive}/>
</div>
<div id="chat-mode-selector-container" style:padding-top={chatModeSelectionAreaActive ? "var(--size-4-2)" : 0}>
<ChatModeSelector focusInput={focusInput} bind:chatModeSelectionAreaActive={chatModeSelectionAreaActive} bind:currentChatMode={chatMode} />
</div>
<button
id="user-instruction-button"
class:instruction-active={userInstructionActive}
class:input-button-highlight={userInstructionActive}
bind:this={userInstructionButton}
on:click={toggleUserInstructionArea}
aria-label={Copy.ButtonUserInstruction}>
@ -532,7 +522,7 @@
<button
id="web-search-button"
class:web-search-active={settingsService.settings.enableWebSearch}
class:input-button-highlight={settingsService.settings.enableWebSearch}
bind:this={webSearchButton}
on:click={toggleWebSearch}
aria-label={settingsService.settings.enableWebSearch ? Copy.ButtonTurnOffWebSearch : Copy.ButtonTurnOnWebSearch}>
@ -541,7 +531,6 @@
<div
id="input-field"
class:error={hasNoApiKey}
class:edit-mode={editModeActive && !hasNoApiKey}
bind:this={textareaElement}
contenteditable="plaintext-only"
on:keydown={handleKeydown}
@ -563,26 +552,25 @@
</div>
<button
id="edit-mode-button"
class:edit-mode={editModeActive}
bind:this={editModeButton}
on:click={() => { toggleEditMode() }}
id="chat-attachment-button"
bind:this={attachmentButton}
on:click={() => { }}
disabled={isSubmitting}
aria-label={editModeActive ? Copy.ButtonTurnOffAgentMode : Copy.ButtonTurnOnAgentMode}>
aria-label={"Attachment"}>
<!-- Copy.AttachmentLabel -->
</button>
<button
id="planning-mode-button"
class:planning-mode={planningModeActive}
bind:this={planningModeButton}
on:click={() => { togglePlanningMode() }}
id="chat-mode-button"
class:input-button-highlight={chatModeAllowsEdits(chatMode)}
bind:this={chatModeButton}
on:click={() => { toggleChatModeSelectionArea() }}
disabled={isSubmitting}
aria-label={planningModeActive ? Copy.ButtonTurnOffPlanningMode : Copy.ButtonTurnOnPlanningMode}>
aria-label={Copy.ButtonChangeChatMode}>
</button>
<button
id="submit-button"
class:edit-mode={editModeActive}
bind:this={submitButton}
on:click={() => {
if (inputMode === InputMode.Question) {
@ -609,11 +597,6 @@
background-color: var(--background-primary);
}
#input-container.edit-mode {
border-color: var(--alt-interactive-accent);
transition: border-color 0.5s ease-out;
}
#input-display-container {
grid-row: 1;
grid-column: 2 / 13;
@ -639,10 +622,16 @@
grid-column: 2 / 13;
}
#chat-mode-selector-container {
grid-row: 4;
grid-column: 2 / 13;
}
#user-instruction-button {
grid-row: 6;
grid-column: 2;
border-radius: var(--radius-l);
border-radius: var(--radius-xl);
padding: var(--size-4-2);
align-self: end;
transition-duration: 0.5s;
}
@ -651,22 +640,15 @@
max-height: 2rem;
}
#user-instruction-button.instruction-active {
box-shadow: 0px 0px 2px 1px var(--color-accent);
}
#web-search-button {
grid-row: 6;
grid-column: 4;
border-radius: var(--radius-l);
border-radius: var(--radius-xl);
padding: var(--size-4-2);
align-self: end;
transition-duration: 0.5s;
}
#web-search-button.web-search-active {
box-shadow: 0px 0px 2px 1px var(--color-accent);
}
:global(.is-mobile) #web-search-button {
max-height: 2rem;
}
@ -704,12 +686,6 @@
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);
@ -732,44 +708,42 @@
outline: none;
}
#edit-mode-button {
#chat-attachment-button {
grid-row: 6;
grid-column: 8;
border-radius: var(--radius-l);
border-radius: var(--radius-xl);
padding: var(--size-4-2);
align-self: end;
transition-duration: 0.5s;
}
#edit-mode-button.edit-mode {
box-shadow: inset 0px 0px 1px 1px var(--alt-interactive-accent);
}
:global(.is-mobile) #edit-mode-button {
:global(.is-mobile) #chat-attachment-button {
max-height: 2rem;
}
#planning-mode-button {
#chat-mode-button {
grid-row: 6;
grid-column: 10;
border-radius: var(--radius-l);
border-radius: var(--radius-xl);
padding: var(--size-4-2);
align-self: end;
transition-duration: 0.5s;
}
#planning-mode-button.planning-mode {
box-shadow: inset 0px 0px 1px 1px var(--alt-interactive-accent);
:global(.is-mobile) #chat-mode-button {
max-height: 2rem;
}
:global(.is-mobile) #planning-mode-button {
max-height: 2rem;
.input-button-highlight {
box-shadow: 0px 0px 2px 1px var(--color-accent);
}
#submit-button {
grid-row: 6;
grid-column: 12;
border-radius: var(--radius-l);
padding-left: var(--size-4-5);
padding-right: var(--size-4-5);
border-radius: var(--radius-xl);
padding-left: var(--size-4-2);
padding-right: var(--size-4-2);
align-self: end;
transition-duration: 0.5s;
background-color: var(--interactive-accent);
@ -784,15 +758,6 @@
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);
}
/* Stacked layout: input above, buttons below (desktop only, when content wraps) */
#input-container.stacked {
grid-template-rows: auto auto auto auto var(--size-4-3) 1fr var(--size-4-2) auto var(--size-4-3);
@ -810,11 +775,11 @@
grid-row: 8;
}
#input-container.stacked #edit-mode-button {
#input-container.stacked #chat-attachment-button {
grid-row: 8;
}
#input-container.stacked #planning-mode-button {
#input-container.stacked #chat-mode-button {
grid-row: 8;
}
@ -832,7 +797,8 @@
:global(.is-mobile) #input-attachments-container,
:global(.is-mobile) #diff-controls-container,
:global(.is-mobile) #input-search-results-container,
:global(.is-mobile) #user-instruction-container {
:global(.is-mobile) #user-instruction-container,
:global(.is-mobile) #chat-mode-selector-container {
grid-column: 2 / 11;
}
@ -851,12 +817,12 @@
grid-column: 4;
}
:global(.is-mobile) #edit-mode-button {
:global(.is-mobile) #chat-attachment-button {
grid-row: 8;
grid-column: 6;
}
:global(.is-mobile) #planning-mode-button {
:global(.is-mobile) #chat-mode-button {
grid-row: 8;
grid-column: 8;
}

View file

@ -0,0 +1,210 @@
<script lang="ts">
import { ChatMode, iconForChatMode } from "Enums/ChatMode";
import { Copy } from "Enums/Copy";
import { tick } from "svelte";
import { setIcon } from "obsidian";
export let focusInput: () => void;
export let chatModeSelectionAreaActive: boolean;
export let currentChatMode: ChatMode;
let height = 0;
let ChatModeSelectionContentDiv: HTMLDivElement;
let chatModeSelectionContainer: HTMLDivElement;
let selectedChatMode: number = ChatMode.ReadOnly;
let iconElements: (HTMLDivElement | null)[] = [null, null, null];
$: if (iconElements.some(element => element !== null)) {
iconElements.forEach((element, index) => {
if (element) {
setIcon(element, iconForChatMode(index));
}
});
}
$: chatModeSelectionAreaActive, updateHeight();
$: if (chatModeSelectionAreaActive) {
selectedChatMode = ChatMode.ReadOnly;
setTimeout(() => {
if (chatModeSelectionContainer && chatModeSelectionAreaActive) {
document.addEventListener("click", handleClickOutside);
updateHeight();
if (ChatModeSelectionContentDiv) {
ChatModeSelectionContentDiv.focus();
}
}
}, 10);
} else {
document.removeEventListener("click", handleClickOutside);
}
function updateHeight() {
tick().then(() => {
if (ChatModeSelectionContentDiv) {
height = ChatModeSelectionContentDiv.scrollHeight;
} else {
height = 0;
}
});
}
function handleClickOutside(event: MouseEvent) {
if (chatModeSelectionContainer && !chatModeSelectionContainer.contains(event.target as Node)) {
chatModeSelectionAreaActive = false;
}
}
function handleChatModeSelect(e?: MouseEvent) {
currentChatMode = selectedChatMode;
chatModeSelectionAreaActive = false;
e?.preventDefault();
focusInput();
}
async function handleKeydown(e: KeyboardEvent) {
if (!chatModeSelectionAreaActive) {
return;
}
e.preventDefault();
if (e.key.startsWith("Arrow")) {
if (e.key === "ArrowUp") {
selectedChatMode = selectedChatMode <= 0 ? 2 : selectedChatMode - 1;
}
if (e.key === "ArrowDown") {
selectedChatMode = selectedChatMode >= 2 ? 0 : selectedChatMode + 1;
}
return;
}
if (e.key === "Enter") {
handleChatModeSelect();
}
if (e.key === "Escape") {
chatModeSelectionAreaActive = false;
return;
}
}
</script>
<div id="chat-mode-selection" style:height="{height}px" bind:this={chatModeSelectionContainer}>
{#if chatModeSelectionAreaActive}
<div
id="chat-mode-selection-inner-container"
bind:this={ChatModeSelectionContentDiv}
role="listbox"
tabindex="0"
on:keydown={handleKeydown}>
<div class="chat-mode-selection-container"
role="option"
tabindex="-1"
aria-selected={selectedChatMode === ChatMode.ReadOnly}
class:current-chat-mode={currentChatMode === ChatMode.ReadOnly}
style:background-color={selectedChatMode === ChatMode.ReadOnly ? "var(--interactive-accent)" : "transparent"}
on:mouseenter={() => selectedChatMode = ChatMode.ReadOnly}
on:mousedown={handleChatModeSelect}
on:keydown={() => {}}>
<div class="chat-mode-selection-icon" bind:this={iconElements[ChatMode.ReadOnly]}></div>
<div class="chat-mode-selection-title">{Copy.ChatModeReadOnlyTitle}</div>
<div class="chat-mode-selection-subtitle">{Copy.ChatModeReadOnlyDesc}</div>
</div>
<div class="chat-mode-selection-container"
role="option"
tabindex="-1"
aria-selected={selectedChatMode === ChatMode.Edit}
class:current-chat-mode={currentChatMode === ChatMode.Edit}
style:background-color={selectedChatMode === ChatMode.Edit ? "var(--interactive-accent)" : "transparent"}
on:mouseenter={() => selectedChatMode = ChatMode.Edit}
on:mousedown={handleChatModeSelect}
on:keydown={() => {}}>
<div class="chat-mode-selection-icon" bind:this={iconElements[ChatMode.Edit]}></div>
<div class="chat-mode-selection-title">{Copy.ChatModeEditTitle}</div>
<div class="chat-mode-selection-subtitle">{Copy.ChatModeEditDesc}</div>
</div>
<div class="chat-mode-selection-container"
role="option"
tabindex="-1"
aria-selected={selectedChatMode === ChatMode.Planning}
class:current-chat-mode={currentChatMode === ChatMode.Planning}
style:background-color={selectedChatMode === ChatMode.Planning ? "var(--interactive-accent)" : "transparent"}
on:mouseenter={() => selectedChatMode = ChatMode.Planning}
on:mousedown={handleChatModeSelect}
on:keydown={() => {}}>
<div class="chat-mode-selection-icon" bind:this={iconElements[ChatMode.Planning]}></div>
<div class="chat-mode-selection-title">{Copy.ChatModePlanningTitle}</div>
<div class="chat-mode-selection-subtitle">{Copy.ChatModePlanningDesc}</div>
</div>
</div>
{/if}
</div>
<style>
#chat-mode-selection {
max-height: 15em;
transition: height 0.2s ease-out;
overflow: auto;
scroll-behavior: smooth;
}
#chat-mode-selection::-webkit-scrollbar {
display: none;
}
#chat-mode-selection-inner-container {
display: flex;
flex-direction: column;
gap: var(--size-2-2);
}
.chat-mode-selection-container {
display: grid;
grid-template-rows: auto auto;
grid-template-columns: auto 1fr;
border-style: solid;
border-radius: var(--size-2-2);
border-color: var(--background-primary-alt);
border-width: 1px;
padding: var(--size-2-2) var(--size-4-2);
cursor: pointer;
}
.chat-mode-selection-icon {
grid-row: 1 / 3;
grid-column: 1;
display: flex;
align-items: center;
justify-content: center;
padding-right: var(--size-4-2);
pointer-events: none;
}
.chat-mode-selection-container.current-chat-mode {
box-shadow: inset 0px 0px 4px 1px var(--color-accent);
border-color: var(--color-accent);
}
.chat-mode-selection-title {
grid-row: 1;
grid-column: 2;
font-family: var(--font-interface-theme);
}
.chat-mode-selection-subtitle {
grid-row: 2;
grid-column: 2;
font-family: var(--font-interface-theme);
font-size: var(--font-smallest);
color: var(--text-muted);
}
:global(.is-mobile) .chat-mode-selection-container[aria-selected="true"] {
background-color: transparent !important;
}
</style>

View file

@ -8,7 +8,6 @@
import type { Writable } from "svelte/store";
export let executionPlanState: Writable<IExecutionPlanState>;
export let editModeActive = false;
export let busyPlanning = false;
let expanded = false;
@ -116,7 +115,7 @@
{#if busyPlanning}
<div id="chat-planning-in-progress" transition:slide>
<Spinner {editModeActive} alternateBackground={true}/>
<Spinner alternateBackground={true}/>
<span id="chat-planning-in-progress-text">{Copy.PlanningInProgress}</span>
</div>
{/if}
@ -137,7 +136,7 @@
{/if}
{#if index === activeStepIndex}
<div class="chat-plan-step-icon">
<Spinner {editModeActive} alternateBackground={true}/>
<Spinner alternateBackground={true}/>
</div>
{/if}
{#if index > activeStepIndex}

View file

@ -21,6 +21,7 @@
import type { StreamingMarkdownService } from "Services/StreamingMarkdownService";
import { HTMLService } from "Services/HTMLService";
import { AITool, fromString } from "Enums/AITool";
import { ChatMode } from "Enums/ChatMode";
const plugin: VaultkeeperAIPlugin = Resolve<VaultkeeperAIPlugin>(Services.VaultkeeperAIPlugin);
const executionPlanStore: ExecutionPlanStore = Resolve<ExecutionPlanStore>(Services.ExecutionPlanStore);
@ -39,8 +40,7 @@
let hasNoApiKey = false;
let isSubmitting = false;
let busyPlanning = false;
let editModeActive = false;
let planningModeActive = false;
let chatMode: ChatMode = ChatMode.ReadOnly;
let currentStreamingMessageId: string | null = null;
let conversation: Conversation = new Conversation();
@ -103,7 +103,7 @@
const currentRequest = userRequest;
await chatService.submit(conversation, editModeActive, planningModeActive, currentRequest, formattedRequest, attachments, {
await chatService.submit(conversation, chatMode, currentRequest, formattedRequest, attachments, {
onSubmit: () => {
isSubmitting = true;
attachments = [];
@ -207,24 +207,22 @@
$: if ($conversationStore.shouldDeactivateEditMode) {
conversationStore.clearEditModeFlag();
planningModeActive = false;
editModeActive = false;
chatMode = ChatMode.ReadOnly;
}
</script>
<main class="container">
<ChatPlanArea executionPlanState={executionPlanStore.executionPlanState} {editModeActive} {busyPlanning}/>
<ChatPlanArea executionPlanState={executionPlanStore.executionPlanState} {busyPlanning}/>
<div id="chat-container">
<ChatArea messages={conversation.contents} bind:this={chatArea} bind:currentThought bind:isSubmitting bind:chatContainer
currentStreamingMessageId={currentStreamingMessageId} editModeActive={editModeActive}/>
currentStreamingMessageId={currentStreamingMessageId}/>
</div>
<ChatInput
bind:this={chatInput}
bind:attachments
bind:editModeActive
bind:planningModeActive
bind:chatMode={chatMode}
{hasNoApiKey}
{isSubmitting}
onSubmit={handleSubmit}

View file

@ -1,8 +1,6 @@
<script lang="ts">
import { slide } from "svelte/transition";
export let editModeActive = false;
let displayItem: HTMLElement | undefined;
let contentDiv: HTMLDivElement;
@ -22,7 +20,7 @@
{#if displayItem}
<div id="input-display-wrapper" transition:slide>
<div id="input-display-container" class:edit-mode={editModeActive} bind:this={contentDiv}>
<div id="input-display-container" bind:this={contentDiv}>
</div>
</div>
{/if}
@ -56,12 +54,6 @@
max-height: 15vh;
}
#input-display-container.edit-mode {
border-color: var(--alt-interactive-accent);
box-shadow: inset 0px 0px 2px 1px var(--alt-interactive-accent);
transition: border-color 0.5s ease-out;
}
#input-display-container::-webkit-scrollbar {
display: none;
}

View file

@ -1,14 +1,12 @@
<script lang="ts">
export let width: string = "18px";
export let height: string = "18px";
export let editModeActive: boolean = false;
export let alternateBackground: boolean = false;
export let spinnerElement: HTMLDivElement | undefined = undefined;
</script>
<div
class="circle-border"
class:edit-mode={editModeActive}
style="width: {width}; height: {height};"
bind:this={spinnerElement}
>
@ -42,10 +40,6 @@
animation: spin 0.8s linear 0s infinite;
}
.circle-border.edit-mode {
--color: var(--alt-interactive-accent);
}
.circle-core {
width: 100%;
height: 100%;

View file

@ -1,9 +1,8 @@
<script lang="ts">
export let editModeActive: boolean = false;
export let streamingIndicatorElement: HTMLElement | undefined = undefined;
</script>
<div class="loader" class:edit-mode={editModeActive} bind:this={streamingIndicatorElement}>
<div class="loader" bind:this={streamingIndicatorElement}>
<div class="circle">
<div class="dot"></div>
<div class="outline"></div>
@ -33,10 +32,6 @@
--animation: 2s ease-in-out infinite;
}
.loader.edit-mode {
--color: var(--alt-interactive-accent);
}
.loader .circle {
display: flex;
align-items: center;

View file

@ -1,10 +1,8 @@
<script lang="ts">
import { fade } from "svelte/transition";
import Spinner from "./Spinner.svelte";
export let thought: string | null = null;
export let thoughtIndicatorElement: HTMLElement | undefined = undefined;
export let editModeActive : boolean = false;
$: isVisible = thought !== null && thought.trim().length > 0;
@ -13,7 +11,7 @@
{#if isVisible}
<div class="ai-thought-container" in:fade={{ duration: 200 }} out:fade={{ duration: 200 }} bind:this={thoughtIndicatorElement}>
<div class="ai-thought-bubble">
<span><Spinner {editModeActive}/> {thought}</span>
<span><Spinner/> {thought}</span>
</div>
</div>
{/if}

20
Enums/ChatMode.ts Normal file
View file

@ -0,0 +1,20 @@
export enum ChatMode {
ReadOnly = 0,
Edit = 1,
Planning = 2
}
export function chatModeAllowsEdits(mode: ChatMode) {
return mode === ChatMode.Edit || mode === ChatMode.Planning;
}
export function iconForChatMode(mode: ChatMode) {
switch (mode) {
case ChatMode.ReadOnly:
return "eye";
case ChatMode.Edit:
return "pencil";
case ChatMode.Planning:
return "list-checks";
}
}

View file

@ -95,6 +95,14 @@ export enum Copy {
SafeContinue= "Continue",
// Chat Mode Selector
ChatModeReadOnlyTitle = "Read-only",
ChatModeReadOnlyDesc = "The AI can search and read your vault but cannot make any changes.",
ChatModeEditTitle = "Allow Edits",
ChatModeEditDesc = "The AI can create, edit, move, and delete files in your vault.",
ChatModePlanningTitle = "Planning",
ChatModePlanningDesc = "The AI plans its approach before executing tasks in your vault.",
// Chat Input Placeholders
InputPlaceholderQuestion = "Provide an answer...",
InputPlaceholderDiff = "Make a suggestion...",
@ -105,8 +113,7 @@ export enum Copy {
ButtonSubmitAnswer = "Submit answer",
ButtonMakeSuggestion = "Make Suggestion",
ButtonSendMessage = "Send Message",
ButtonTurnOffAgentMode = "Turn off Agent Mode",
ButtonTurnOnAgentMode = "Turn on Agent Mode",
ButtonChangeChatMode = "Change the Chat Mode",
ButtonTurnOffPlanningMode = "Turn off Planning Mode",
ButtonTurnOnPlanningMode = "Turn on Planning Mode",
ButtonTurnOffWebSearch = "Turn off Web Search",

View file

@ -14,14 +14,15 @@ import { Role } from "Enums/Role";
import { DebugColor } from "Enums/DebugColor";
import { AIToolUsageMode } from "Enums/AIToolUsageMode";
import { AIToolResponsePayload } from "AIClasses/ToolDefinitions/AIToolResponsePayload";
import { ChatMode } from "Enums/ChatMode";
export class MainAgent extends BaseAgent {
public async runMainAgent(conversation: Conversation, allowDestructiveActions: boolean, planningMode: boolean, callbacks: IChatServiceCallbacks) {
await this.setAgentPromptAndTools(planningMode, allowDestructiveActions);
this.debugService?.log("MainAgent", `Starting MainAgent (planningMode: ${planningMode}, destructive: ${allowDestructiveActions})`);
public async runMainAgent(conversation: Conversation, chatMode: ChatMode, callbacks: IChatServiceCallbacks) {
await this.setAgentPromptAndTools(chatMode);
this.debugService?.log("MainAgent", `Starting MainAgent (chatMode: ${chatMode})`);
if (planningMode) {
if (chatMode === ChatMode.Planning) {
this.debugService?.log("MainAgent", "Planning mode enabled - workflow execution available");
conversation.contents.push(new ConversationContent({
role: Role.User,
@ -45,7 +46,7 @@ export class MainAgent extends BaseAgent {
result.toolCall.toolId
));
await this.setAgentPromptAndTools(planningMode, allowDestructiveActions);
await this.setAgentPromptAndTools(chatMode);
result = await this.runMainAgentLoop(conversation, callbacks);
}
}
@ -85,7 +86,7 @@ export class MainAgent extends BaseAgent {
return { planRequest: planRequest, toolCall: planToolCall };
}
private async setAgentPromptAndTools(planningMode: boolean, allowDestructiveActions: boolean): Promise<void> {
private async setAgentPromptAndTools(chatMode: ChatMode): Promise<void> {
if (!this.ai) { // this shouldn't ever happen
Exception.throw("Error: No AI provider has been set!");
}
@ -93,8 +94,8 @@ export class MainAgent extends BaseAgent {
this.ai.aiToolUsageMode = AIToolUsageMode.Auto;
this.ai.systemPrompt = await this.aiPrompt.systemInstruction();
this.ai.userInstruction = await this.aiPrompt.userInstruction();
this.ai.aiToolDefinitions = AIToolDefinitions.agentDefinitions(allowDestructiveActions, planningMode,
this.memoriesEnabled(), this.updateMemoriesEnabled(), this.webViewerAccessEnabled());
this.ai.aiToolDefinitions = AIToolDefinitions.agentDefinitions(
chatMode, this.memoriesEnabled(), this.updateMemoriesEnabled(), this.webViewerAccessEnabled());
}
protected override setDebugColor(): void {

View file

@ -16,6 +16,7 @@ import { Reference } from "Conversations/Reference";
import type { WorkSpaceService } from "./WorkSpaceService";
import type { ExecutionPlan } from "Types/ExecutionPlan";
import type { MainAgent } from "./AIServices/MainAgent";
import type { ChatMode } from "Enums/ChatMode";
export interface IChatServiceCallbacks {
onSubmit: () => void;
@ -59,7 +60,7 @@ export class ChatService {
public onNameChanged: ((name: string) => void) | undefined = undefined;
public async submit(conversation: Conversation, allowDestructiveActions: boolean, planningMode: boolean, userRequest: string, formattedRequest: string, attachments: Attachment[], callbacks: IChatServiceCallbacks) {
public async submit(conversation: Conversation, chatMode: ChatMode, userRequest: string, formattedRequest: string, attachments: Attachment[], callbacks: IChatServiceCallbacks) {
if (!await this.semaphore.wait()) {
return;
}
@ -108,7 +109,7 @@ export class ChatService {
callbacks.onSubmit();
callbacks.onStreamingUpdate(null);
await this.mainAgent.runMainAgent(conversation, allowDestructiveActions, planningMode, callbacks);
await this.mainAgent.runMainAgent(conversation, chatMode, callbacks);
if (namingPromise) {
const timeout = new Promise<void>(resolve => setTimeout(resolve, 5000));

View file

@ -50,7 +50,7 @@
}
.ai-exclusions-input {
width: 350px;
width: max(350px, 100%);
height: 110px;
font: var(--font-monospace);
font-size: 0.9em;
@ -202,16 +202,6 @@ body {
--checkbox-border: var(--background-modifier-border);
--checkbox-checked-bg: var(--interactive-accent);
--footnote-border: var(--background-modifier-border);
--alt-interactive-accent: color-mix(
in hsl longer hue,
var(--interactive-accent) 50%,
hsl(130, 100%, 50%) 50%
);
--alt-interactive-accent-hover: color-mix(
in hsl longer hue,
var(--interactive-accent-hover) 50%,
hsl(130 100% 50%) 50%
);
--alt-background-primary: color-mix(
in hsl shorter hue,
var(--background-primary) 75%,