diff --git a/src/interface/obsidian/src/chat_view.ts b/src/interface/obsidian/src/chat_view.ts index b76d2808..1b4dc480 100644 --- a/src/interface/obsidian/src/chat_view.ts +++ b/src/interface/obsidian/src/chat_view.ts @@ -1,9 +1,9 @@ import { ItemView, MarkdownRenderer, Scope, WorkspaceLeaf, request, requestUrl, setIcon, Platform, TFile } from 'obsidian'; import * as DOMPurify from 'isomorphic-dompurify'; -import { KhojSetting } from 'src/settings'; import { KhojPaneView } from 'src/pane_view'; import { KhojView, createCopyParentText, getLinkToEntry, pasteTextAtCursor } from 'src/utils'; import { KhojSearchModal } from 'src/search_modal'; +import Khoj from 'src/main'; import { FileInteractions, EditBlock } from 'src/interact_with_files'; export interface ChatJsonResult { @@ -67,12 +67,12 @@ interface Agent { export class KhojChatView extends KhojPaneView { result: string; - setting: KhojSetting; waitingForLocation: boolean; location: Location = { timezone: Intl.DateTimeFormat().resolvedOptions().timeZone }; keyPressTimeout: NodeJS.Timeout | null = null; userMessages: string[] = []; // Store user sent messages for input history cycling currentMessageIndex: number = -1; // Track current message index in userMessages array + voiceChatActive: boolean = false; // Flag to track if voice chat is active private currentUserInput: string = ""; // Stores the current user input that is being typed in chat private startingMessage: string = this.getLearningMoment(); chatMessageState: ChatMessageState; @@ -101,10 +101,13 @@ export class KhojChatView extends KhojPaneView { // 2. Higher invalid edit blocks than tolerable private maxEditRetries: number = 1; // Maximum retries for edit blocks - constructor(leaf: WorkspaceLeaf, setting: KhojSetting) { - super(leaf, setting); + constructor(leaf: WorkspaceLeaf, plugin: Khoj) { + super(leaf, plugin); this.fileInteractions = new FileInteractions(this.app); + // Initialize file access mode from persisted settings + this.fileAccessMode = this.setting.fileAccessMode ?? 'read'; + this.waitingForLocation = true; fetch("https://ipapi.co/json") @@ -129,7 +132,7 @@ export class KhojChatView extends KhojPaneView { this.scope = new Scope(this.app.scope); this.scope.register(["Ctrl", "Alt"], 'n', (_) => this.createNewConversation(this.currentAgent)); this.scope.register(["Ctrl", "Alt"], 'o', async (_) => await this.toggleChatSessions()); - this.scope.register(["Ctrl", "Alt"], 'v', (_) => this.speechToText(new KeyboardEvent('keydown'))); + this.scope.register(["Ctrl", "Alt"], 'v', (_) => this.speechToText(this.voiceChatActive ? new KeyboardEvent('keyup') : new KeyboardEvent('keydown'))); this.scope.register(["Ctrl"], 'f', (_) => new KhojSearchModal(this.app, this.setting).open()); this.scope.register(["Ctrl"], 'r', (_) => { this.activateView(KhojView.SIMILAR); }); } @@ -272,29 +275,48 @@ export class KhojChatView extends KhojPaneView { text: "File Access", attr: { class: "khoj-input-row-button clickable-icon", - title: "Toggle file access mode (Read Only)", + title: "Toggle open file access", }, }); - setIcon(fileAccessButton, "file-search"); - fileAccessButton.addEventListener('click', () => { + // Set initial icon based on persisted setting + switch (this.fileAccessMode) { + case 'none': + setIcon(fileAccessButton, "file-x"); + fileAccessButton.title = "Toggle open file access (No Access)"; + break; + case 'write': + setIcon(fileAccessButton, "file-edit"); + fileAccessButton.title = "Toggle open file access (Read & Write)"; + break; + case 'read': + default: + setIcon(fileAccessButton, "file-search"); + fileAccessButton.title = "Toggle open file access (Read Only)"; + break; + } + fileAccessButton.addEventListener('click', async () => { // Cycle through modes: none -> read -> write -> none switch (this.fileAccessMode) { case 'none': this.fileAccessMode = 'read'; setIcon(fileAccessButton, "file-search"); - fileAccessButton.title = "Toggle file access mode (Read Only)"; + fileAccessButton.title = "Toggle open file access (Read Only)"; break; case 'read': this.fileAccessMode = 'write'; setIcon(fileAccessButton, "file-edit"); - fileAccessButton.title = "Toggle file access mode (Read & Write)"; + fileAccessButton.title = "Toggle open file access (Read & Write)"; break; case 'write': this.fileAccessMode = 'none'; setIcon(fileAccessButton, "file-x"); - fileAccessButton.title = "Toggle file access mode (No Access)"; + fileAccessButton.title = "Toggle open file access (No Access)"; break; } + + // Persist the updated mode to settings + this.setting.fileAccessMode = this.fileAccessMode; + await this.plugin.saveSettings(); }); let chatInput = inputRow.createEl("textarea", { @@ -319,7 +341,7 @@ export class KhojChatView extends KhojPaneView { attr: { id: "khoj-transcribe", class: "khoj-transcribe khoj-input-row-button clickable-icon ", - title: "Start Voice Chat (Ctrl+Alt+V)", + title: "Hold to Voice Chat (Ctrl+Alt+V)", }, }) transcribe.addEventListener('mousedown', (event) => { this.startSpeechToText(event) }); @@ -1037,7 +1059,7 @@ export class KhojChatView extends KhojPaneView { if (incomingConversationId == conversationId) { conversationSessionEl.classList.add("selected-conversation"); } - const conversationTitle = conversation["slug"] || `New conversation 🌱`; + const conversationTitle = conversation["slug"].split("")[0].trim() || `New conversation 🌱`; const conversationSessionTitleEl = conversationSessionEl.createDiv("conversation-session-title"); conversationSessionTitleEl.textContent = conversationTitle; conversationSessionTitleEl.addEventListener('click', () => { @@ -1190,7 +1212,7 @@ export class KhojChatView extends KhojPaneView { chatUrl += `&conversation_id=${chatBodyEl.dataset.conversationId}`; } - console.log("Fetching chat history from:", chatUrl); + console.debug("Fetching chat history from:", chatUrl); try { let response = await fetch(chatUrl, { @@ -1199,7 +1221,7 @@ export class KhojChatView extends KhojPaneView { }); let responseJson: any = await response.json(); - console.log("Chat history response:", responseJson); + console.debug("Chat history response:", responseJson); chatBodyEl.dataset.conversationId = responseJson.conversation_id; @@ -1221,7 +1243,7 @@ export class KhojChatView extends KhojPaneView { // Update current agent from conversation history if (responseJson.response.agent?.slug) { - console.log("Found agent in conversation history:", responseJson.response.agent); + console.debug("Found agent in conversation history:", responseJson.response.agent); this.currentAgent = responseJson.response.agent.slug; // Update the agent selector if it exists const agentSelect = this.contentEl.querySelector('.khoj-header-agent-select') as HTMLSelectElement; @@ -1352,18 +1374,25 @@ export class KhojChatView extends KhojPaneView { if (this.fileAccessMode === 'write') { const editBlocks = this.parseEditBlocks(this.chatMessageState.rawResponse); - // Check for errors and retry if needed - if (editBlocks.length > 0 && editBlocks[0].hasError && this.editRetryCount < this.maxEditRetries) { - await this.handleEditRetry(editBlocks[0]); - return; - } - - // Reset retry count on success - this.editRetryCount = 0; - - // Apply edits if there are any if (editBlocks.length > 0) { - await this.applyEditBlocks(editBlocks); + const firstBlock = editBlocks[0]; + if (firstBlock.hasError) { + // Only retry if we have remaining attempts; do NOT reset counter on failure + if (this.editRetryCount < this.maxEditRetries) { + await this.handleEditRetry(firstBlock); + return; // Wait for retry response + } else { + // Exhausted retries; surface error and do not attempt further automatic retries + console.warn('[Khoj] Max edit retries reached. Aborting further retries.'); + } + } else { + // Successful parse => reset counter and apply edits + this.editRetryCount = 0; + await this.applyEditBlocks(editBlocks); + } + } else { + // No edit blocks => reset counter just in case + this.editRetryCount = 0; } } @@ -1720,6 +1749,7 @@ export class KhojChatView extends KhojPaneView { // Toggle recording if (!this.mediaRecorder || this.mediaRecorder.state === 'inactive' || event.type === 'touchstart' || event.type === 'mousedown' || event.type === 'keydown') { + this.voiceChatActive = true; navigator.mediaDevices .getUserMedia({ audio: true }) ?.then(handleRecording) @@ -1727,6 +1757,7 @@ export class KhojChatView extends KhojPaneView { this.flashStatusInChatInput("⛔️ Failed to access microphone"); }); } else if (this.mediaRecorder?.state === 'recording' || event.type === 'touchend' || event.type === 'touchcancel' || event.type === 'mouseup' || event.type === 'keyup') { + this.voiceChatActive = false; this.mediaRecorder.stop(); this.mediaRecorder.stream.getTracks().forEach(track => track.stop()); this.mediaRecorder = undefined; @@ -2403,7 +2434,7 @@ export class KhojChatView extends KhojPaneView { // Add retry count retryBadge.createSpan({ cls: "retry-count", - text: `Attempt ${this.editRetryCount}/3` + text: `Attempt ${this.editRetryCount}/${this.maxEditRetries}` }); // Add error details as a tooltip @@ -2418,7 +2449,7 @@ export class KhojChatView extends KhojPaneView { retryBadge.scrollIntoView({ behavior: "smooth", block: "center" }); // Create a retry prompt for the LLM - const retryPrompt = `/general I noticed some issues with the edit block. Please fix the following and provide a corrected version (retry ${this.editRetryCount}/3):\n\n${errorDetails}\n\nPlease provide a new edit block that fixes these issues. Make sure to follow the exact format required.`; + const retryPrompt = `/general I noticed some issues with the edit block. Please fix the following and provide a corrected version (retry ${this.editRetryCount}/${this.maxEditRetries}):\n\n${errorDetails}\n\nPlease provide a new edit block that fixes these issues. Make sure to follow the exact format required.`; // Send retry request without displaying the user message await this.getChatResponse(retryPrompt, "", false, false); diff --git a/src/interface/obsidian/src/interact_with_files.ts b/src/interface/obsidian/src/interact_with_files.ts index ebd2b7a7..baa6ce8f 100644 --- a/src/interface/obsidian/src/interact_with_files.ts +++ b/src/interface/obsidian/src/interact_with_files.ts @@ -1,4 +1,4 @@ -import { App, TFile } from 'obsidian'; +import { App, MarkdownView, TFile } from 'obsidian'; import { diffWords } from 'diff'; /** @@ -55,6 +55,7 @@ export class FileInteractions { private app: App; private readonly EDIT_BLOCK_START = ''; private readonly EDIT_BLOCK_END = ''; + private readonly CONTEXT_FILES_LIMIT = 3; /** * Constructor for FileInteractions @@ -65,6 +66,26 @@ export class FileInteractions { this.app = app; } + /** + * Get N open, recently viewed markdown files. + */ + private getRecentActiveMarkdownFiles(N: number): TFile[] { + const seen = new Set(); + const recentActiveFiles = this.app.workspace.getLeavesOfType('markdown') + .sort((a, b) => (b as any).activeTime - (a as any).activeTime) // Sort by leaf activeTime (note: undocumented prop) + .map(leaf => (leaf.view as MarkdownView)?.file) + // Dedupe by file path + .filter((file): file is TFile => { + if (!file || seen.has(file.path)) return false; + seen.add(file.path); + return true; + }) + .slice(0, N); + + console.log(`Using ${recentActiveFiles.length} recently viewed md files for context: ${recentActiveFiles.map(file => file.path).join(', ')}`); + return recentActiveFiles; + } + /** * Gets the content of all open files * @@ -75,9 +96,9 @@ export class FileInteractions { // Only proceed if we have read or write access if (fileAccessMode === 'none') return ''; - // Get all open markdown leaves - const leaves = this.app.workspace.getLeavesOfType('markdown'); - if (leaves.length === 0) return ''; + // Get recently viewed markdown files + const recentFiles = this.getRecentActiveMarkdownFiles(this.CONTEXT_FILES_LIMIT); + if (recentFiles.length === 0) return ''; // Instructions in write access mode let editInstructions: string = ''; @@ -274,11 +295,7 @@ For context, the user is currently working on the following files: `; - for (const leaf of leaves) { - const view = leaf.view as any; - const file = view?.file; - if (!file || file.extension !== 'md') continue; - + for (const file of recentFiles) { // Read file content let fileContent: string; try { @@ -415,8 +432,16 @@ For context, the user is currently working on the following files: } // Try parse SEARCH/REPLACE format for complete edit blocks - // Regex: file_path\n<<<<<<< SEARCH\nsearch_content\n=======\nreplacement_content\n>>>>>>> REPLACE - const newFormatRegex = /^([^\n]+)\n<<<<<<< SEARCH\n([\s\S]*?)\n=======\n([\s\S]*?)\n>>>>>>> REPLACE\s*$/; + // Supports empty SEARCH (new file / replace whole file) and empty REPLACE (deletion) + // Regex structure: + // file_path (group 1) + // <<<<<<< SEARCH literal marker + // search_content (group 2, can be empty) + // ======= divider + // replacement_content (group 3, can be empty => deletion) + // >>>>>>> REPLACE end marker + // Note: The trailing newline before the end marker is optional to allow zero-length replacement + const newFormatRegex = /^([^\n]+)\n<<<<<<< SEARCH\n([\s\S]*?)\n=======\n([\s\S]*?)\n?>>>>>>> REPLACE\s*$/; const newFormatMatch = newFormatRegex.exec(cleanContent); let editData: EditBlock | null = null; @@ -430,32 +455,25 @@ For context, the user is currently working on the following files: // Validate required fields let error: { type: 'missing_field' | 'invalid_format' | 'preprocessing' | 'unknown', message: string, details?: string } | null = null; - if (!editData) { - error = { - type: 'invalid_format', - message: 'Invalid edit block format', - details: 'The edit block does not match the expected format' - }; - } - else if (!editData.file) { + if (editData && !editData.file) { error = { type: 'missing_field', message: 'Missing "file" field in edit block', details: 'The "file" field is required and should contain the target file name' }; } - else if (editData.find === undefined || editData.find === null) { + else if (editData && (editData.find === undefined || editData.find === null)) { error = { type: 'missing_field', message: 'Missing "find" field markers', - details: 'The "find" field is required and should contain the content to find in the file' + details: 'The "find" field is required. It should contain the content to find in the file or be empty for new files' }; } - else if (!editData.replace) { + else if (editData && editData.replace === undefined) { error = { type: 'missing_field', message: 'Missing "replace" field in edit block', - details: 'The "replace" field is required and should contain the replacement text' + details: 'The "replace" field is required. It should contain the content to replace or be empty to indicate deletion' }; } @@ -507,7 +525,7 @@ For context, the user is currently working on the following files: } if (!editData) { - console.error("No edit data parsed"); + console.debug("No edit data parsed"); continue; } @@ -684,10 +702,8 @@ For context, the user is currently working on the following files: // Track current content for each file as we apply edits const currentFileContents = new Map(); - // Get all open markdown files - const files = this.app.workspace.getLeavesOfType('markdown') - .map(leaf => (leaf.view as any)?.file) - .filter(file => file && file.extension === 'md'); + // Get recently viewed markdown file(s) to edit + const files = this.getRecentActiveMarkdownFiles(this.CONTEXT_FILES_LIMIT); // Track success/failure for each edit const editResults: { block: EditBlock, success: boolean, error?: string }[] = []; @@ -883,6 +899,10 @@ For context, the user is currently working on the following files: // Parse the block content const { editData, cleanContent, error, inProgress } = this.parseEditBlock(content, isComplete); + if (!editData && !error) { + // If no edit data and no error, skip this block + continue; + } // Escape content for HTML display const diff = diffWords(editData?.find || '', editData?.replace || ''); @@ -898,7 +918,7 @@ For context, the user is currently working on the following files: ).join('').trim(); let htmlRender = ''; - if (error || !editData) { + if (error) { // Error block console.error("Error parsing khoj-edit block:", error); console.error("Content causing error:", content); @@ -913,7 +933,7 @@ For context, the user is currently working on the following files:
${diffContent}
`; - } else if (inProgress) { + } else if (editData && inProgress) { // In-progress block htmlRender = `
📄 ${editData.file} In Progress @@ -921,7 +941,7 @@ For context, the user is currently working on the following files:
${diffContent}
`; - } else { + } else if (editData) { // Success block // Find the actual file that will be modified const targetFile = this.findBestMatchingFile(editData.file, files); diff --git a/src/interface/obsidian/src/main.ts b/src/interface/obsidian/src/main.ts index 57eab878..b0c8d7d0 100644 --- a/src/interface/obsidian/src/main.ts +++ b/src/interface/obsidian/src/main.ts @@ -3,8 +3,8 @@ import { KhojSetting, KhojSettingTab, DEFAULT_SETTINGS } from 'src/settings' import { KhojSearchModal } from 'src/search_modal' import { KhojChatView } from 'src/chat_view' import { KhojSimilarView } from 'src/similar_view' -import { updateContentIndex, canConnectToBackend, KhojView, jumpToPreviousView } from './utils'; -import { KhojPaneView } from './pane_view'; +import { updateContentIndex, canConnectToBackend, KhojView } from 'src/utils'; +import { KhojPaneView } from 'src/pane_view'; export default class Khoj extends Plugin { @@ -73,7 +73,7 @@ export default class Khoj extends Plugin { this.activateView(KhojView.CHAT).then(() => { const chatView = this.app.workspace.getActiveViewOfType(KhojChatView); if (chatView) { - chatView.toggleChatSessions(true); + chatView.toggleChatSessions(); } }); } @@ -88,8 +88,9 @@ export default class Khoj extends Plugin { this.activateView(KhojView.CHAT).then(() => { const chatView = this.app.workspace.getActiveViewOfType(KhojChatView); if (chatView) { - // Trigger speech to text functionality - chatView.speechToText(new KeyboardEvent('keydown')); + // Toggle speech to text functionality + const toggleEvent = chatView.voiceChatActive ? 'keyup' : 'keydown'; + chatView.speechToText(new KeyboardEvent(toggleEvent)); } }); } @@ -136,8 +137,8 @@ export default class Khoj extends Plugin { }); // Register views - this.registerView(KhojView.CHAT, (leaf) => new KhojChatView(leaf, this.settings)); - this.registerView(KhojView.SIMILAR, (leaf) => new KhojSimilarView(leaf, this.settings)); + this.registerView(KhojView.CHAT, (leaf) => new KhojChatView(leaf, this)); + this.registerView(KhojView.SIMILAR, (leaf) => new KhojSimilarView(leaf, this)); // Create an icon in the left ribbon. this.addRibbonIcon('message-circle', 'Khoj', (_: MouseEvent) => { diff --git a/src/interface/obsidian/src/pane_view.ts b/src/interface/obsidian/src/pane_view.ts index 095dfd62..d63a5edd 100644 --- a/src/interface/obsidian/src/pane_view.ts +++ b/src/interface/obsidian/src/pane_view.ts @@ -1,14 +1,17 @@ import { ItemView, WorkspaceLeaf } from 'obsidian'; import { KhojSetting } from 'src/settings'; import { KhojView, populateHeaderPane } from './utils'; +import Khoj from 'src/main'; export abstract class KhojPaneView extends ItemView { setting: KhojSetting; + plugin: Khoj; - constructor(leaf: WorkspaceLeaf, setting: KhojSetting) { + constructor(leaf: WorkspaceLeaf, plugin: Khoj) { super(leaf); - this.setting = setting; + this.setting = plugin.settings; + this.plugin = plugin; // Register Modal Keybindings to send user message // this.scope.register([], 'Enter', async () => { await this.chat() }); diff --git a/src/interface/obsidian/src/settings.ts b/src/interface/obsidian/src/settings.ts index e8cf4f51..4a07ca0c 100644 --- a/src/interface/obsidian/src/settings.ts +++ b/src/interface/obsidian/src/settings.ts @@ -38,6 +38,7 @@ export interface KhojSetting { syncFolders: string[]; syncInterval: number; autoVoiceResponse: boolean; + fileAccessMode: 'none' | 'read' | 'write'; selectedChatModelId: string | null; // Mirrors server's selected_chat_model_config availableChatModels: ModelOption[]; } @@ -58,6 +59,7 @@ export const DEFAULT_SETTINGS: KhojSetting = { syncFolders: [], syncInterval: 60, autoVoiceResponse: true, + fileAccessMode: 'read', selectedChatModelId: null, // Will be populated from server availableChatModels: [], } diff --git a/src/interface/obsidian/src/similar_view.ts b/src/interface/obsidian/src/similar_view.ts index 2f5306d2..bb1621d3 100644 --- a/src/interface/obsidian/src/similar_view.ts +++ b/src/interface/obsidian/src/similar_view.ts @@ -1,7 +1,7 @@ import { WorkspaceLeaf, TFile, MarkdownRenderer, Notice, setIcon } from 'obsidian'; -import { KhojSetting } from 'src/settings'; import { KhojPaneView } from 'src/pane_view'; import { KhojView, getLinkToEntry, supportedBinaryFileTypes } from 'src/utils'; +import Khoj from 'src/main'; export interface SimilarResult { entry: string; @@ -11,7 +11,6 @@ export interface SimilarResult { export class KhojSimilarView extends KhojPaneView { static iconName: string = "search"; - setting: KhojSetting; currentController: AbortController | null = null; isLoading: boolean = false; loadingEl: HTMLElement; @@ -21,9 +20,8 @@ export class KhojSimilarView extends KhojPaneView { fileWatcher: any; component: any; - constructor(leaf: WorkspaceLeaf, setting: KhojSetting) { - super(leaf, setting); - this.setting = setting; + constructor(leaf: WorkspaceLeaf, plugin: Khoj) { + super(leaf, plugin); this.component = this; }