mirror of
https://github.com/khoaliber/khoj.git
synced 2026-03-09 13:25:11 +00:00
Fix and improve file read, write handling in Obsidian
Fixes - Fix to allow khoj to delete content in obsidian write mode - Do not throw error when no edit blocks in write mode on obsidian - Limit retries to fix invalid edit blocks in obsidian write mode Improvements - Only show 3 recent files as context in obsidian file read, write mode - Persist open file access mode setting across restarts in obsidian - Make khoj obsidian keyboard shortcuts toggle voice chat, chat history - Do not show <SYSTEM> instructions in chat session title on obsidian Closes #1209
This commit is contained in:
@@ -1,9 +1,9 @@
|
|||||||
import { ItemView, MarkdownRenderer, Scope, WorkspaceLeaf, request, requestUrl, setIcon, Platform, TFile } from 'obsidian';
|
import { ItemView, MarkdownRenderer, Scope, WorkspaceLeaf, request, requestUrl, setIcon, Platform, TFile } from 'obsidian';
|
||||||
import * as DOMPurify from 'isomorphic-dompurify';
|
import * as DOMPurify from 'isomorphic-dompurify';
|
||||||
import { KhojSetting } from 'src/settings';
|
|
||||||
import { KhojPaneView } from 'src/pane_view';
|
import { KhojPaneView } from 'src/pane_view';
|
||||||
import { KhojView, createCopyParentText, getLinkToEntry, pasteTextAtCursor } from 'src/utils';
|
import { KhojView, createCopyParentText, getLinkToEntry, pasteTextAtCursor } from 'src/utils';
|
||||||
import { KhojSearchModal } from 'src/search_modal';
|
import { KhojSearchModal } from 'src/search_modal';
|
||||||
|
import Khoj from 'src/main';
|
||||||
import { FileInteractions, EditBlock } from 'src/interact_with_files';
|
import { FileInteractions, EditBlock } from 'src/interact_with_files';
|
||||||
|
|
||||||
export interface ChatJsonResult {
|
export interface ChatJsonResult {
|
||||||
@@ -67,12 +67,12 @@ interface Agent {
|
|||||||
|
|
||||||
export class KhojChatView extends KhojPaneView {
|
export class KhojChatView extends KhojPaneView {
|
||||||
result: string;
|
result: string;
|
||||||
setting: KhojSetting;
|
|
||||||
waitingForLocation: boolean;
|
waitingForLocation: boolean;
|
||||||
location: Location = { timezone: Intl.DateTimeFormat().resolvedOptions().timeZone };
|
location: Location = { timezone: Intl.DateTimeFormat().resolvedOptions().timeZone };
|
||||||
keyPressTimeout: NodeJS.Timeout | null = null;
|
keyPressTimeout: NodeJS.Timeout | null = null;
|
||||||
userMessages: string[] = []; // Store user sent messages for input history cycling
|
userMessages: string[] = []; // Store user sent messages for input history cycling
|
||||||
currentMessageIndex: number = -1; // Track current message index in userMessages array
|
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 currentUserInput: string = ""; // Stores the current user input that is being typed in chat
|
||||||
private startingMessage: string = this.getLearningMoment();
|
private startingMessage: string = this.getLearningMoment();
|
||||||
chatMessageState: ChatMessageState;
|
chatMessageState: ChatMessageState;
|
||||||
@@ -101,10 +101,13 @@ export class KhojChatView extends KhojPaneView {
|
|||||||
// 2. Higher invalid edit blocks than tolerable
|
// 2. Higher invalid edit blocks than tolerable
|
||||||
private maxEditRetries: number = 1; // Maximum retries for edit blocks
|
private maxEditRetries: number = 1; // Maximum retries for edit blocks
|
||||||
|
|
||||||
constructor(leaf: WorkspaceLeaf, setting: KhojSetting) {
|
constructor(leaf: WorkspaceLeaf, plugin: Khoj) {
|
||||||
super(leaf, setting);
|
super(leaf, plugin);
|
||||||
this.fileInteractions = new FileInteractions(this.app);
|
this.fileInteractions = new FileInteractions(this.app);
|
||||||
|
|
||||||
|
// Initialize file access mode from persisted settings
|
||||||
|
this.fileAccessMode = this.setting.fileAccessMode ?? 'read';
|
||||||
|
|
||||||
this.waitingForLocation = true;
|
this.waitingForLocation = true;
|
||||||
|
|
||||||
fetch("https://ipapi.co/json")
|
fetch("https://ipapi.co/json")
|
||||||
@@ -129,7 +132,7 @@ export class KhojChatView extends KhojPaneView {
|
|||||||
this.scope = new Scope(this.app.scope);
|
this.scope = new Scope(this.app.scope);
|
||||||
this.scope.register(["Ctrl", "Alt"], 'n', (_) => this.createNewConversation(this.currentAgent));
|
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"], '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"], 'f', (_) => new KhojSearchModal(this.app, this.setting).open());
|
||||||
this.scope.register(["Ctrl"], 'r', (_) => { this.activateView(KhojView.SIMILAR); });
|
this.scope.register(["Ctrl"], 'r', (_) => { this.activateView(KhojView.SIMILAR); });
|
||||||
}
|
}
|
||||||
@@ -272,29 +275,48 @@ export class KhojChatView extends KhojPaneView {
|
|||||||
text: "File Access",
|
text: "File Access",
|
||||||
attr: {
|
attr: {
|
||||||
class: "khoj-input-row-button clickable-icon",
|
class: "khoj-input-row-button clickable-icon",
|
||||||
title: "Toggle file access mode (Read Only)",
|
title: "Toggle open file access",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
// 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");
|
setIcon(fileAccessButton, "file-search");
|
||||||
fileAccessButton.addEventListener('click', () => {
|
fileAccessButton.title = "Toggle open file access (Read Only)";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
fileAccessButton.addEventListener('click', async () => {
|
||||||
// Cycle through modes: none -> read -> write -> none
|
// Cycle through modes: none -> read -> write -> none
|
||||||
switch (this.fileAccessMode) {
|
switch (this.fileAccessMode) {
|
||||||
case 'none':
|
case 'none':
|
||||||
this.fileAccessMode = 'read';
|
this.fileAccessMode = 'read';
|
||||||
setIcon(fileAccessButton, "file-search");
|
setIcon(fileAccessButton, "file-search");
|
||||||
fileAccessButton.title = "Toggle file access mode (Read Only)";
|
fileAccessButton.title = "Toggle open file access (Read Only)";
|
||||||
break;
|
break;
|
||||||
case 'read':
|
case 'read':
|
||||||
this.fileAccessMode = 'write';
|
this.fileAccessMode = 'write';
|
||||||
setIcon(fileAccessButton, "file-edit");
|
setIcon(fileAccessButton, "file-edit");
|
||||||
fileAccessButton.title = "Toggle file access mode (Read & Write)";
|
fileAccessButton.title = "Toggle open file access (Read & Write)";
|
||||||
break;
|
break;
|
||||||
case 'write':
|
case 'write':
|
||||||
this.fileAccessMode = 'none';
|
this.fileAccessMode = 'none';
|
||||||
setIcon(fileAccessButton, "file-x");
|
setIcon(fileAccessButton, "file-x");
|
||||||
fileAccessButton.title = "Toggle file access mode (No Access)";
|
fileAccessButton.title = "Toggle open file access (No Access)";
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Persist the updated mode to settings
|
||||||
|
this.setting.fileAccessMode = this.fileAccessMode;
|
||||||
|
await this.plugin.saveSettings();
|
||||||
});
|
});
|
||||||
|
|
||||||
let chatInput = inputRow.createEl("textarea", {
|
let chatInput = inputRow.createEl("textarea", {
|
||||||
@@ -319,7 +341,7 @@ export class KhojChatView extends KhojPaneView {
|
|||||||
attr: {
|
attr: {
|
||||||
id: "khoj-transcribe",
|
id: "khoj-transcribe",
|
||||||
class: "khoj-transcribe khoj-input-row-button clickable-icon ",
|
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) });
|
transcribe.addEventListener('mousedown', (event) => { this.startSpeechToText(event) });
|
||||||
@@ -1037,7 +1059,7 @@ export class KhojChatView extends KhojPaneView {
|
|||||||
if (incomingConversationId == conversationId) {
|
if (incomingConversationId == conversationId) {
|
||||||
conversationSessionEl.classList.add("selected-conversation");
|
conversationSessionEl.classList.add("selected-conversation");
|
||||||
}
|
}
|
||||||
const conversationTitle = conversation["slug"] || `New conversation 🌱`;
|
const conversationTitle = conversation["slug"].split("<SYSTEM>")[0].trim() || `New conversation 🌱`;
|
||||||
const conversationSessionTitleEl = conversationSessionEl.createDiv("conversation-session-title");
|
const conversationSessionTitleEl = conversationSessionEl.createDiv("conversation-session-title");
|
||||||
conversationSessionTitleEl.textContent = conversationTitle;
|
conversationSessionTitleEl.textContent = conversationTitle;
|
||||||
conversationSessionTitleEl.addEventListener('click', () => {
|
conversationSessionTitleEl.addEventListener('click', () => {
|
||||||
@@ -1190,7 +1212,7 @@ export class KhojChatView extends KhojPaneView {
|
|||||||
chatUrl += `&conversation_id=${chatBodyEl.dataset.conversationId}`;
|
chatUrl += `&conversation_id=${chatBodyEl.dataset.conversationId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("Fetching chat history from:", chatUrl);
|
console.debug("Fetching chat history from:", chatUrl);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let response = await fetch(chatUrl, {
|
let response = await fetch(chatUrl, {
|
||||||
@@ -1199,7 +1221,7 @@ export class KhojChatView extends KhojPaneView {
|
|||||||
});
|
});
|
||||||
|
|
||||||
let responseJson: any = await response.json();
|
let responseJson: any = await response.json();
|
||||||
console.log("Chat history response:", responseJson);
|
console.debug("Chat history response:", responseJson);
|
||||||
|
|
||||||
chatBodyEl.dataset.conversationId = responseJson.conversation_id;
|
chatBodyEl.dataset.conversationId = responseJson.conversation_id;
|
||||||
|
|
||||||
@@ -1221,7 +1243,7 @@ export class KhojChatView extends KhojPaneView {
|
|||||||
|
|
||||||
// Update current agent from conversation history
|
// Update current agent from conversation history
|
||||||
if (responseJson.response.agent?.slug) {
|
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;
|
this.currentAgent = responseJson.response.agent.slug;
|
||||||
// Update the agent selector if it exists
|
// Update the agent selector if it exists
|
||||||
const agentSelect = this.contentEl.querySelector('.khoj-header-agent-select') as HTMLSelectElement;
|
const agentSelect = this.contentEl.querySelector('.khoj-header-agent-select') as HTMLSelectElement;
|
||||||
@@ -1352,19 +1374,26 @@ export class KhojChatView extends KhojPaneView {
|
|||||||
if (this.fileAccessMode === 'write') {
|
if (this.fileAccessMode === 'write') {
|
||||||
const editBlocks = this.parseEditBlocks(this.chatMessageState.rawResponse);
|
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) {
|
if (editBlocks.length > 0) {
|
||||||
|
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);
|
await this.applyEditBlocks(editBlocks);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// No edit blocks => reset counter just in case
|
||||||
|
this.editRetryCount = 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Automatically respond with voice if the subscribed user has sent voice message
|
// Automatically respond with voice if the subscribed user has sent voice message
|
||||||
@@ -1720,6 +1749,7 @@ export class KhojChatView extends KhojPaneView {
|
|||||||
|
|
||||||
// Toggle recording
|
// Toggle recording
|
||||||
if (!this.mediaRecorder || this.mediaRecorder.state === 'inactive' || event.type === 'touchstart' || event.type === 'mousedown' || event.type === 'keydown') {
|
if (!this.mediaRecorder || this.mediaRecorder.state === 'inactive' || event.type === 'touchstart' || event.type === 'mousedown' || event.type === 'keydown') {
|
||||||
|
this.voiceChatActive = true;
|
||||||
navigator.mediaDevices
|
navigator.mediaDevices
|
||||||
.getUserMedia({ audio: true })
|
.getUserMedia({ audio: true })
|
||||||
?.then(handleRecording)
|
?.then(handleRecording)
|
||||||
@@ -1727,6 +1757,7 @@ export class KhojChatView extends KhojPaneView {
|
|||||||
this.flashStatusInChatInput("⛔️ Failed to access microphone");
|
this.flashStatusInChatInput("⛔️ Failed to access microphone");
|
||||||
});
|
});
|
||||||
} else if (this.mediaRecorder?.state === 'recording' || event.type === 'touchend' || event.type === 'touchcancel' || event.type === 'mouseup' || event.type === 'keyup') {
|
} 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.stop();
|
||||||
this.mediaRecorder.stream.getTracks().forEach(track => track.stop());
|
this.mediaRecorder.stream.getTracks().forEach(track => track.stop());
|
||||||
this.mediaRecorder = undefined;
|
this.mediaRecorder = undefined;
|
||||||
@@ -2403,7 +2434,7 @@ export class KhojChatView extends KhojPaneView {
|
|||||||
// Add retry count
|
// Add retry count
|
||||||
retryBadge.createSpan({
|
retryBadge.createSpan({
|
||||||
cls: "retry-count",
|
cls: "retry-count",
|
||||||
text: `Attempt ${this.editRetryCount}/3`
|
text: `Attempt ${this.editRetryCount}/${this.maxEditRetries}`
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add error details as a tooltip
|
// Add error details as a tooltip
|
||||||
@@ -2418,7 +2449,7 @@ export class KhojChatView extends KhojPaneView {
|
|||||||
retryBadge.scrollIntoView({ behavior: "smooth", block: "center" });
|
retryBadge.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||||
|
|
||||||
// Create a retry prompt for the LLM
|
// 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
|
// Send retry request without displaying the user message
|
||||||
await this.getChatResponse(retryPrompt, "", false, false);
|
await this.getChatResponse(retryPrompt, "", false, false);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { App, TFile } from 'obsidian';
|
import { App, MarkdownView, TFile } from 'obsidian';
|
||||||
import { diffWords } from 'diff';
|
import { diffWords } from 'diff';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -55,6 +55,7 @@ export class FileInteractions {
|
|||||||
private app: App;
|
private app: App;
|
||||||
private readonly EDIT_BLOCK_START = '<khoj_edit>';
|
private readonly EDIT_BLOCK_START = '<khoj_edit>';
|
||||||
private readonly EDIT_BLOCK_END = '</khoj_edit>';
|
private readonly EDIT_BLOCK_END = '</khoj_edit>';
|
||||||
|
private readonly CONTEXT_FILES_LIMIT = 3;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Constructor for FileInteractions
|
* Constructor for FileInteractions
|
||||||
@@ -65,6 +66,26 @@ export class FileInteractions {
|
|||||||
this.app = app;
|
this.app = app;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get N open, recently viewed markdown files.
|
||||||
|
*/
|
||||||
|
private getRecentActiveMarkdownFiles(N: number): TFile[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
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
|
* Gets the content of all open files
|
||||||
*
|
*
|
||||||
@@ -75,9 +96,9 @@ export class FileInteractions {
|
|||||||
// Only proceed if we have read or write access
|
// Only proceed if we have read or write access
|
||||||
if (fileAccessMode === 'none') return '';
|
if (fileAccessMode === 'none') return '';
|
||||||
|
|
||||||
// Get all open markdown leaves
|
// Get recently viewed markdown files
|
||||||
const leaves = this.app.workspace.getLeavesOfType('markdown');
|
const recentFiles = this.getRecentActiveMarkdownFiles(this.CONTEXT_FILES_LIMIT);
|
||||||
if (leaves.length === 0) return '';
|
if (recentFiles.length === 0) return '';
|
||||||
|
|
||||||
// Instructions in write access mode
|
// Instructions in write access mode
|
||||||
let editInstructions: string = '';
|
let editInstructions: string = '';
|
||||||
@@ -274,11 +295,7 @@ For context, the user is currently working on the following files:
|
|||||||
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
for (const leaf of leaves) {
|
for (const file of recentFiles) {
|
||||||
const view = leaf.view as any;
|
|
||||||
const file = view?.file;
|
|
||||||
if (!file || file.extension !== 'md') continue;
|
|
||||||
|
|
||||||
// Read file content
|
// Read file content
|
||||||
let fileContent: string;
|
let fileContent: string;
|
||||||
try {
|
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
|
// Try parse SEARCH/REPLACE format for complete edit blocks
|
||||||
// Regex: file_path\n<<<<<<< SEARCH\nsearch_content\n=======\nreplacement_content\n>>>>>>> REPLACE
|
// Supports empty SEARCH (new file / replace whole file) and empty REPLACE (deletion)
|
||||||
const newFormatRegex = /^([^\n]+)\n<<<<<<< SEARCH\n([\s\S]*?)\n=======\n([\s\S]*?)\n>>>>>>> REPLACE\s*$/;
|
// 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);
|
const newFormatMatch = newFormatRegex.exec(cleanContent);
|
||||||
|
|
||||||
let editData: EditBlock | null = null;
|
let editData: EditBlock | null = null;
|
||||||
@@ -430,32 +455,25 @@ For context, the user is currently working on the following files:
|
|||||||
|
|
||||||
// Validate required fields
|
// Validate required fields
|
||||||
let error: { type: 'missing_field' | 'invalid_format' | 'preprocessing' | 'unknown', message: string, details?: string } | null = null;
|
let error: { type: 'missing_field' | 'invalid_format' | 'preprocessing' | 'unknown', message: string, details?: string } | null = null;
|
||||||
if (!editData) {
|
if (editData && !editData.file) {
|
||||||
error = {
|
|
||||||
type: 'invalid_format',
|
|
||||||
message: 'Invalid edit block format',
|
|
||||||
details: 'The edit block does not match the expected format'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
else if (!editData.file) {
|
|
||||||
error = {
|
error = {
|
||||||
type: 'missing_field',
|
type: 'missing_field',
|
||||||
message: 'Missing "file" field in edit block',
|
message: 'Missing "file" field in edit block',
|
||||||
details: 'The "file" field is required and should contain the target file name'
|
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 = {
|
error = {
|
||||||
type: 'missing_field',
|
type: 'missing_field',
|
||||||
message: 'Missing "find" field markers',
|
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 = {
|
error = {
|
||||||
type: 'missing_field',
|
type: 'missing_field',
|
||||||
message: 'Missing "replace" field in edit block',
|
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) {
|
if (!editData) {
|
||||||
console.error("No edit data parsed");
|
console.debug("No edit data parsed");
|
||||||
continue;
|
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
|
// Track current content for each file as we apply edits
|
||||||
const currentFileContents = new Map<string, string>();
|
const currentFileContents = new Map<string, string>();
|
||||||
|
|
||||||
// Get all open markdown files
|
// Get recently viewed markdown file(s) to edit
|
||||||
const files = this.app.workspace.getLeavesOfType('markdown')
|
const files = this.getRecentActiveMarkdownFiles(this.CONTEXT_FILES_LIMIT);
|
||||||
.map(leaf => (leaf.view as any)?.file)
|
|
||||||
.filter(file => file && file.extension === 'md');
|
|
||||||
|
|
||||||
// Track success/failure for each edit
|
// Track success/failure for each edit
|
||||||
const editResults: { block: EditBlock, success: boolean, error?: string }[] = [];
|
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
|
// Parse the block content
|
||||||
const { editData, cleanContent, error, inProgress } = this.parseEditBlock(content, isComplete);
|
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
|
// Escape content for HTML display
|
||||||
const diff = diffWords(editData?.find || '', editData?.replace || '');
|
const diff = diffWords(editData?.find || '', editData?.replace || '');
|
||||||
@@ -898,7 +918,7 @@ For context, the user is currently working on the following files:
|
|||||||
).join('').trim();
|
).join('').trim();
|
||||||
|
|
||||||
let htmlRender = '';
|
let htmlRender = '';
|
||||||
if (error || !editData) {
|
if (error) {
|
||||||
// Error block
|
// Error block
|
||||||
console.error("Error parsing khoj-edit block:", error);
|
console.error("Error parsing khoj-edit block:", error);
|
||||||
console.error("Content causing error:", content);
|
console.error("Content causing error:", content);
|
||||||
@@ -913,7 +933,7 @@ For context, the user is currently working on the following files:
|
|||||||
<pre><code class="language-md error">${diffContent}</code></pre>
|
<pre><code class="language-md error">${diffContent}</code></pre>
|
||||||
</div>
|
</div>
|
||||||
</details>`;
|
</details>`;
|
||||||
} else if (inProgress) {
|
} else if (editData && inProgress) {
|
||||||
// In-progress block
|
// In-progress block
|
||||||
htmlRender = `<details class="khoj-edit-accordion in-progress">
|
htmlRender = `<details class="khoj-edit-accordion in-progress">
|
||||||
<summary>📄 ${editData.file} <span class="khoj-edit-status">In Progress</span></summary>
|
<summary>📄 ${editData.file} <span class="khoj-edit-status">In Progress</span></summary>
|
||||||
@@ -921,7 +941,7 @@ For context, the user is currently working on the following files:
|
|||||||
<pre><code class="language-md">${diffContent}</code></pre>
|
<pre><code class="language-md">${diffContent}</code></pre>
|
||||||
</div>
|
</div>
|
||||||
</details>`;
|
</details>`;
|
||||||
} else {
|
} else if (editData) {
|
||||||
// Success block
|
// Success block
|
||||||
// Find the actual file that will be modified
|
// Find the actual file that will be modified
|
||||||
const targetFile = this.findBestMatchingFile(editData.file, files);
|
const targetFile = this.findBestMatchingFile(editData.file, files);
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import { KhojSetting, KhojSettingTab, DEFAULT_SETTINGS } from 'src/settings'
|
|||||||
import { KhojSearchModal } from 'src/search_modal'
|
import { KhojSearchModal } from 'src/search_modal'
|
||||||
import { KhojChatView } from 'src/chat_view'
|
import { KhojChatView } from 'src/chat_view'
|
||||||
import { KhojSimilarView } from 'src/similar_view'
|
import { KhojSimilarView } from 'src/similar_view'
|
||||||
import { updateContentIndex, canConnectToBackend, KhojView, jumpToPreviousView } from './utils';
|
import { updateContentIndex, canConnectToBackend, KhojView } from 'src/utils';
|
||||||
import { KhojPaneView } from './pane_view';
|
import { KhojPaneView } from 'src/pane_view';
|
||||||
|
|
||||||
|
|
||||||
export default class Khoj extends Plugin {
|
export default class Khoj extends Plugin {
|
||||||
@@ -73,7 +73,7 @@ export default class Khoj extends Plugin {
|
|||||||
this.activateView(KhojView.CHAT).then(() => {
|
this.activateView(KhojView.CHAT).then(() => {
|
||||||
const chatView = this.app.workspace.getActiveViewOfType(KhojChatView);
|
const chatView = this.app.workspace.getActiveViewOfType(KhojChatView);
|
||||||
if (chatView) {
|
if (chatView) {
|
||||||
chatView.toggleChatSessions(true);
|
chatView.toggleChatSessions();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -88,8 +88,9 @@ export default class Khoj extends Plugin {
|
|||||||
this.activateView(KhojView.CHAT).then(() => {
|
this.activateView(KhojView.CHAT).then(() => {
|
||||||
const chatView = this.app.workspace.getActiveViewOfType(KhojChatView);
|
const chatView = this.app.workspace.getActiveViewOfType(KhojChatView);
|
||||||
if (chatView) {
|
if (chatView) {
|
||||||
// Trigger speech to text functionality
|
// Toggle speech to text functionality
|
||||||
chatView.speechToText(new KeyboardEvent('keydown'));
|
const toggleEvent = chatView.voiceChatActive ? 'keyup' : 'keydown';
|
||||||
|
chatView.speechToText(new KeyboardEvent(toggleEvent));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -136,8 +137,8 @@ export default class Khoj extends Plugin {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Register views
|
// Register views
|
||||||
this.registerView(KhojView.CHAT, (leaf) => new KhojChatView(leaf, this.settings));
|
this.registerView(KhojView.CHAT, (leaf) => new KhojChatView(leaf, this));
|
||||||
this.registerView(KhojView.SIMILAR, (leaf) => new KhojSimilarView(leaf, this.settings));
|
this.registerView(KhojView.SIMILAR, (leaf) => new KhojSimilarView(leaf, this));
|
||||||
|
|
||||||
// Create an icon in the left ribbon.
|
// Create an icon in the left ribbon.
|
||||||
this.addRibbonIcon('message-circle', 'Khoj', (_: MouseEvent) => {
|
this.addRibbonIcon('message-circle', 'Khoj', (_: MouseEvent) => {
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
import { ItemView, WorkspaceLeaf } from 'obsidian';
|
import { ItemView, WorkspaceLeaf } from 'obsidian';
|
||||||
import { KhojSetting } from 'src/settings';
|
import { KhojSetting } from 'src/settings';
|
||||||
import { KhojView, populateHeaderPane } from './utils';
|
import { KhojView, populateHeaderPane } from './utils';
|
||||||
|
import Khoj from 'src/main';
|
||||||
|
|
||||||
export abstract class KhojPaneView extends ItemView {
|
export abstract class KhojPaneView extends ItemView {
|
||||||
setting: KhojSetting;
|
setting: KhojSetting;
|
||||||
|
plugin: Khoj;
|
||||||
|
|
||||||
constructor(leaf: WorkspaceLeaf, setting: KhojSetting) {
|
constructor(leaf: WorkspaceLeaf, plugin: Khoj) {
|
||||||
super(leaf);
|
super(leaf);
|
||||||
|
|
||||||
this.setting = setting;
|
this.setting = plugin.settings;
|
||||||
|
this.plugin = plugin;
|
||||||
|
|
||||||
// Register Modal Keybindings to send user message
|
// Register Modal Keybindings to send user message
|
||||||
// this.scope.register([], 'Enter', async () => { await this.chat() });
|
// this.scope.register([], 'Enter', async () => { await this.chat() });
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export interface KhojSetting {
|
|||||||
syncFolders: string[];
|
syncFolders: string[];
|
||||||
syncInterval: number;
|
syncInterval: number;
|
||||||
autoVoiceResponse: boolean;
|
autoVoiceResponse: boolean;
|
||||||
|
fileAccessMode: 'none' | 'read' | 'write';
|
||||||
selectedChatModelId: string | null; // Mirrors server's selected_chat_model_config
|
selectedChatModelId: string | null; // Mirrors server's selected_chat_model_config
|
||||||
availableChatModels: ModelOption[];
|
availableChatModels: ModelOption[];
|
||||||
}
|
}
|
||||||
@@ -58,6 +59,7 @@ export const DEFAULT_SETTINGS: KhojSetting = {
|
|||||||
syncFolders: [],
|
syncFolders: [],
|
||||||
syncInterval: 60,
|
syncInterval: 60,
|
||||||
autoVoiceResponse: true,
|
autoVoiceResponse: true,
|
||||||
|
fileAccessMode: 'read',
|
||||||
selectedChatModelId: null, // Will be populated from server
|
selectedChatModelId: null, // Will be populated from server
|
||||||
availableChatModels: [],
|
availableChatModels: [],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { WorkspaceLeaf, TFile, MarkdownRenderer, Notice, setIcon } from 'obsidian';
|
import { WorkspaceLeaf, TFile, MarkdownRenderer, Notice, setIcon } from 'obsidian';
|
||||||
import { KhojSetting } from 'src/settings';
|
|
||||||
import { KhojPaneView } from 'src/pane_view';
|
import { KhojPaneView } from 'src/pane_view';
|
||||||
import { KhojView, getLinkToEntry, supportedBinaryFileTypes } from 'src/utils';
|
import { KhojView, getLinkToEntry, supportedBinaryFileTypes } from 'src/utils';
|
||||||
|
import Khoj from 'src/main';
|
||||||
|
|
||||||
export interface SimilarResult {
|
export interface SimilarResult {
|
||||||
entry: string;
|
entry: string;
|
||||||
@@ -11,7 +11,6 @@ export interface SimilarResult {
|
|||||||
|
|
||||||
export class KhojSimilarView extends KhojPaneView {
|
export class KhojSimilarView extends KhojPaneView {
|
||||||
static iconName: string = "search";
|
static iconName: string = "search";
|
||||||
setting: KhojSetting;
|
|
||||||
currentController: AbortController | null = null;
|
currentController: AbortController | null = null;
|
||||||
isLoading: boolean = false;
|
isLoading: boolean = false;
|
||||||
loadingEl: HTMLElement;
|
loadingEl: HTMLElement;
|
||||||
@@ -21,9 +20,8 @@ export class KhojSimilarView extends KhojPaneView {
|
|||||||
fileWatcher: any;
|
fileWatcher: any;
|
||||||
component: any;
|
component: any;
|
||||||
|
|
||||||
constructor(leaf: WorkspaceLeaf, setting: KhojSetting) {
|
constructor(leaf: WorkspaceLeaf, plugin: Khoj) {
|
||||||
super(leaf, setting);
|
super(leaf, plugin);
|
||||||
this.setting = setting;
|
|
||||||
this.component = this;
|
this.component = this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user