Major updates to Obsidian Khoj plugin chat interface and editing features (#1109)

## Description
This PR introduces significant improvements to the Obsidian Khoj
plugin's chat interface and editing capabilities, enhancing the overall
user experience and content management functionality.

## Features

### 🔍 Enhanced Communication Mode
I've implemented radio buttons below the chat window for easier
communication mode selection. The modes are now displayed as emojis in
the conversation for a cleaner interface, replacing the previous
text-based system (e.g., /default, /research). I've also documented the
search mode functionality in the help command.

#### Screenshots
- Radio buttons for mode selection
- Emoji display in conversations
![Recording 2025-02-11 at 18 56
10](https://github.com/user-attachments/assets/798d15df-ad32-45bd-b03f-581f6093575a)

### 💬 Revamped Message Interaction
I've redesigned the message buttons with improved spacing and color
coding for better visual differentiation. The new edit button allows
quick message modifications - clicking it removes the conversation up to
that point and copies the message to the input field for easy editing or
retrying questions.

#### Screenshots
- New message styling and color scheme
![Recording 2025-02-11 at 18 44
48](https://github.com/user-attachments/assets/159ece3d-2d80-4583-a7a8-2ef1f253adcc)
- Edit button functionality
![Recording 2025-02-11 at 18 47
52](https://github.com/user-attachments/assets/82ee7221-bc49-4088-9a98-744ef74d1e58)

### 🤖 Advanced Agent Selection System
I've added a new chat creation button with agent selection capability.
Users can now choose from their available agents when starting a new
chat. While agents can't be switched mid-conversation to maintain
context, users can easily start fresh conversations with different
agents.

#### Screenshots
- Agent selection dropdown
![Recording 2025-02-11 at 18 51
27](https://github.com/user-attachments/assets/be4208df-224c-45bf-a5b4-cf0a8068b102)

### 👁️ Real-Time Context Awareness
I've added a button that gives Khoj access to read Obsidian opened tabs.
This allows Khoj to read open notes and track changes in real-time,
maintaining a history of previous versions to provide more contextual
assistance.

#### Screenshots
- Window access toggle
![Recording 2025-02-11 at 18 59
01](https://github.com/user-attachments/assets/b596bfca-f622-41b7-b826-25a8e254d4a2)

### ✏️ Smart Document Editing
Inspired by Cursor IDE's intelligent editing and ChatGPT's Canvas
functionality, I've implemented a first version of a content creation
system we've been discussing. Using a JSON-based modification system,
Khoj can now make precise changes to specific parts of files, with
changes previewed in yellow highlighting before application.
Modification code blocks are neatly organized in collapsible sections
with clear action summaries. While this is just a first step, it's
working remarkably well and I have several ideas for expanding this
functionality to make Khoj an even more powerful content creation
assistant.

#### Screenshots
- JSON modification preview
- Change highlighting system
- Collapsible code blocks
- Accept/cancel controls
![Recording 2025-02-11 at 19 02
32](https://github.com/user-attachments/assets/88826c9e-d0c9-40da-ab78-9976c786aa9e)

---------

Co-authored-by: Debanjum <debanjum@gmail.com>
This commit is contained in:
Henri Jamet
2025-06-01 07:12:36 +02:00
committed by GitHub
parent dee767042e
commit dbfac89a0c
12 changed files with 4697 additions and 446 deletions

View File

@@ -5,6 +5,12 @@
*.iml
.idea
# Cursor
.cursor
# Copilot
.github
# npm
node_modules

View File

@@ -18,7 +18,7 @@
"second brain"
],
"devDependencies": {
"@types/dompurify": "^3.0.5",
"@types/dompurify": "3.2.0",
"@types/node": "^16.11.6",
"@typescript-eslint/eslint-plugin": "7.13.1",
"@typescript-eslint/parser": "7.13.1",
@@ -29,6 +29,7 @@
"typescript": "4.7.4"
},
"dependencies": {
"dompurify": "^3.1.4"
"diff": "^8.0.2",
"isomorphic-dompurify": "^2.25.0"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,976 @@
import { App, TFile } from 'obsidian';
import { diffWords } from 'diff';
/**
* Interface representing a block of edit instructions for modifying files
*/
export interface EditBlock {
file: string; // Target file name [Required]
find: string; // Content to find in file [Required]
replace: string; // Content to replace with in file [Required]
note?: string; // Brief explanation of edit [Optional]
hasError?: boolean; // Flag to indicate parsing error [Optional]
error?: {
type: 'missing_field' | 'invalid_format' | 'preprocessing' | 'unknown';
message: string;
details?: string;
};
}
/**
* Interface representing the result of parsing a Khoj edit block
*/
export interface ParsedEditBlock {
editData: EditBlock | null;
cleanContent: string;
inProgress?: boolean;
error?: {
type: 'missing_field' | 'invalid_format' | 'preprocessing' | 'unknown';
message: string;
details?: string;
};
}
/**
* Interface representing the result of processing an edit block
*/
interface ProcessedEditResult {
preview: string; // The content with diff markers to be inserted
newContent: string; // The new content after replacement
error?: string; // Error message if processing failed (e.g., 'find' text not found)
}
/**
* Interface representing the result of detecting a partial edit block
*/
interface PartialEditBlockResult {
content: string;
isComplete: boolean;
}
/**
* Class that handles file operations for the Khoj plugin
*/
export class FileInteractions {
private app: App;
private readonly EDIT_BLOCK_START = '<khoj_edit>';
private readonly EDIT_BLOCK_END = '</khoj_edit>';
/**
* Constructor for FileInteractions
*
* @param app - The Obsidian App instance
*/
constructor(app: App) {
this.app = app;
}
/**
* Gets the content of all open files
*
* @param fileAccessMode - The access mode ('none', 'read', or 'write')
* @returns A string containing the content of all open files
*/
public async getOpenFilesContent(fileAccessMode: 'none' | 'read' | 'write'): Promise<string> {
// 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 '';
// Instructions in write access mode
let editInstructions: string = '';
if (fileAccessMode === 'write') {
editInstructions = `
If the user requests, you can suggest edits to files provided in the WORKING_FILE_SET provided below.
Once you understand the user request you MUST:
1. Decide if you need to propose *SEARCH/REPLACE* edits to any files that haven't been added to the chat.
If you need to propose edits to existing files not already added to the chat, you *MUST* tell the user their full path names and ask them to *add the files to the chat*.
End your reply and wait for their approval.
You can keep asking if you then decide you need to edit more files.
2. Think step-by-step and explain the needed changes in a few short sentences before each EDIT block.
3. Describe each change with a *SEARCH/REPLACE block* like the examples below.
All changes to files must use this *SEARCH/REPLACE block* format.
ONLY EVER RETURN EDIT TEXT IN A *SEARCH/REPLACE BLOCK*!
# *SEARCH/REPLACE block* Rules:
Every *SEARCH/REPLACE block* must use this format:
1. The opening fence: \`${this.EDIT_BLOCK_START}\`
2. The *FULL* file path alone on a line, verbatim. No bold asterisks, no quotes around it, no escaping of characters, etc.
3. The start of search block: <<<<<<< SEARCH
4. A contiguous chunk of lines to search for in the source file
5. The dividing line: =======
6. The lines to replace into the source file
7. The end of the replace block: >>>>>>> REPLACE
8. The closing fence: \`${this.EDIT_BLOCK_END}\`
Use the *FULL* file path, as shown to you by the user.
Every *SEARCH* section must *EXACTLY MATCH* the existing file content, character for character, including all comments, docstrings, etc.
If the file contains code or other data wrapped/escaped in json/xml/quotes or other containers, you need to propose edits to the literal contents of the file, including the container markup.
*SEARCH/REPLACE* blocks will *only* replace the first match occurrence.
Including multiple unique *SEARCH/REPLACE* blocks if needed.
Include enough lines in each SEARCH section to uniquely match each set of lines that need to change.
Keep *SEARCH/REPLACE* blocks concise.
Break large *SEARCH/REPLACE* blocks into a series of smaller blocks that each change a small portion of the file.
Include just the changing lines, and a few surrounding lines if needed for uniqueness.
Do not include long runs of unchanging lines in *SEARCH/REPLACE* blocks.
Only create *SEARCH/REPLACE* blocks for files that the user has added to the chat!
To move text within a file, use 2 *SEARCH/REPLACE* blocks: 1 to delete it from its current location, 1 to insert it in the new location.
Pay attention to which filenames the user wants you to edit, especially if they are asking you to create a new file.
If you want to put text in a new file, use a *SEARCH/REPLACE block* with:
- A new file path, including dir name if needed
- An empty \`SEARCH\` section
- The new file's contents in the \`REPLACE\` section
ONLY EVER RETURN EDIT TEXT IN A *SEARCH/REPLACE BLOCK*!
<EDIT_INSTRUCTIONS>
Suggest edits using targeted modifications. Use multiple edit blocks to make precise changes rather than rewriting entire sections.
Here's how to use the *SEARCH/REPLACE block* format:
${this.EDIT_BLOCK_START}
target-filename
<<<<<<< SEARCH
from flask import Flask
=======
import math
from flask import Flask
>>>>>>> REPLACE
${this.EDIT_BLOCK_END}
⚠️ Important:
- The target-filename parameter is required and must match an open file name.
- The XML format ${this.EDIT_BLOCK_START}...${this.EDIT_BLOCK_END} ensures reliable parsing.
- The SEARCH block content must completely and uniquely identify the section to edit.
- The REPLACE block content will replace the first SEARCH block match in the specified \`target-filename\`.
📝 Example note:
\`\`\`
---
date: 2024-01-20
tags: meeting, planning
status: active
---
# file: Meeting Notes.md
Action items from today:
- Review Q4 metrics
- Schedule follow-up with marketing team about new campaign launch
- Update project timeline and milestones for Q1 2024
Next steps:
- Send summary to team
- Book conference room for next week
\`\`\`
Examples of targeted edits:
1. Using just a few words to identify long text (notice how "campaign launch" is kept in content):
Add deadline and specificity to the marketing team follow-up.
${this.EDIT_BLOCK_START}
Meeting Notes.md
<<<<<<< SEARCH
- Schedule follow-up with marketing team about new campaign launch
=======
- Schedule follow-up with marketing team by Wednesday to discuss Q1 campaign launch
>>>>>>> REPLACE
${this.EDIT_BLOCK_END}
2. Multiple targeted changes with escaped characters:
Add HIGH priority flag with code reference to Q4 metrics review"
${this.EDIT_BLOCK_START}
Meeting Notes.md
<<<<<<< SEARCH
- Review Q4 metrics
=======
- [HIGH] Review Q4 metrics (see "metrics.ts" and \`calculateQ4Metrics()\`)
>>>>>>> REPLACE
</${this.EDIT_BLOCK_END}>
Add resource allocation to project timeline task
<${this.EDIT_BLOCK_START}>
Meeting Notes.md
<<<<<<< SEARCH
- Update project timeline and milestones for Q1 2024
=======
- Update project timeline and add resource allocation for Q1 2024
>>>>>>> REPLACE
</${this.EDIT_BLOCK_END}>
3. Adding new content between sections:
Insert a new section for discussion points after the action items section:
${this.EDIT_BLOCK_START}
Meeting Notes.md
<<<<<<< SEARCH
Action items from today:
- Review Q4 metrics
- Schedule follow-up with marketing team about new campaign launch
- Update project timeline and milestones for Q1 2024
=======
Action items from today:
- Review Q4 metrics
- Schedule follow-up
- Update timeline
Discussion Points:
- Budget review
- Team feedback
>>>>>>> REPLACE
</${this.EDIT_BLOCK_END}>
4. Completely replacing a file content (preserving frontmatter):
Replace entire file content while keeping frontmatter metadata
${this.EDIT_BLOCK_START}
Meeting Notes.md
<<<<<<< SEARCH
=======
# Project Overview
## Goals
- Increase user engagement by 25%
- Launch mobile app by Q3
- Expand to 3 new markets
## Timeline
1. Q1: Research & Planning
2. Q2: Development
3. Q3: Testing & Launch
4. Q4: Market Expansion
>>>>>>> REPLACE
${this.EDIT_BLOCK_END}
- The SEARCH block must uniquely identify the section to edit
- The REPLACE block content replaces the first SEARCH block match in the specified file
- Frontmatter metadata (between --- markers at top of file) cannot be modified
- Use an empty SEARCH block to replace entire file content with content in REPLACE block (while preserving frontmatter).
- Remember to escape special characters: use \" for quotes in content
- Each edit block must be fenced in ${this.EDIT_BLOCK_START}...${this.EDIT_BLOCK_END} XML tags
</EDIT_INSTRUCTIONS>
`;
}
let openFilesContent = `
For context, the user is currently working on the following files:
<WORKING_FILE_SET>
`;
for (const leaf of leaves) {
const view = leaf.view as any;
const file = view?.file;
if (!file || file.extension !== 'md') continue;
// Read file content
let fileContent: string;
try {
fileContent = await this.app.vault.read(file);
} catch (error) {
console.error(`Error reading file ${file.path}:`, error);
continue;
}
openFilesContent += `<OPEN_FILE>\n# file: ${file.basename}.md\n\n${fileContent}\n</OPEN_FILE>\n\n`;
}
openFilesContent += "</WORKING_FILE_SET>\n";
// Collate open files content with instructions
let context: string;
if (fileAccessMode === 'write') {
context = `\n\n<SYSTEM>${editInstructions + openFilesContent}</SYSTEM>`;
} else {
context = `\n\n<SYSTEM>${openFilesContent}</SYSTEM>`;
}
return context;
}
/**
* Calculates the Levenshtein distance between two strings
*
* @param a - First string
* @param b - Second string
* @returns The Levenshtein distance
*/
public levenshteinDistance(a: string, b: string): number {
if (a.length === 0) return b.length;
if (b.length === 0) return a.length;
const matrix = Array(b.length + 1).fill(null).map(() =>
Array(a.length + 1).fill(null)
);
for (let i = 0; i <= a.length; i++) matrix[0][i] = i;
for (let j = 0; j <= b.length; j++) matrix[j][0] = j;
for (let j = 1; j <= b.length; j++) {
for (let i = 1; i <= a.length; i++) {
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
matrix[j][i] = Math.min(
matrix[j][i - 1] + 1, // deletion
matrix[j - 1][i] + 1, // insertion
matrix[j - 1][i - 1] + cost // substitution
);
}
}
return matrix[b.length][a.length];
}
/**
* Finds the best matching file from a list of files based on the target name
*
* @param targetName - The name to match against
* @param files - Array of TFile objects to search
* @returns The best matching TFile or null if no matches found
*/
public findBestMatchingFile(targetName: string, files: TFile[]): TFile | null {
const MAX_DISTANCE = 10;
let bestMatch: { file: TFile, distance: number } | null = null;
for (const file of files) {
// Try both with and without extension
const distanceWithExt = this.levenshteinDistance(targetName.toLowerCase(), file.name.toLowerCase());
const distanceWithoutExt = this.levenshteinDistance(targetName.toLowerCase(), file.basename.toLowerCase());
const distance = Math.min(distanceWithExt, distanceWithoutExt);
if (distance <= MAX_DISTANCE && (!bestMatch || distance < bestMatch.distance)) {
bestMatch = { file, distance };
}
}
return bestMatch?.file || null;
}
/**
* Parses a text edit block from the content string
* Enhanced to handle incomplete blocks and extract partial information
*
* @param content - The content from which to parse edit blocks
* @param isComplete - Whether the edit block is complete (has closing tag)
* @returns Object with the parsed edit data and cleaned content
*/
public parseEditBlock(content: string, isComplete: boolean = true): ParsedEditBlock {
let cleanContent = '';
try {
// Normalize line breaks and clean control characters, but preserve empty lines
cleanContent = content
.replace(/\r\n/g, '\n')
.replace(/\r/g, '\n')
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '')
.trim();
// For incomplete blocks, try to extract partial information
if (!isComplete) {
// Initialize with basic structure
const partialData: EditBlock = {
file: "",
find: "",
replace: ""
};
// Try to extract file name from the first line
const firstLineMatch = cleanContent.match(/^([^\n]+)/);
if (firstLineMatch) {
partialData.file = firstLineMatch[1].trim();
}
// Try to extract search content field
const searchStartMatch = cleanContent.match(/<<<<<<< SEARCH\n([\s\S]*)/);
if (searchStartMatch) {
partialData.find = searchStartMatch[1];
if (!partialData.file) { // If file not on first line, try line before SEARCH
const lines = cleanContent.split('\n');
const searchIndex = lines.findIndex(line => line.startsWith("<<<<<<< SEARCH"));
if (searchIndex > 0) {
partialData.file = lines[searchIndex - 1].trim();
}
}
}
return {
editData: partialData,
cleanContent,
inProgress: true
};
}
// 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*$/;
const newFormatMatch = newFormatRegex.exec(cleanContent);
let editData: EditBlock | null = null;
if (newFormatMatch) {
editData = {
file: newFormatMatch[1].trim(),
find: newFormatMatch[2],
replace: newFormatMatch[3],
};
}
// 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) {
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) {
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'
};
}
else if (!editData.replace) {
error = {
type: 'missing_field',
message: 'Missing "replace" field in edit block',
details: 'The "replace" field is required and should contain the replacement text'
};
}
return error
? { editData, cleanContent, error }
: { editData, cleanContent };
} catch (error) {
console.error("Error parsing edit block:", error);
console.error("Content causing error:", content);
return {
editData: null,
cleanContent,
error: {
type: 'invalid_format',
message: 'Invalid JSON format in edit block',
details: error.message
}
};
}
}
/**
* Parses all edit blocks from a message
*
* @param message - The message containing text edit blocks in XML format
* @returns Array of EditBlock objects
*/
public parseEditBlocks(message: string): EditBlock[] {
const editBlocks: EditBlock[] = [];
// Set regex to match edit blocks based on Edit Start, End XML tags in the message
const editBlockRegex = new RegExp(`${this.EDIT_BLOCK_START}([\\s\\S]*?)${this.EDIT_BLOCK_END}`, 'g');
let match;
while ((match = editBlockRegex.exec(message)) !== null) {
const { editData, cleanContent, error } = this.parseEditBlock(match[1]);
if (error) {
console.error("Failed to parse edit block:", error);
console.debug("Content causing error:", match[1]);
editBlocks.push({
file: "unknown", // Fallback value when editData is null
find: "",
replace: `Error: ${error.message}\nOriginal content:\n${match[1]}`,
note: "Error parsing edit block",
hasError: true,
error: error
});
continue;
}
if (!editData) {
console.error("No edit data parsed");
continue;
}
editBlocks.push({
note: "Suggested edit",
file: editData.file,
find: editData.find,
replace: editData.replace,
hasError: !!error,
error: error || undefined
});
}
return editBlocks;
}
/**
* Creates a preview with differences highlighted
*
* @param originalText - The original text
* @param newText - The modified text
* @returns A string with differences highlighted
*/
public createPreviewWithDiff(originalText: string, newText: string): string {
// Define unique tokens to temporarily replace existing formatting markers
const HIGHLIGHT_TOKEN = "___KHOJ_HIGHLIGHT_MARKER___";
const STRIKETHROUGH_TOKEN = "___KHOJ_STRIKETHROUGH_MARKER___";
// Function to preserve existing formatting markers by replacing them with tokens
const preserveFormatting = (text: string): string => {
// Replace existing highlight markers with non-greedy pattern
let processed = text.replace(/==(.*?)==/g, `${HIGHLIGHT_TOKEN}$1${HIGHLIGHT_TOKEN}`);
// Replace existing strikethrough markers with non-greedy pattern
processed = processed.replace(/~~(.*?)~~/g, `${STRIKETHROUGH_TOKEN}$1${STRIKETHROUGH_TOKEN}`);
return processed;
};
// Function to restore original formatting markers
const restoreFormatting = (text: string): string => {
// Restore highlight markers
let processed = text.replace(new RegExp(HIGHLIGHT_TOKEN + "(.*?)" + HIGHLIGHT_TOKEN, "g"), "==$1==");
// Restore strikethrough markers
processed = processed.replace(new RegExp(STRIKETHROUGH_TOKEN + "(.*?)" + STRIKETHROUGH_TOKEN, "g"), "~~$1~~");
return processed;
};
// Preserve existing formatting in both texts
const preservedOriginal = preserveFormatting(originalText);
const preservedNew = preserveFormatting(newText);
// Find common prefix and suffix
let prefixLength = 0;
const minLength = Math.min(preservedOriginal.length, preservedNew.length);
while (prefixLength < minLength && preservedOriginal[prefixLength] === preservedNew[prefixLength]) {
prefixLength++;
}
let suffixLength = 0;
while (
suffixLength < minLength - prefixLength &&
preservedOriginal[preservedOriginal.length - 1 - suffixLength] === preservedNew[preservedNew.length - 1 - suffixLength]
) {
suffixLength++;
}
// Extract the parts
const commonPrefix = preservedOriginal.slice(0, prefixLength);
const commonSuffix = preservedOriginal.slice(preservedOriginal.length - suffixLength);
const originalDiff = preservedOriginal.slice(prefixLength, preservedOriginal.length - suffixLength);
const newDiff = preservedNew.slice(prefixLength, preservedNew.length - suffixLength);
// Format the differences
const formatLines = (text: string, marker: string): string => {
if (!text) return '';
return text.split('\n')
.map(line => {
line = line.trim();
if (!line) {
return marker === '==' ? '' : '~~';
}
return `${marker}${line}${marker}`;
})
.filter(line => line !== '~~')
.join('\n');
};
// Create the diff preview with preserved formatting tokens
const diffPreview = commonPrefix +
(originalDiff ? formatLines(originalDiff, '~~') : '') +
(newDiff ? formatLines(newDiff, '==') : '') +
commonSuffix;
// Restore original formatting markers in the final result
return restoreFormatting(diffPreview);
}
private textNormalize(text: string): string {
// Normalize whitespace and special characters
return text
.replace(/\u00A0/g, " ") // Replace non-breaking spaces with regular spaces
.replace(/[\u2002\u2003\u2007\u2008\u2009\u200A\u205F\u3000]/g, " ") // Replace various other Unicode spaces with regular spaces
.replace(/[\u2013\u2014]/g, '-') // Replace en-dash and em-dash with hyphen
.replace(/[\u2018\u2019]/g, "'") // Replace smart quotes with regular quotes
.replace(/[\u201C\u201D]/g, '"') // Replace smart double quotes with regular quotes
.replace(/\u2026/g, '...') // Replace ellipsis with three dots
.normalize('NFC') // Normalize to NFC form
}
private processSingleEdit(
rawFindText: string,
replaceText: string,
rawCurrentFileContent: string,
frontmatterEndIndex: number
): ProcessedEditResult {
let startIndex = -1;
let endIndex = -1;
// Normalize special characters before searching
const findText = this.textNormalize(rawFindText);
const currentFileContent = this.textNormalize(rawCurrentFileContent);
if (findText === "") {
// Empty search means replace entire content after frontmatter
startIndex = frontmatterEndIndex;
endIndex = currentFileContent.length;
} else {
startIndex = currentFileContent.indexOf(findText, frontmatterEndIndex);
if (startIndex !== -1) {
endIndex = startIndex + findText.length;
}
}
if (startIndex === -1 || endIndex === -1 || startIndex > endIndex) {
return {
preview: "",
newContent: currentFileContent,
error: `No matching text found in file.`
};
}
const textToReplace = currentFileContent.substring(startIndex, endIndex);
const newText = replaceText.trim();
const preview = this.createPreviewWithDiff(textToReplace, newText);
const newContent =
currentFileContent.substring(0, startIndex) +
preview +
currentFileContent.substring(endIndex);
return { preview, newContent };
}
/**
* Applies edit blocks to modify files
*
* @param editBlocks - Array of EditBlock objects to apply
* @param addConfirmationButtons - Optional callback to add confirmation UI elements
* @returns Object containing edit results and file backups
*/
public async applyEditBlocks(
editBlocks: EditBlock[],
onRetryNeeded?: (blockToRetry: EditBlock) => void
): Promise<{
editResults: { block: EditBlock, success: boolean, error?: string }[],
fileBackups: Map<string, string>,
}> {
// Check for parsing errors first
if (editBlocks.length === 0) {
return { editResults: [], fileBackups: new Map() };
}
// Store original content for each file in case we need to cancel
const fileBackups = new Map<string, string>();
// Track current content for each file as we apply edits
const currentFileContents = new Map<string, string>();
// 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');
// Track success/failure for each edit
const editResults: { block: EditBlock, success: boolean, error?: string }[] = [];
const blocksNeedingRetry: EditBlock[] = [];
// PHASE 1: Validation - Check all blocks before applying any changes
const validationResults: { block: EditBlock, valid: boolean, error?: string, targetFile?: TFile }[] = [];
for (const block of editBlocks) {
try {
// Skip blocks with parsing errors
if (block.hasError) {
validationResults.push({
block,
valid: false,
error: block.error?.message || 'Parsing error'
});
continue;
}
const targetFile = this.findBestMatchingFile(block.file, files);
if (!targetFile) {
validationResults.push({
block,
valid: false,
error: `No matching file found for "${block.file}"`
});
continue;
}
// Read the file content if not already backed up
if (!fileBackups.has(targetFile.path)) {
const content = await this.app.vault.read(targetFile);
fileBackups.set(targetFile.path, content);
currentFileContents.set(targetFile.path, content);
}
// Use current content (which may have been modified by previous validations)
const currentContent = currentFileContents.get(targetFile.path)!;
// Find frontmatter boundaries
const frontmatterMatch = currentContent.match(/^---\n[\s\S]*?\n---\n/);
const frontmatterEndIndex = frontmatterMatch ? frontmatterMatch[0].length : 0;
const processedEdit = this.processSingleEdit(block.find, block.replace, currentContent, frontmatterEndIndex);
if (processedEdit.error) {
validationResults.push({ block, valid: false, error: processedEdit.error });
continue;
}
// Validation passed
validationResults.push({ block, valid: true, targetFile });
// Update the current content for this file for subsequent validations
currentFileContents.set(targetFile.path, processedEdit.newContent);
} catch (error) {
validationResults.push({ block, valid: false, error: error.message });
}
}
// Check if all blocks are valid
const allValid = validationResults.every(result => result.valid);
// If any block is invalid, don't apply any changes
if (!allValid) {
// Reset current file contents
currentFileContents.clear();
// Add all invalid blocks to retry list
for (const result of validationResults) {
if (!result.valid) {
blocksNeedingRetry.push({
...result.block,
hasError: true,
error: {
type: 'invalid_format',
message: result.error || 'Validation failed',
details: result.error || 'Could not validate edit'
}
});
editResults.push({
block: result.block,
success: false,
error: result.error || 'Validation failed'
});
} else {
// Even valid blocks are considered failed in atomic mode if any block fails
editResults.push({
block: result.block,
success: false,
error: 'Other edits in the group failed validation'
});
}
}
// Trigger retry for the first failed block
if (blocksNeedingRetry.length > 0 && onRetryNeeded) {
onRetryNeeded(blocksNeedingRetry[0]);
}
return { editResults, fileBackups };
}
// PHASE 2: Application - Apply all changes since all blocks are valid
try {
// Reset current file contents to original state
currentFileContents.clear();
for (const [path, content] of fileBackups.entries()) {
currentFileContents.set(path, content);
}
// Apply all edits
for (const result of validationResults) {
const block = result.block;
const targetFile = result.targetFile!;
// Use current content (which may have been modified by previous edits)
const content = currentFileContents.get(targetFile.path)!;
// Find frontmatter boundaries
const frontmatterMatch = content.match(/^---\n[\s\S]*?\n---\n/);
const frontmatterEndIndex = frontmatterMatch ? frontmatterMatch[0].length : 0;
// Find the text to replace in original content
// Recalculate based on the current state of the file content for this phase
const processedEdit = this.processSingleEdit(block.find, block.replace, content, frontmatterEndIndex);
if (processedEdit.error) {
throw new Error(`Failed to re-locate edit markers for file "${targetFile.basename}" during application. Content may have shifted.`);
}
// Apply the changes to the file
await this.app.vault.modify(targetFile, processedEdit.newContent);
// Update the current content for this file for subsequent edits
currentFileContents.set(targetFile.path, processedEdit.newContent);
editResults.push({ block: {...block, replace: processedEdit.preview}, success: true });
}
} catch (error) {
console.error(`Error applying edits:`, error);
// Restore all files to their original state
for (const [path, content] of fileBackups.entries()) {
const file = this.app.vault.getAbstractFileByPath(path);
if (file && file instanceof TFile) {
await this.app.vault.modify(file, content);
}
}
// Mark all blocks as failed
for (const block of editBlocks) {
blocksNeedingRetry.push(block);
editResults.push({
block,
success: false,
error: `Failed to apply edits: ${error.message}`
});
}
// Trigger retry for the first block
if (blocksNeedingRetry.length > 0 && onRetryNeeded) {
onRetryNeeded(blocksNeedingRetry[0]);
}
}
return { editResults, fileBackups };
}
/**
* Transforms content edit blocks in a message to HTML for display
*
* @param message - The message containing content edit blocks in XML format
* @returns The transformed message with HTML for edit blocks
*/
public transformEditBlocks(message: string): string {
// 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');
// Detect all edit blocks, including partial ones
const partialBlocks = this.detectPartialEditBlocks(message);
// Process each detected block
let transformedMessage = message;
for (const block of partialBlocks) {
const isComplete = block.isComplete;
const content = block.content;
// Parse the block content
const { editData, cleanContent, error, inProgress } = this.parseEditBlock(content, isComplete);
// Escape content for HTML display
const diff = diffWords(editData?.find || '', editData?.replace || '');
let diffContent = diff.map(part => {
if (part.added) {
return `<span class="cm-positive">${part.value}</span>`;
} else if (part.removed) {
return `<span class="cm-negative"><s>${part.value}</s></span>`;
} else {
return `<span>${part.value}</span>`;
}
}
).join('').trim();
let htmlRender = '';
if (error || !editData) {
// Error block
console.error("Error parsing khoj-edit block:", error);
console.error("Content causing error:", content);
const errorTitle = `Error: ${error?.message || 'Parse error'}`;
const errorDetails = `Failed to parse edit block. Please check the JSON format and ensure all required fields are present.`;
htmlRender = `<details class="khoj-edit-accordion error">
<summary>${errorTitle}</summary>
<div class="khoj-edit-content">
<p class="khoj-edit-error-message">${errorDetails}</p>
<pre><code class="language-md error">${diffContent}</code></pre>
</div>
</details>`;
} else if (inProgress) {
// In-progress block
htmlRender = `<details class="khoj-edit-accordion in-progress">
<summary>📄 ${editData.file} <span class="khoj-edit-status">In Progress</span></summary>
<div class="khoj-edit-content">
<pre><code class="language-md">${diffContent}</code></pre>
</div>
</details>`;
} else {
// Success block
// Find the actual file that will be modified
const targetFile = this.findBestMatchingFile(editData.file, files);
const displayFileName = targetFile ? `${targetFile.basename}.${targetFile.extension}` : editData.file;
htmlRender = `<details class="khoj-edit-accordion success">
<summary>📄 ${displayFileName}</summary>
<div class="khoj-edit-content">
<div>${diffContent}</div>
</div>
</details>`;
}
// Replace the block in the message
if (isComplete) {
transformedMessage = transformedMessage.replace(`${this.EDIT_BLOCK_START}${content}${this.EDIT_BLOCK_END}`, htmlRender);
} else {
transformedMessage = transformedMessage.replace(`${this.EDIT_BLOCK_START}${content}`, htmlRender);
}
}
return transformedMessage;
}
/**
* Detects partial edit blocks in a message
* This allows for early detection of edit blocks before they are complete
*
* @param message - The message to search for partial edit blocks
* @returns An array of detected blocks with their content and completion status
*/
public detectPartialEditBlocks(message: string): PartialEditBlockResult[] {
const results: PartialEditBlockResult[] = [];
// This regex captures both complete and incomplete edit blocks
// It looks for EDIT_BLOCK_START tag followed by any content, and then either EDIT_BLOCK_END or the end of the string
const regex = new RegExp(`${this.EDIT_BLOCK_START}([\\s\\S]*?)(?:${this.EDIT_BLOCK_END}|$)`, 'g');
let match;
while ((match = regex.exec(message)) !== null) {
const content = match[1];
const isComplete = match[0].endsWith(this.EDIT_BLOCK_END);
results.push({
content,
isComplete
});
}
return results;
}
}

View File

@@ -2,6 +2,7 @@ import { Plugin, WorkspaceLeaf } from 'obsidian';
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';
@@ -17,6 +18,7 @@ export default class Khoj extends Plugin {
this.addCommand({
id: 'search',
name: 'Search',
hotkeys: [{ modifiers: ["Ctrl", "Alt"], key: "S" }],
callback: () => { new KhojSearchModal(this.app, this.settings).open(); }
});
@@ -24,7 +26,8 @@ export default class Khoj extends Plugin {
this.addCommand({
id: 'similar',
name: 'Find similar notes',
editorCallback: () => { new KhojSearchModal(this.app, this.settings, true).open(); }
hotkeys: [{ modifiers: ["Ctrl", "Alt"], key: "F" }],
editorCallback: () => { this.activateView(KhojView.SIMILAR); }
});
// Add chat command. It can be triggered from anywhere
@@ -34,6 +37,64 @@ export default class Khoj extends Plugin {
callback: () => { this.activateView(KhojView.CHAT); }
});
// Add similar documents view command
this.addCommand({
id: 'similar-view',
name: 'Open Similar Documents View',
callback: () => { this.activateView(KhojView.SIMILAR); }
});
// Add new chat command with hotkey
this.addCommand({
id: 'new-chat',
name: 'New Chat',
hotkeys: [{ modifiers: ["Ctrl", "Alt"], key: "N" }],
callback: async () => {
// First, activate the chat view
await this.activateView(KhojView.CHAT);
// Wait a short moment for the view to activate
setTimeout(() => {
// Try to get the active chat view
const chatView = this.app.workspace.getActiveViewOfType(KhojChatView);
if (chatView) {
chatView.createNewConversation();
}
}, 100);
}
});
// Add conversation history command with hotkey
this.addCommand({
id: 'conversation-history',
name: 'Show Conversation History',
hotkeys: [{ modifiers: ["Ctrl", "Alt"], key: "O" }],
callback: () => {
this.activateView(KhojView.CHAT).then(() => {
const chatView = this.app.workspace.getActiveViewOfType(KhojChatView);
if (chatView) {
chatView.toggleChatSessions(true);
}
});
}
});
// Add voice capture command with hotkey
this.addCommand({
id: 'voice-capture',
name: 'Start Voice Capture',
hotkeys: [{ modifiers: ["Ctrl", "Alt"], key: "V" }],
callback: () => {
this.activateView(KhojView.CHAT).then(() => {
const chatView = this.app.workspace.getActiveViewOfType(KhojChatView);
if (chatView) {
// Trigger speech to text functionality
chatView.speechToText(new KeyboardEvent('keydown'));
}
});
}
});
// Add sync command to manually sync new changes
this.addCommand({
id: 'sync',
@@ -49,7 +110,34 @@ export default class Khoj extends Plugin {
}
});
// Add edit confirmation commands
this.addCommand({
id: 'apply-edits',
name: 'Apply pending edits',
hotkeys: [{ modifiers: ["Ctrl", "Shift"], key: "Enter" }],
callback: () => {
const chatView = this.app.workspace.getActiveViewOfType(KhojChatView);
if (chatView) {
chatView.applyPendingEdits();
}
}
});
this.addCommand({
id: 'cancel-edits',
name: 'Cancel pending edits',
hotkeys: [{ modifiers: ["Ctrl", "Shift"], key: "Backspace" }],
callback: () => {
const chatView = this.app.workspace.getActiveViewOfType(KhojChatView);
if (chatView) {
chatView.cancelPendingEdits();
}
}
});
// Register views
this.registerView(KhojView.CHAT, (leaf) => new KhojChatView(leaf, this.settings));
this.registerView(KhojView.SIMILAR, (leaf) => new KhojSimilarView(leaf, this.settings));
// Create an icon in the left ribbon.
this.addRibbonIcon('message-circle', 'Khoj', (_: MouseEvent) => {
@@ -108,35 +196,48 @@ export default class Khoj extends Plugin {
this.unload();
}
async activateView(viewType: KhojView) {
async activateView(viewType: KhojView, existingLeaf?: WorkspaceLeaf) {
const { workspace } = this.app;
let leafToUse: WorkspaceLeaf | null = null;
let leaf: WorkspaceLeaf | null = null;
const leaves = workspace.getLeavesOfType(viewType);
if (leaves.length > 0) {
// A leaf with our view already exists, use that
leaf = leaves[0];
// Check if an existingLeaf is provided and is suitable for a view type switch
if (existingLeaf && existingLeaf.view &&
(existingLeaf.view.getViewType() === KhojView.CHAT || existingLeaf.view.getViewType() === KhojView.SIMILAR) &&
existingLeaf.view.getViewType() !== viewType) {
// The existing leaf is a Khoj pane and we want to switch its type
leafToUse = existingLeaf;
await leafToUse.setViewState({ type: viewType, active: true });
} else {
// Our view could not be found in the workspace, create a new leaf
// in the right sidebar for it
leaf = workspace.getRightLeaf(false);
await leaf?.setViewState({ type: viewType, active: true });
// Standard logic: find an existing leaf of the target type, or create a new one
const leaves = workspace.getLeavesOfType(viewType);
if (leaves.length > 0) {
leafToUse = leaves[0];
} else {
// If we are not switching an existing Khoj leaf,
// and no leaf of the target type exists, create a new one.
// Use the provided existingLeaf if it's not a Khoj pane we're trying to switch,
// otherwise, get a new right leaf.
leafToUse = (existingLeaf && !(existingLeaf.view instanceof KhojPaneView)) ? existingLeaf : workspace.getRightLeaf(false);
if (leafToUse) {
await leafToUse.setViewState({ type: viewType, active: true });
} else {
console.error("Khoj: Could not get a leaf to activate view.");
return;
}
}
}
if (leaf) {
const activeKhojLeaf = workspace.getActiveViewOfType(KhojPaneView)?.leaf;
// Jump to the previous view if the current view is Khoj Side Pane
if (activeKhojLeaf === leaf) jumpToPreviousView();
// Else Reveal the leaf in case it is in a collapsed sidebar
else {
workspace.revealLeaf(leaf);
if (leafToUse) {
workspace.revealLeaf(leafToUse); // Ensure the leaf is visible
if (viewType === KhojView.CHAT) {
// focus on the chat input when the chat view is opened
let chatView = leaf.view as KhojChatView;
let chatInput = <HTMLTextAreaElement>chatView.contentEl.getElementsByClassName("khoj-chat-input")[0];
if (chatInput) chatInput.focus();
// Specific actions after revealing/switching
if (viewType === KhojView.CHAT) {
// Ensure the view instance is correct after potential setViewState
const chatView = leafToUse.view as KhojChatView;
if (chatView instanceof KhojChatView) { // Double check instance type
// Use a more robust way to get the input, or ensure it's always present after onOpen
const chatInput = chatView.containerEl.querySelector<HTMLTextAreaElement>(".khoj-chat-input");
chatInput?.focus();
}
}
}

View File

@@ -1,6 +1,5 @@
import { ItemView, WorkspaceLeaf } from 'obsidian';
import { KhojSetting } from 'src/settings';
import { KhojSearchModal } from 'src/search_modal';
import { KhojView, populateHeaderPane } from './utils';
export abstract class KhojPaneView extends ItemView {
@@ -20,13 +19,18 @@ export abstract class KhojPaneView extends ItemView {
// Add title to the Khoj Chat modal
let headerEl = contentEl.createDiv(({ attr: { id: "khoj-header", class: "khoj-header" } }));
// Setup the header pane
await populateHeaderPane(headerEl, this.setting);
// Set the active nav pane
headerEl.getElementsByClassName("chat-nav")[0]?.classList.add("khoj-nav-selected");
headerEl.getElementsByClassName("chat-nav")[0]?.addEventListener("click", (_) => { this.activateView(KhojView.CHAT); });
headerEl.getElementsByClassName("search-nav")[0]?.addEventListener("click", (_) => { new KhojSearchModal(this.app, this.setting).open(); });
headerEl.getElementsByClassName("similar-nav")[0]?.addEventListener("click", (_) => { new KhojSearchModal(this.app, this.setting, true).open(); });
const viewType = this.getViewType();
await populateHeaderPane(headerEl, this.setting, viewType);
// Set the active nav pane based on the current view's type
if (viewType === KhojView.CHAT) {
headerEl.querySelector(".chat-nav")?.classList.add("khoj-nav-selected");
} else if (viewType === KhojView.SIMILAR) {
headerEl.querySelector(".similar-nav")?.classList.add("khoj-nav-selected");
}
// The similar-nav event listener is already set in utils.ts
let similarNavSvgEl = headerEl.getElementsByClassName("khoj-nav-icon-similar")[0]?.firstElementChild;
if (!!similarNavSvgEl) similarNavSvgEl.id = "similar-nav-icon-svg";
}

View File

@@ -1,6 +1,6 @@
import { App, Notice, PluginSettingTab, Setting, TFile, SuggestModal } from 'obsidian';
import Khoj from 'src/main';
import { canConnectToBackend, getBackendStatusMessage, updateContentIndex } from './utils';
import { canConnectToBackend, fetchChatModels, fetchUserServerSettings, getBackendStatusMessage, updateContentIndex, updateServerChatModel } from './utils';
export interface UserInfo {
username?: string;
@@ -16,6 +16,16 @@ interface SyncFileTypes {
pdf: boolean;
}
export interface ModelOption {
id: string;
name: string;
}
export interface ServerUserConfig {
selected_chat_model_config?: number; // This is the ID from the server
// Add other fields from UserConfig if needed by the plugin elsewhere
}
export interface KhojSetting {
resultsCount: number;
khojUrl: string;
@@ -27,10 +37,13 @@ export interface KhojSetting {
userInfo: UserInfo | null;
syncFolders: string[];
syncInterval: number;
autoVoiceResponse: boolean;
selectedChatModelId: string | null; // Mirrors server's selected_chat_model_config
availableChatModels: ModelOption[];
}
export const DEFAULT_SETTINGS: KhojSetting = {
resultsCount: 6,
resultsCount: 15,
khojUrl: 'https://app.khoj.dev',
khojApiKey: '',
connectedToBackend: false,
@@ -44,10 +57,14 @@ export const DEFAULT_SETTINGS: KhojSetting = {
userInfo: null,
syncFolders: [],
syncInterval: 60,
autoVoiceResponse: true,
selectedChatModelId: null, // Will be populated from server
availableChatModels: [],
}
export class KhojSettingTab extends PluginSettingTab {
plugin: Khoj;
private chatModelSetting: Setting | null = null;
constructor(app: App, plugin: Khoj) {
super(app, plugin);
@@ -57,20 +74,75 @@ export class KhojSettingTab extends PluginSettingTab {
display(): void {
const { containerEl } = this;
containerEl.empty();
this.chatModelSetting = null; // Reset when display is called
// Add notice whether able to connect to khoj backend or not
let backendStatusEl = containerEl.createEl('small', {
text: getBackendStatusMessage(
this.plugin.settings.connectedToBackend,
this.plugin.settings.userInfo?.email,
this.plugin.settings.khojUrl,
this.plugin.settings.khojApiKey
)
}
let backendStatusMessage = getBackendStatusMessage(
this.plugin.settings.connectedToBackend,
this.plugin.settings.userInfo?.email,
this.plugin.settings.khojUrl,
this.plugin.settings.khojApiKey
);
let backendStatusMessage: string = '';
const connectHeaderEl = containerEl.createEl('h3', { title: backendStatusMessage });
const connectHeaderContentEl = connectHeaderEl.createSpan({ cls: 'khoj-connect-settings-header' });
const connectTitleEl = connectHeaderContentEl.createSpan({ text: 'Connect' });
const backendStatusEl = connectTitleEl.createSpan({ text: this.connectStatusIcon(), cls: 'khoj-connect-settings-header-status' });
if (this.plugin.settings.userInfo && this.plugin.settings.connectedToBackend) {
if (this.plugin.settings.userInfo.photo) {
const profilePicEl = connectHeaderContentEl.createEl('img', {
attr: { src: this.plugin.settings.userInfo.photo },
cls: 'khoj-profile'
});
profilePicEl.addEventListener('click', () => { new Notice(backendStatusMessage); });
} else if (this.plugin.settings.userInfo.email) {
const initial = this.plugin.settings.userInfo.email[0].toUpperCase();
const profilePicEl = connectHeaderContentEl.createDiv({
text: initial,
cls: 'khoj-profile khoj-profile-initial'
});
profilePicEl.addEventListener('click', () => { new Notice(backendStatusMessage); });
}
}
if (this.plugin.settings.userInfo && this.plugin.settings.userInfo.email) {
connectHeaderEl.title = this.plugin.settings.userInfo?.email === 'default@example.com'
? "Signed in"
: `Signed in as ${this.plugin.settings.userInfo.email}`;
}
// Add khoj settings configurable from the plugin settings tab
const apiKeySetting = new Setting(containerEl)
.setName('Khoj API Key')
.addText(text => text
.setValue(`${this.plugin.settings.khojApiKey}`)
.onChange(async (value) => {
this.plugin.settings.khojApiKey = value.trim();
({
connectedToBackend: this.plugin.settings.connectedToBackend,
userInfo: this.plugin.settings.userInfo,
statusMessage: backendStatusMessage,
} = await canConnectToBackend(this.plugin.settings.khojUrl, this.plugin.settings.khojApiKey));
if (!this.plugin.settings.connectedToBackend) {
this.plugin.settings.availableChatModels = [];
this.plugin.settings.selectedChatModelId = null;
}
await this.plugin.saveSettings();
backendStatusEl.setText(this.connectStatusIcon())
connectHeaderEl.title = backendStatusMessage;
await this.refreshModelsAndServerPreference();
}));
// Add API key setting description with link to get API key
apiKeySetting.descEl.createEl('span', {
text: 'Connect your Khoj Cloud account. ',
});
apiKeySetting.descEl.createEl('a', {
text: 'Get your API Key',
href: `${this.plugin.settings.khojUrl}/settings#clients`,
attr: { target: '_blank' }
});
new Setting(containerEl)
.setName('Khoj URL')
.setDesc('The URL of the Khoj backend.')
@@ -84,29 +156,46 @@ export class KhojSettingTab extends PluginSettingTab {
statusMessage: backendStatusMessage,
} = await canConnectToBackend(this.plugin.settings.khojUrl, this.plugin.settings.khojApiKey));
if (!this.plugin.settings.connectedToBackend) {
this.plugin.settings.availableChatModels = [];
this.plugin.settings.selectedChatModelId = null;
}
await this.plugin.saveSettings();
backendStatusEl.setText(backendStatusMessage);
backendStatusEl.setText(this.connectStatusIcon())
connectHeaderEl.title = backendStatusMessage;
await this.refreshModelsAndServerPreference();
}));
// Interact section
containerEl.createEl('h3', { text: 'Interact' });
// Chat Model Dropdown
this.renderChatModelDropdown();
// Initial fetch of models and server preference if connected
if (this.plugin.settings.connectedToBackend) {
// Defer slightly to ensure UI is ready and avoid race conditions
setTimeout(async () => {
await this.refreshModelsAndServerPreference();
}, 1000);
}
// Add new setting for auto voice response after voice input
new Setting(containerEl)
.setName('Khoj API Key')
.setDesc('Use Khoj Cloud with your Khoj API Key')
.addText(text => text
.setValue(`${this.plugin.settings.khojApiKey}`)
.setName('Auto Voice Response')
.setDesc('Automatically read responses after voice messages')
.addToggle(toggle => toggle
.setValue(this.plugin.settings.autoVoiceResponse)
.onChange(async (value) => {
this.plugin.settings.khojApiKey = value.trim();
({
connectedToBackend: this.plugin.settings.connectedToBackend,
userInfo: this.plugin.settings.userInfo,
statusMessage: backendStatusMessage,
} = await canConnectToBackend(this.plugin.settings.khojUrl, this.plugin.settings.khojApiKey));
this.plugin.settings.autoVoiceResponse = value;
await this.plugin.saveSettings();
backendStatusEl.setText(backendStatusMessage);
}));
new Setting(containerEl)
.setName('Results Count')
.setDesc('The number of results to show in search and use for chat.')
.addSlider(slider => slider
.setLimits(1, 10, 1)
.setLimits(1, 30, 1)
.setValue(this.plugin.settings.resultsCount)
.setDynamicTooltip()
.onChange(async (value) => {
@@ -117,6 +206,16 @@ export class KhojSettingTab extends PluginSettingTab {
// Add new "Sync" heading
containerEl.createEl('h3', { text: 'Sync' });
new Setting(containerEl)
.setName('Auto Sync')
.setDesc('Automatically index your vault with Khoj.')
.addToggle(toggle => toggle
.setValue(this.plugin.settings.autoConfigure)
.onChange(async (value) => {
this.plugin.settings.autoConfigure = value;
await this.plugin.saveSettings();
}));
// Add setting to sync markdown notes
new Setting(containerEl)
.setName('Sync Notes')
@@ -150,16 +249,6 @@ export class KhojSettingTab extends PluginSettingTab {
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName('Auto Sync')
.setDesc('Automatically index your vault with Khoj.')
.addToggle(toggle => toggle
.setValue(this.plugin.settings.autoConfigure)
.onChange(async (value) => {
this.plugin.settings.autoConfigure = value;
await this.plugin.saveSettings();
}));
// Add setting for sync interval
const syncIntervalValues = [1, 5, 10, 20, 30, 45, 60, 120, 1440];
new Setting(containerEl)
@@ -184,7 +273,7 @@ export class KhojSettingTab extends PluginSettingTab {
// Add setting to manage sync folders
const syncFoldersContainer = containerEl.createDiv('sync-folders-container');
const foldersSetting = new Setting(syncFoldersContainer)
new Setting(syncFoldersContainer)
.setName('Sync Folders')
.setDesc('Specify folders to sync (leave empty to sync entire vault)')
.addButton(button => button
@@ -252,6 +341,104 @@ export class KhojSettingTab extends PluginSettingTab {
);
}
private connectStatusIcon() {
if (this.plugin.settings.connectedToBackend && this.plugin.settings.userInfo?.email)
return '🟢';
else if (this.plugin.settings.connectedToBackend)
return '🟡'
else
return '🔴';
}
private async refreshModelsAndServerPreference() {
let serverSelectedModelId: string | null = null;
if (this.plugin.settings.connectedToBackend) {
const [availableModels, serverConfig] = await Promise.all([
fetchChatModels(this.plugin.settings),
fetchUserServerSettings(this.plugin.settings)
]);
this.plugin.settings.availableChatModels = availableModels;
if (serverConfig && serverConfig.selected_chat_model_config !== undefined && serverConfig.selected_chat_model_config !== null) {
const serverModelIdStr = serverConfig.selected_chat_model_config.toString();
// Ensure the server's selected model is actually in the available list
if (this.plugin.settings.availableChatModels.some(m => m.id === serverModelIdStr)) {
serverSelectedModelId = serverModelIdStr;
} else {
// Server has a selection, but it's not in the options list (e.g. model removed, or different set of models)
// In this case, we might fall back to null (Khoj Default)
console.warn(`Khoj: Server's selected model ID ${serverModelIdStr} not in available models. Falling back to default.`);
serverSelectedModelId = null;
}
} else {
// No specific model configured on the server, or it's explicitly null
serverSelectedModelId = null;
}
this.plugin.settings.selectedChatModelId = serverSelectedModelId;
} else {
this.plugin.settings.availableChatModels = [];
this.plugin.settings.selectedChatModelId = null; // Clear selection if disconnected
}
await this.plugin.saveSettings(); // Save the potentially updated selectedChatModelId
this.renderChatModelDropdown(); // Re-render the dropdown with new data
}
private renderChatModelDropdown() {
if (!this.chatModelSetting) {
this.chatModelSetting = new Setting(this.containerEl)
.setName('Chat Model');
} else {
// Clear previous description and controls to prepare for re-rendering
this.chatModelSetting.descEl.empty();
this.chatModelSetting.controlEl.empty();
}
// Use this.chatModelSetting directly for modifications
const modelSetting = this.chatModelSetting;
if (!this.plugin.settings.connectedToBackend) {
modelSetting.setDesc('Connect to Khoj to load and set chat model options.');
modelSetting.addText(text => text.setValue("Not connected").setDisabled(true));
return;
}
if (this.plugin.settings.availableChatModels.length === 0 && this.plugin.settings.connectedToBackend) {
modelSetting.setDesc('Fetching models or no models available. Check Khoj connection or try refreshing.');
modelSetting.addButton(button => button
.setButtonText('Refresh Models')
.onClick(async () => {
button.setButtonText('Refreshing...').setDisabled(true);
await this.refreshModelsAndServerPreference();
// Re-rendering happens inside refreshModelsAndServerPreference
}));
return;
}
modelSetting.setDesc('The default AI model used for chat.');
modelSetting.addDropdown(dropdown => {
dropdown.addOption('', 'Default'); // Placeholder when cannot retrieve chat model options from server.
this.plugin.settings.availableChatModels.forEach(model => {
dropdown.addOption(model.id, model.name);
});
dropdown
.setValue(this.plugin.settings.selectedChatModelId || '')
.onChange(async (value) => {
// Attempt to update the server
const success = await updateServerChatModel(value, this.plugin.settings);
if (success) {
await this.plugin.saveSettings();
} else {
// Server update failed, revert dropdown to the current setting value
// to avoid UI mismatch.
dropdown.setValue(this.plugin.settings.selectedChatModelId || '');
}
// Potentially re-render or refresh if needed, though setValue should update UI.
// this.refreshModelsAndServerPreference(); // Could be called to ensure full sync, but might be too much
});
});
}
// Helper method to update the folder list display
private updateFolderList(containerEl: HTMLElement) {
containerEl.empty();

View File

@@ -0,0 +1,434 @@
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';
export interface SimilarResult {
entry: string;
file: string;
inVault: boolean;
}
export class KhojSimilarView extends KhojPaneView {
static iconName: string = "search";
setting: KhojSetting;
currentController: AbortController | null = null;
isLoading: boolean = false;
loadingEl: HTMLElement;
resultsContainerEl: HTMLElement;
searchInputEl: HTMLInputElement;
currentFile: TFile | null = null;
fileWatcher: any;
component: any;
constructor(leaf: WorkspaceLeaf, setting: KhojSetting) {
super(leaf, setting);
this.setting = setting;
this.component = this;
}
getViewType(): string {
return KhojView.SIMILAR;
}
getDisplayText(): string {
return "Khoj Similar Documents";
}
getIcon(): string {
return "search";
}
async onOpen() {
await super.onOpen();
const { contentEl } = this;
// Create main container
const mainContainerEl = contentEl.createDiv({ cls: "khoj-similar-container" });
// Create search input container
const searchContainerEl = mainContainerEl.createDiv({ cls: "khoj-similar-search-container" });
// Create search input
this.searchInputEl = searchContainerEl.createEl("input", {
cls: "khoj-similar-search-input",
attr: {
type: "text",
placeholder: "Search or use current file"
}
});
// Create refresh button
const refreshButtonEl = searchContainerEl.createEl("button", {
cls: "khoj-similar-refresh-button"
});
setIcon(refreshButtonEl, "refresh-cw");
refreshButtonEl.createSpan({ text: "Refresh" });
refreshButtonEl.addEventListener("click", () => {
this.updateSimilarDocuments();
});
// Create results container
this.resultsContainerEl = mainContainerEl.createDiv({ cls: "khoj-similar-results" });
// Create loading element
this.loadingEl = mainContainerEl.createDiv({ cls: "search-loading" });
const spinnerEl = this.loadingEl.createDiv({ cls: "search-loading-spinner" });
this.loadingEl.style.position = "absolute";
this.loadingEl.style.top = "50%";
this.loadingEl.style.left = "50%";
this.loadingEl.style.transform = "translate(-50%, -50%)";
this.loadingEl.style.zIndex = "1000";
this.loadingEl.style.display = "none";
// Register event handlers
this.registerFileActiveHandler();
// Add event listener for search input
this.searchInputEl.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
this.getSimilarDocuments(this.searchInputEl.value);
}
});
// Update similar documents for current file
this.updateSimilarDocuments();
}
/**
* Register handler for file active events
*/
registerFileActiveHandler() {
// Clean up existing watcher if any
if (this.fileWatcher) {
this.app.workspace.off("file-open", this.fileWatcher);
}
// Set up new watcher
this.fileWatcher = this.app.workspace.on("file-open", (file) => {
if (file) {
this.currentFile = file;
this.updateSimilarDocuments();
}
});
// Register for cleanup when view is closed
this.register(() => {
this.app.workspace.off("file-open", this.fileWatcher);
});
}
/**
* Update similar documents based on current file
*/
async updateSimilarDocuments() {
const file = this.app.workspace.getActiveFile();
if (!file) {
this.updateUI("no-file");
return;
}
if (file.extension !== 'md') {
this.updateUI("unsupported-file");
return;
}
this.currentFile = file;
// Read file content
const content = await this.app.vault.read(file);
// Get similar documents
await this.getSimilarDocuments(content);
}
/**
* Get similar documents from Khoj API
*
* @param query - The query text to find similar documents
*/
async getSimilarDocuments(query: string): Promise<SimilarResult[]> {
// Do not show loading if the query is empty
if (!query.trim()) {
this.isLoading = false;
this.updateLoadingState();
this.updateUI("empty-query");
return [];
}
// Show loading state
this.isLoading = true;
this.updateLoadingState();
this.updateUI("loading");
// Cancel previous request if it exists
if (this.currentController) {
this.currentController.abort();
}
try {
// Create a new controller for this request
this.currentController = new AbortController();
// Setup Query Khoj backend for search results
let encodedQuery = encodeURIComponent(query);
let searchUrl = `${this.setting.khojUrl}/api/search?q=${encodedQuery}&n=${this.setting.resultsCount}&r=true&client=obsidian`;
let headers = {
'Authorization': `Bearer ${this.setting.khojApiKey}`,
}
// Get search results from Khoj backend
const response = await fetch(searchUrl, {
headers: headers,
signal: this.currentController.signal
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
// Parse search results
let results = data
.filter((result: any) => {
// Filter out the current file if it's in the results
if (this.currentFile && result.additional.file.endsWith(this.currentFile.path)) {
return false;
}
return true;
})
.map((result: any) => {
return {
entry: result.entry,
file: result.additional.file,
inVault: this.isFileInVault(result.additional.file)
} as SimilarResult;
})
.sort((a: SimilarResult, b: SimilarResult) => {
if (a.inVault === b.inVault) return 0;
return a.inVault ? -1 : 1;
});
// Hide loading state
this.isLoading = false;
this.updateLoadingState();
// Render results
this.renderResults(results);
return results;
} catch (error) {
// Ignore cancellation errors
if (error.name === 'AbortError') {
return [];
}
// For other errors, show error state
console.error('Search error:', error);
this.isLoading = false;
this.updateLoadingState();
this.updateUI("error", error.message);
return [];
}
}
/**
* Check if a file is in the vault
*
* @param filePath - The file path to check
* @returns True if the file is in the vault
*/
isFileInVault(filePath: string): boolean {
const files = this.app.vault.getFiles();
return files.some(file => filePath.endsWith(file.path));
}
/**
* Render search results
*
* @param results - The search results to render
*/
renderResults(results: SimilarResult[]) {
// Clear previous results
this.resultsContainerEl.empty();
if (results.length === 0) {
this.updateUI("no-results");
return;
}
// Show results count
this.resultsContainerEl.createEl("div", {
cls: "khoj-results-count",
text: `Found ${results.length} similar document${results.length > 1 ? 's' : ''}`
});
// Create results list
const resultsListEl = this.resultsContainerEl.createEl("div", { cls: "khoj-similar-results-list" });
// Render each result
results.forEach(async (result) => {
const resultEl = resultsListEl.createEl("div", { cls: "khoj-similar-result-item" });
// Extract filename
let os_path_separator = result.file.includes('\\') ? '\\' : '/';
let filename = result.file.split(os_path_separator).pop();
// Create header container for filename and more context button
const headerEl = resultEl.createEl("div", { cls: "khoj-similar-result-header" });
// Show filename with appropriate color
const fileEl = headerEl.createEl("div", {
cls: `khoj-result-file ${result.inVault ? 'in-vault' : 'not-in-vault'}`
});
fileEl.setText(filename ?? "");
// Add indicator for files not in vault
if (!result.inVault) {
fileEl.createSpan({
text: " (not in vault)",
cls: "khoj-result-file-status"
});
}
// Add "More context" button
const moreContextButton = headerEl.createEl("button", {
cls: "khoj-more-context-button"
});
moreContextButton.createSpan({ text: "More context" });
setIcon(moreContextButton.createSpan(), "chevron-down");
// Create content element (hidden by default)
const contentEl = resultEl.createEl("div", {
cls: "khoj-result-entry khoj-similar-content-hidden"
});
// Prepare content for rendering
let contentToRender = "";
// Remove YAML frontmatter
result.entry = result.entry.replace(/---[\n\r][\s\S]*---[\n\r]/, '');
// Truncate to 8 lines
const lines_to_render = 8;
let entry_snipped_indicator = result.entry.split('\n').length > lines_to_render ? ' **...**' : '';
let snipped_entry = result.entry.split('\n').slice(0, lines_to_render).join('\n');
contentToRender = `${snipped_entry}${entry_snipped_indicator}`;
// Render markdown
await MarkdownRenderer.renderMarkdown(
contentToRender,
contentEl,
result.file,
this.component
);
// Add click handler to the more context button
moreContextButton.addEventListener("click", (e) => {
e.stopPropagation(); // Prevent opening the file
// Toggle content visibility
if (contentEl.classList.contains("khoj-similar-content-hidden")) {
contentEl.classList.remove("khoj-similar-content-hidden");
contentEl.classList.add("khoj-similar-content-visible");
moreContextButton.empty();
moreContextButton.createSpan({ text: "Less context" });
setIcon(moreContextButton.createSpan(), "chevron-up");
} else {
contentEl.classList.remove("khoj-similar-content-visible");
contentEl.classList.add("khoj-similar-content-hidden");
moreContextButton.empty();
moreContextButton.createSpan({ text: "More context" });
setIcon(moreContextButton.createSpan(), "chevron-down");
}
});
// Add click handler to open the file
resultEl.addEventListener("click", (e) => {
// Don't open if clicking on the more context button
if (e.target === moreContextButton || moreContextButton.contains(e.target as Node)) {
return;
}
this.openResult(result);
});
});
}
/**
* Open a search result
*
* @param result - The result to open
*/
async openResult(result: SimilarResult) {
// Only open files that are in the vault
if (!result.inVault) {
new Notice("This file is not in your vault");
return;
}
// Get all markdown and binary files in vault
const mdFiles = this.app.vault.getMarkdownFiles();
const binaryFiles = this.app.vault.getFiles().filter(file =>
supportedBinaryFileTypes.includes(file.extension)
);
// Find and open the file
let linkToEntry = getLinkToEntry(
mdFiles.concat(binaryFiles),
result.file,
result.entry
);
if (linkToEntry) {
this.app.workspace.openLinkText(linkToEntry, '');
}
}
/**
* Update the loading state
*/
private updateLoadingState() {
this.loadingEl.style.display = this.isLoading ? "block" : "none";
}
/**
* Update the UI based on the current state
*
* @param state - The current state
* @param message - Optional message for error states
*/
updateUI(state: "loading" | "no-file" | "unsupported-file" | "no-results" | "error" | "empty-query", message?: string) {
// Clear results container if not loading
if (state !== "loading") {
this.resultsContainerEl.empty();
}
// Create message element
const messageEl = this.resultsContainerEl.createEl("div", { cls: "khoj-similar-message" });
// Set message based on state
switch (state) {
case "loading":
// Loading is handled by the loading spinner
break;
case "no-file":
messageEl.setText("No file is currently open. Open a markdown file to see similar documents.");
break;
case "unsupported-file":
messageEl.setText("This file type is not supported. Only markdown files are supported.");
break;
case "no-results":
messageEl.setText("No similar documents found.");
break;
case "error":
messageEl.setText(`Error: ${message || "Failed to fetch similar documents"}`);
break;
case "empty-query":
messageEl.setText("Please enter a search query or open a markdown file.");
break;
}
}
}

View File

@@ -1,5 +1,6 @@
import { FileSystemAdapter, Notice, Vault, Modal, TFile, request, setIcon, Editor } from 'obsidian';
import { KhojSetting, UserInfo } from 'src/settings'
import { FileSystemAdapter, Notice, Vault, Modal, TFile, request, setIcon, Editor, App, WorkspaceLeaf } from 'obsidian';
import { KhojSetting, ModelOption, ServerUserConfig, UserInfo } from 'src/settings'
import { KhojSearchModal } from './search_modal';
export function getVaultAbsolutePath(vault: Vault): string {
let adaptor = vault.adapter;
@@ -162,8 +163,9 @@ export async function updateContentIndex(vault: Vault, setting: KhojSetting, las
batch.forEach(fileItem => { formData.append('files', fileItem.blob, fileItem.path) });
// Call Khoj backend to sync index with updated files in vault
const method = regenerate ? "PUT" : "PATCH";
const response = await fetch(`${setting.khojUrl}/api/content?client=obsidian`, {
method: "PATCH",
method: method,
headers: {
'Authorization': `Bearer ${setting.khojApiKey}`,
},
@@ -317,12 +319,12 @@ export function getBackendStatusMessage(
return `✅ Connected to Khoj. ❗Get a valid API key from ${khojUrl}/settings#clients to log in`;
else if (userEmail === 'default@example.com')
// Logged in as default user in anonymous mode
return `Signed in to Khoj`;
return `Welcome back to Khoj`;
else
return `Signed in to Khoj as ${userEmail}`;
return `Welcome back to Khoj, ${userEmail}`;
}
export async function populateHeaderPane(headerEl: Element, setting: KhojSetting): Promise<void> {
export async function populateHeaderPane(headerEl: Element, setting: KhojSetting, viewType: string): Promise<void> {
let userInfo: UserInfo | null = null;
try {
const { userInfo: extractedUserInfo } = await canConnectToBackend(setting.khojUrl, setting.khojApiKey, false);
@@ -332,19 +334,26 @@ export async function populateHeaderPane(headerEl: Element, setting: KhojSetting
}
// Add Khoj title to header element
const titleEl = headerEl.createDiv();
const titlePaneEl = headerEl.createDiv();
titlePaneEl.className = 'khoj-header-title-pane';
const titleEl = titlePaneEl.createDiv();
titleEl.className = 'khoj-logo';
titleEl.textContent = "KHOJ"
titleEl.textContent = "Khoj";
// Populate the header element with the navigation pane
// Create the nav element
const nav = headerEl.createEl('nav');
const nav = titlePaneEl.createEl('nav');
nav.className = 'khoj-nav';
// Create the title pane element
titlePaneEl.appendChild(titleEl);
titlePaneEl.appendChild(nav);
// Create the chat link
const chatLink = nav.createEl('a');
chatLink.id = 'chat-nav';
chatLink.className = 'khoj-nav chat-nav';
chatLink.dataset.view = KhojView.CHAT;
// Create the chat icon
const chatIcon = chatLink.createEl('span');
@@ -368,6 +377,7 @@ export async function populateHeaderPane(headerEl: Element, setting: KhojSetting
// Create the search icon
const searchIcon = searchLink.createEl('span');
searchIcon.className = 'khoj-nav-icon khoj-nav-icon-search';
setIcon(searchIcon, 'khoj-search');
// Create the search text
const searchText = searchLink.createEl('span');
@@ -378,38 +388,142 @@ export async function populateHeaderPane(headerEl: Element, setting: KhojSetting
searchLink.appendChild(searchIcon);
searchLink.appendChild(searchText);
// Create the search link
// Create the similar link
const similarLink = nav.createEl('a');
similarLink.id = 'similar-nav';
similarLink.className = 'khoj-nav similar-nav';
similarLink.dataset.view = KhojView.SIMILAR;
// Create the search icon
const similarIcon = searchLink.createEl('span');
// Create the similar icon
const similarIcon = similarLink.createEl('span');
similarIcon.id = 'similar-nav-icon';
similarIcon.className = 'khoj-nav-icon khoj-nav-icon-similar';
setIcon(similarIcon, 'webhook');
// Create the search text
const similarText = searchLink.createEl('span');
// Create the similar text
const similarText = similarLink.createEl('span');
similarText.className = 'khoj-nav-item-text';
similarText.textContent = 'Similar';
// Append the search icon and text to the search link
// Append the similar icon and text to the similar link
similarLink.appendChild(similarIcon);
similarLink.appendChild(similarText);
// Helper to get the current Khoj leaf if active
const getCurrentKhojLeaf = (): WorkspaceLeaf | undefined => {
const activeLeaf = this.app.workspace.activeLeaf;
if (activeLeaf && activeLeaf.view &&
(activeLeaf.view.getViewType() === KhojView.CHAT || activeLeaf.view.getViewType() === KhojView.SIMILAR)) {
return activeLeaf;
}
return undefined;
};
// Add event listeners to the navigation links
// Chat link event listener
chatLink.addEventListener('click', () => {
// Get the activateView method from the plugin instance
const khojPlugin = this.app.plugins.plugins.khoj;
khojPlugin?.activateView(KhojView.CHAT, getCurrentKhojLeaf());
});
// Search link event listener
searchLink.addEventListener('click', () => {
// Open the search modal
new KhojSearchModal(this.app, setting).open();
});
// Similar link event listener
similarLink.addEventListener('click', () => {
// Get the activateView method from the plugin instance
const khojPlugin = this.app.plugins.plugins.khoj;
khojPlugin?.activateView(KhojView.SIMILAR, getCurrentKhojLeaf());
});
// Append the nav items to the nav element
nav.appendChild(chatLink);
nav.appendChild(searchLink);
nav.appendChild(similarLink);
// Append the title, nav items to the header element
headerEl.appendChild(titleEl);
headerEl.appendChild(nav);
// Append the title and new chat container to the header element
headerEl.appendChild(titlePaneEl);
if (viewType === KhojView.CHAT) {
// Create subtitle pane for New Chat button and agent selector
const newChatEl = headerEl.createDiv("khoj-header-right-container");
// Add agent selector container
const agentContainer = newChatEl.createDiv("khoj-header-agent-container");
// Add agent selector
agentContainer.createEl("select", {
attr: {
class: "khoj-header-agent-select",
id: "khoj-header-agent-select"
}
});
// Add New Chat button
const newChatButton = newChatEl.createEl('button');
newChatButton.className = 'khoj-header-new-chat-button';
newChatButton.title = 'Start New Chat (Ctrl+Alt+N)';
setIcon(newChatButton, 'plus-circle');
newChatButton.textContent = 'New Chat';
// Add event listener to the New Chat button
newChatButton.addEventListener('click', () => {
const khojPlugin = this.app.plugins.plugins.khoj;
if (khojPlugin) {
// First activate the chat view
khojPlugin.activateView(KhojView.CHAT).then(() => {
// Then create a new conversation
setTimeout(() => {
// Access the chat view directly from the leaf after activation
const leaves = this.app.workspace.getLeavesOfType(KhojView.CHAT);
if (leaves.length > 0) {
const chatView = leaves[0].view;
if (chatView && typeof chatView.createNewConversation === 'function') {
chatView.createNewConversation();
}
}
}, 100);
});
}
});
// Append the new chat container to the header element
headerEl.appendChild(newChatEl);
}
// Update active state based on current view
const updateActiveState = () => {
const activeLeaf = this.app.workspace.activeLeaf;
if (!activeLeaf) return;
const viewType = activeLeaf.view?.getViewType();
// Remove active class from all links
chatLink.classList.remove('khoj-nav-selected');
similarLink.classList.remove('khoj-nav-selected');
// Add active class to the current view link
if (viewType === KhojView.CHAT) {
chatLink.classList.add('khoj-nav-selected');
} else if (viewType === KhojView.SIMILAR) {
similarLink.classList.add('khoj-nav-selected');
}
};
// Initial update
updateActiveState();
// Register event for workspace changes
this.app.workspace.on('active-leaf-change', updateActiveState);
}
export enum KhojView {
CHAT = "khoj-chat-view",
SIMILAR = "khoj-similar-view",
}
function copyParentText(event: MouseEvent, message: string, originalButton: string) {
@@ -425,7 +539,7 @@ function copyParentText(event: MouseEvent, message: string, originalButton: stri
}).catch((error) => {
console.error("Error copying text to clipboard:", error);
const originalButtonText = button.innerHTML;
button.innerHTML = "⛔️";
setIcon((button as HTMLElement), 'x-circle');
setTimeout(() => {
button.innerHTML = originalButtonText;
setIcon((button as HTMLElement), originalButton);
@@ -437,7 +551,13 @@ function copyParentText(event: MouseEvent, message: string, originalButton: stri
export function createCopyParentText(message: string, originalButton: string = 'copy-plus') {
return function (event: MouseEvent) {
return copyParentText(event, message, originalButton);
let markdownMessage = copyParentText(event, message, originalButton);
// Convert edit blocks back to markdown format before pasting
const editRegex = /<details class="khoj-edit-accordion">[\s\S]*?<pre><code class="language-khoj-edit">([\s\S]*?)<\/code><\/pre>[\s\S]*?<\/details>/g;
markdownMessage = markdownMessage?.replace(editRegex, (_, content) => {
return `<khoj-edit>\n${content}\n</khoj-edit>`;
});
return markdownMessage;
}
}
@@ -485,3 +605,76 @@ export function getLinkToEntry(sourceFiles: TFile[], chosenFile: string, chosenE
return linkToEntry;
}
}
export async function fetchChatModels(settings: KhojSetting): Promise<ModelOption[]> {
if (!settings.connectedToBackend || !settings.khojUrl) {
return [];
}
try {
const response = await fetch(`${settings.khojUrl}/api/model/chat/options`, {
method: 'GET',
headers: settings.khojApiKey ? { 'Authorization': `Bearer ${settings.khojApiKey}` } : {},
});
if (response.ok) {
const modelsData = await response.json();
if (Array.isArray(modelsData)) {
return modelsData.map((model: any) => ({
id: model.id.toString(),
name: model.name,
}));
}
} else {
console.warn("Khoj: Failed to fetch chat models:", response.statusText);
}
} catch (error) {
console.error("Khoj: Error fetching chat models:", error);
}
return [];
}
export async function fetchUserServerSettings(settings: KhojSetting): Promise<ServerUserConfig | null> {
if (!settings.connectedToBackend || !settings.khojUrl) {
return null;
}
try {
const response = await fetch(`${settings.khojUrl}/api/settings?detailed=true`, {
method: 'GET',
headers: settings.khojApiKey ? { 'Authorization': `Bearer ${settings.khojApiKey}` } : {},
});
if (response.ok) {
return await response.json() as ServerUserConfig;
} else {
console.warn("Khoj: Failed to fetch user server settings:", response.statusText);
}
} catch (error) {
console.error("Khoj: Error fetching user server settings:", error);
}
return null;
}
export async function updateServerChatModel(modelId: string, settings: KhojSetting): Promise<boolean> {
if (!settings.connectedToBackend || !settings.khojUrl) {
new Notice("️⛔️ Connect to Khoj to update chat model.");
return false;
}
try {
const response = await fetch(`${settings.khojUrl}/api/model/chat?id=${modelId}`, {
method: 'POST', // As per web app's updateModel function
headers: settings.khojApiKey ? { 'Authorization': `Bearer ${settings.khojApiKey}` } : {},
});
if (response.ok) {
settings.selectedChatModelId = modelId; // Update local mirror
return true;
} else {
const errorData = await response.text();
new Notice(`️⛔️ Failed to update chat model on server: ${response.status} ${errorData}`);
console.error("Khoj: Failed to update chat model:", response.status, errorData);
return false;
}
} catch (error) {
new Notice("️⛔️ Error updating chat model on server. See console.");
console.error("Khoj: Error updating chat model:", error);
return false;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -4,18 +4,19 @@
"inlineSourceMap": true,
"inlineSources": true,
"module": "ESNext",
"target": "ES6",
"target": "ES2018",
"allowJs": true,
"noImplicitAny": true,
"moduleResolution": "node",
"importHelpers": true,
"isolatedModules": true,
"strictNullChecks": true,
"strictNullChecks": true,
"lib": [
"DOM",
"ES5",
"ES6",
"ES7"
"ES7",
"ES2018"
]
},
"include": [

View File

@@ -2,17 +2,56 @@
# yarn lockfile v1
"@eslint-community/eslint-utils@^4.4.0":
version "4.4.0"
resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz#a23514e8fb9af1269d5f7788aa556798d61c6b59"
integrity sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==
"@asamuzakjp/css-color@^3.1.2":
version "3.2.0"
resolved "https://registry.yarnpkg.com/@asamuzakjp/css-color/-/css-color-3.2.0.tgz#cc42f5b85c593f79f1fa4f25d2b9b321e61d1794"
integrity sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==
dependencies:
eslint-visitor-keys "^3.3.0"
"@csstools/css-calc" "^2.1.3"
"@csstools/css-color-parser" "^3.0.9"
"@csstools/css-parser-algorithms" "^3.0.4"
"@csstools/css-tokenizer" "^3.0.3"
lru-cache "^10.4.3"
"@csstools/color-helpers@^5.0.2":
version "5.0.2"
resolved "https://registry.yarnpkg.com/@csstools/color-helpers/-/color-helpers-5.0.2.tgz#82592c9a7c2b83c293d9161894e2a6471feb97b8"
integrity sha512-JqWH1vsgdGcw2RR6VliXXdA0/59LttzlU8UlRT/iUUsEeWfYq8I+K0yhihEUTTHLRm1EXvpsCx3083EU15ecsA==
"@csstools/css-calc@^2.1.3":
version "2.1.3"
resolved "https://registry.yarnpkg.com/@csstools/css-calc/-/css-calc-2.1.3.tgz#6f68affcb569a86b91965e8622d644be35a08423"
integrity sha512-XBG3talrhid44BY1x3MHzUx/aTG8+x/Zi57M4aTKK9RFB4aLlF3TTSzfzn8nWVHWL3FgAXAxmupmDd6VWww+pw==
"@csstools/css-color-parser@^3.0.9":
version "3.0.9"
resolved "https://registry.yarnpkg.com/@csstools/css-color-parser/-/css-color-parser-3.0.9.tgz#8d81b77d6f211495b5100ec4cad4c8828de49f6b"
integrity sha512-wILs5Zk7BU86UArYBJTPy/FMPPKVKHMj1ycCEyf3VUptol0JNRLFU/BZsJ4aiIHJEbSLiizzRrw8Pc1uAEDrXw==
dependencies:
"@csstools/color-helpers" "^5.0.2"
"@csstools/css-calc" "^2.1.3"
"@csstools/css-parser-algorithms@^3.0.4":
version "3.0.4"
resolved "https://registry.yarnpkg.com/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.4.tgz#74426e93bd1c4dcab3e441f5cc7ba4fb35d94356"
integrity sha512-Up7rBoV77rv29d3uKHUIVubz1BTcgyUK72IvCQAbfbMv584xHcGKCKbWh7i8hPrRJ7qU4Y8IO3IY9m+iTB7P3A==
"@csstools/css-tokenizer@^3.0.3":
version "3.0.3"
resolved "https://registry.yarnpkg.com/@csstools/css-tokenizer/-/css-tokenizer-3.0.3.tgz#a5502c8539265fecbd873c1e395a890339f119c2"
integrity sha512-UJnjoFsmxfKUdNYdWgOB0mWUypuLvAfQPH1+pyvRJs6euowbFkFC6P13w1l8mJyi3vxYMxc9kld5jZEGRQs6bw==
"@eslint-community/eslint-utils@^4.4.0":
version "4.7.0"
resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz#607084630c6c033992a082de6e6fbc1a8b52175a"
integrity sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==
dependencies:
eslint-visitor-keys "^3.4.3"
"@eslint-community/regexpp@^4.10.0":
version "4.11.0"
resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.11.0.tgz#b0ffd0312b4a3fd2d6f77237e7248a5ad3a680ae"
integrity sha512-G/M/tIiMrTAxEWRfLfQJMmGNX28IxBg4PBz8XqQhqUHLFI6TL2htpIB1iQCj144V5ee/JaKyT9/WZ0MGZWfA7A==
version "4.12.1"
resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.12.1.tgz#cfc6cffe39df390a3841cde2abccf92eaa7ae0e0"
integrity sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==
"@nodelib/fs.scandir@2.1.5":
version "2.1.5"
@@ -42,22 +81,22 @@
dependencies:
"@types/tern" "*"
"@types/dompurify@^3.0.5":
version "3.0.5"
resolved "https://registry.yarnpkg.com/@types/dompurify/-/dompurify-3.0.5.tgz#02069a2fcb89a163bacf1a788f73cb415dd75cb7"
integrity sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==
"@types/dompurify@3.2.0":
version "3.2.0"
resolved "https://registry.yarnpkg.com/@types/dompurify/-/dompurify-3.2.0.tgz#56610bf3e4250df57744d61fbd95422e07dfb840"
integrity sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg==
dependencies:
"@types/trusted-types" "*"
dompurify "*"
"@types/estree@*":
version "1.0.5"
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.5.tgz#a6ce3e556e00fd9895dd872dd172ad0d4bd687f4"
integrity sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==
version "1.0.7"
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.7.tgz#4158d3105276773d5b7695cd4834b1722e4f37a8"
integrity sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==
"@types/node@^16.11.6":
version "16.18.108"
resolved "https://registry.yarnpkg.com/@types/node/-/node-16.18.108.tgz#b794e2b2a85b4c12935ea7d0f18641be68b352f9"
integrity sha512-fj42LD82fSv6yN9C6Q4dzS+hujHj+pTv0IpRR3kI20fnYeS0ytBpjFO9OjmDowSPPt4lNKN46JLaKbCyP+BW2A==
version "16.18.126"
resolved "https://registry.yarnpkg.com/@types/node/-/node-16.18.126.tgz#27875faa2926c0f475b39a8bb1e546c0176f8d4b"
integrity sha512-OTcgaiwfGFBKacvfwuHzzn1KLxH/er8mluiy8/uM3sGXHaRe73RrSIj01jow9t4kJEW633Ov+cOexXeiApTyAw==
"@types/tern@*":
version "0.23.9"
@@ -66,7 +105,7 @@
dependencies:
"@types/estree" "*"
"@types/trusted-types@*":
"@types/trusted-types@^2.0.7":
version "2.0.7"
resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.7.tgz#baccb07a970b91707df3a3e8ba6896c57ead2d11"
integrity sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==
@@ -152,6 +191,11 @@
"@typescript-eslint/types" "7.13.1"
eslint-visitor-keys "^3.4.3"
agent-base@^7.1.0, agent-base@^7.1.2:
version "7.1.3"
resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-7.1.3.tgz#29435eb821bc4194633a5b89e5bc4703bafc25a1"
integrity sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==
array-union@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/array-union/-/array-union-2.1.0.tgz#b798420adbeb1de828d84acd8a2e23d3efe85e8d"
@@ -181,12 +225,38 @@ builtin-modules@3.3.0:
resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-3.3.0.tgz#cae62812b89801e9656336e46223e030386be7b6"
integrity sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==
debug@^4.3.4:
version "4.3.6"
resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.6.tgz#2ab2c38fbaffebf8aa95fdfe6d88438c7a13c52b"
integrity sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==
cssstyle@^4.2.1:
version "4.3.1"
resolved "https://registry.yarnpkg.com/cssstyle/-/cssstyle-4.3.1.tgz#68a3c9f5a70aa97d5a6ebecc9805e511fc022eb8"
integrity sha512-ZgW+Jgdd7i52AaLYCriF8Mxqft0gD/R9i9wi6RWBhs1pqdPEzPjym7rvRKi397WmQFf3SlyUsszhw+VVCbx79Q==
dependencies:
ms "2.1.2"
"@asamuzakjp/css-color" "^3.1.2"
rrweb-cssom "^0.8.0"
data-urls@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/data-urls/-/data-urls-5.0.0.tgz#2f76906bce1824429ffecb6920f45a0b30f00dde"
integrity sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==
dependencies:
whatwg-mimetype "^4.0.0"
whatwg-url "^14.0.0"
debug@4, debug@^4.3.4:
version "4.4.1"
resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.1.tgz#e5a8bc6cbc4c6cd3e64308b0693a3d4fa550189b"
integrity sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==
dependencies:
ms "^2.1.3"
decimal.js@^10.5.0:
version "10.5.0"
resolved "https://registry.yarnpkg.com/decimal.js/-/decimal.js-10.5.0.tgz#0f371c7cf6c4898ce0afb09836db73cd82010f22"
integrity sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw==
diff@^8.0.2:
version "8.0.2"
resolved "https://registry.yarnpkg.com/diff/-/diff-8.0.2.tgz#712156a6dd288e66ebb986864e190c2fc9eddfae"
integrity sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg==
dir-glob@^3.0.1:
version "3.0.1"
@@ -195,10 +265,17 @@ dir-glob@^3.0.1:
dependencies:
path-type "^4.0.0"
dompurify@^3.1.4:
version "3.1.6"
resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-3.1.6.tgz#43c714a94c6a7b8801850f82e756685300a027e2"
integrity sha512-cTOAhc36AalkjtBpfG6O8JimdTMWNXjiePT2xQH/ppBGi/4uIpmj8eKyIkMJErXWARyINV/sB38yf8JCLF5pbQ==
dompurify@*, dompurify@^3.2.6:
version "3.2.6"
resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-3.2.6.tgz#ca040a6ad2b88e2a92dc45f38c79f84a714a1cad"
integrity sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ==
optionalDependencies:
"@types/trusted-types" "^2.0.7"
entities@^6.0.0:
version "6.0.0"
resolved "https://registry.yarnpkg.com/entities/-/entities-6.0.0.tgz#09c9e29cb79b0a6459a9b9db9efb418ac5bb8e51"
integrity sha512-aKstq2TDOndCn4diEyp9Uq/Flu2i1GlLkc6XIDQSDMuaFE3OPW5OphLCyQ5SpSJZTb4reN+kTcYru5yIfXoRPw==
esbuild-android-64@0.14.47:
version "0.14.47"
@@ -326,26 +403,26 @@ esbuild@0.14.47:
esbuild-windows-64 "0.14.47"
esbuild-windows-arm64 "0.14.47"
eslint-visitor-keys@^3.3.0, eslint-visitor-keys@^3.4.3:
eslint-visitor-keys@^3.4.3:
version "3.4.3"
resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz#0cd72fe8550e3c2eae156a96a4dddcd1c8ac5800"
integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==
fast-glob@^3.2.9:
version "3.3.2"
resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.3.2.tgz#a904501e57cfdd2ffcded45e99a54fef55e46129"
integrity sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==
version "3.3.3"
resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.3.3.tgz#d06d585ce8dba90a16b0505c543c3ccfb3aeb818"
integrity sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==
dependencies:
"@nodelib/fs.stat" "^2.0.2"
"@nodelib/fs.walk" "^1.2.3"
glob-parent "^5.1.2"
merge2 "^1.3.0"
micromatch "^4.0.4"
micromatch "^4.0.8"
fastq@^1.6.0:
version "1.17.1"
resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.17.1.tgz#2a523f07a4e7b1e81a42b91b8bf2254107753b47"
integrity sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==
version "1.19.1"
resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.19.1.tgz#d50eaba803c8846a883c16492821ebcd2cda55f5"
integrity sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==
dependencies:
reusify "^1.0.4"
@@ -380,6 +457,36 @@ graphemer@^1.4.0:
resolved "https://registry.yarnpkg.com/graphemer/-/graphemer-1.4.0.tgz#fb2f1d55e0e3a1849aeffc90c4fa0dd53a0e66c6"
integrity sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==
html-encoding-sniffer@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz#696df529a7cfd82446369dc5193e590a3735b448"
integrity sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==
dependencies:
whatwg-encoding "^3.1.1"
http-proxy-agent@^7.0.2:
version "7.0.2"
resolved "https://registry.yarnpkg.com/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz#9a8b1f246866c028509486585f62b8f2c18c270e"
integrity sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==
dependencies:
agent-base "^7.1.0"
debug "^4.3.4"
https-proxy-agent@^7.0.6:
version "7.0.6"
resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz#da8dfeac7da130b05c2ba4b59c9b6cd66611a6b9"
integrity sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==
dependencies:
agent-base "^7.1.2"
debug "4"
iconv-lite@0.6.3:
version "0.6.3"
resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.6.3.tgz#a52f80bf38da1952eb5c681790719871a1a72501"
integrity sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==
dependencies:
safer-buffer ">= 2.1.2 < 3.0.0"
ignore@^5.2.0, ignore@^5.3.1:
version "5.3.2"
resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.2.tgz#3cd40e729f3643fd87cb04e50bf0eb722bc596f5"
@@ -402,12 +509,56 @@ is-number@^7.0.0:
resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b"
integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==
is-potential-custom-element-name@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz#171ed6f19e3ac554394edf78caa05784a45bebb5"
integrity sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==
isomorphic-dompurify@^2.25.0:
version "2.25.0"
resolved "https://registry.yarnpkg.com/isomorphic-dompurify/-/isomorphic-dompurify-2.25.0.tgz#063e3ea7399bc1146783a9527be6c10baa25dc15"
integrity sha512-bcpJzu9DOjN21qaCVpcoCwUX1ytpvA6EFqCK5RNtPg5+F0Jz9PX50jl6jbEicBNeO87eDDfC7XtPs4zjDClZJg==
dependencies:
dompurify "^3.2.6"
jsdom "^26.1.0"
jsdom@^26.1.0:
version "26.1.0"
resolved "https://registry.yarnpkg.com/jsdom/-/jsdom-26.1.0.tgz#ab5f1c1cafc04bd878725490974ea5e8bf0c72b3"
integrity sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==
dependencies:
cssstyle "^4.2.1"
data-urls "^5.0.0"
decimal.js "^10.5.0"
html-encoding-sniffer "^4.0.0"
http-proxy-agent "^7.0.2"
https-proxy-agent "^7.0.6"
is-potential-custom-element-name "^1.0.1"
nwsapi "^2.2.16"
parse5 "^7.2.1"
rrweb-cssom "^0.8.0"
saxes "^6.0.0"
symbol-tree "^3.2.4"
tough-cookie "^5.1.1"
w3c-xmlserializer "^5.0.0"
webidl-conversions "^7.0.0"
whatwg-encoding "^3.1.1"
whatwg-mimetype "^4.0.0"
whatwg-url "^14.1.1"
ws "^8.18.0"
xml-name-validator "^5.0.0"
lru-cache@^10.4.3:
version "10.4.3"
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.4.3.tgz#410fc8a17b70e598013df257c2446b7f3383f119"
integrity sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==
merge2@^1.3.0, merge2@^1.4.1:
version "1.4.1"
resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae"
integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==
micromatch@^4.0.4:
micromatch@^4.0.8:
version "4.0.8"
resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.8.tgz#d66fa18f3a47076789320b9b1af32bd86d9fa202"
integrity sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==
@@ -427,24 +578,36 @@ moment@2.29.4:
resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.4.tgz#3dbe052889fe7c1b2ed966fcb3a77328964ef108"
integrity sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==
ms@2.1.2:
version "2.1.2"
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009"
integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==
ms@^2.1.3:
version "2.1.3"
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2"
integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==
natural-compare@^1.4.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7"
integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==
nwsapi@^2.2.16:
version "2.2.20"
resolved "https://registry.yarnpkg.com/nwsapi/-/nwsapi-2.2.20.tgz#22e53253c61e7b0e7e93cef42c891154bcca11ef"
integrity sha512-/ieB+mDe4MrrKMT8z+mQL8klXydZWGR5Dowt4RAGKbJ3kIGEx3X4ljUo+6V73IXtUPWgfOlU5B9MlGxFO5T+cA==
obsidian@^1.6.6:
version "1.6.6"
resolved "https://registry.yarnpkg.com/obsidian/-/obsidian-1.6.6.tgz#d45c4021c291765e1b77ed4a1c645e562ff6e77f"
integrity sha512-GZHzeOiwmw/wBjB5JwrsxAZBLqxGQmqtEKSvJJvT0LtTcqeOFnV8jv0ZK5kO7hBb44WxJc+LdS7mZgLXbb+qXQ==
version "1.8.7"
resolved "https://registry.yarnpkg.com/obsidian/-/obsidian-1.8.7.tgz#601e9ea1724289effa4c9bb3b4e20d327263634f"
integrity sha512-h4bWwNFAGRXlMlMAzdEiIM2ppTGlrh7uGOJS6w4gClrsjc+ei/3YAtU2VdFUlCiPuTHpY4aBpFJJW75S1Tl/JA==
dependencies:
"@types/codemirror" "5.60.8"
moment "2.29.4"
parse5@^7.2.1:
version "7.3.0"
resolved "https://registry.yarnpkg.com/parse5/-/parse5-7.3.0.tgz#d7e224fa72399c7a175099f45fc2ad024b05ec05"
integrity sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==
dependencies:
entities "^6.0.0"
path-type@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b"
@@ -455,15 +618,25 @@ picomatch@^2.3.1:
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42"
integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==
punycode@^2.3.1:
version "2.3.1"
resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5"
integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==
queue-microtask@^1.2.2:
version "1.2.3"
resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243"
integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==
reusify@^1.0.4:
version "1.0.4"
resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76"
integrity sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==
version "1.1.0"
resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.1.0.tgz#0fe13b9522e1473f51b558ee796e08f11f9b489f"
integrity sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==
rrweb-cssom@^0.8.0:
version "0.8.0"
resolved "https://registry.yarnpkg.com/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz#3021d1b4352fbf3b614aaeed0bc0d5739abe0bc2"
integrity sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==
run-parallel@^1.1.9:
version "1.2.0"
@@ -472,16 +645,45 @@ run-parallel@^1.1.9:
dependencies:
queue-microtask "^1.2.2"
"safer-buffer@>= 2.1.2 < 3.0.0":
version "2.1.2"
resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a"
integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==
saxes@^6.0.0:
version "6.0.0"
resolved "https://registry.yarnpkg.com/saxes/-/saxes-6.0.0.tgz#fe5b4a4768df4f14a201b1ba6a65c1f3d9988cc5"
integrity sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==
dependencies:
xmlchars "^2.2.0"
semver@^7.6.0:
version "7.6.3"
resolved "https://registry.yarnpkg.com/semver/-/semver-7.6.3.tgz#980f7b5550bc175fb4dc09403085627f9eb33143"
integrity sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==
version "7.7.2"
resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.2.tgz#67d99fdcd35cec21e6f8b87a7fd515a33f982b58"
integrity sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==
slash@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634"
integrity sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==
symbol-tree@^3.2.4:
version "3.2.4"
resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2"
integrity sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==
tldts-core@^6.1.86:
version "6.1.86"
resolved "https://registry.yarnpkg.com/tldts-core/-/tldts-core-6.1.86.tgz#a93e6ed9d505cb54c542ce43feb14c73913265d8"
integrity sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==
tldts@^6.1.32:
version "6.1.86"
resolved "https://registry.yarnpkg.com/tldts/-/tldts-6.1.86.tgz#087e0555b31b9725ee48ca7e77edc56115cd82f7"
integrity sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==
dependencies:
tldts-core "^6.1.86"
to-regex-range@^5.0.1:
version "5.0.1"
resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4"
@@ -489,10 +691,24 @@ to-regex-range@^5.0.1:
dependencies:
is-number "^7.0.0"
tough-cookie@^5.1.1:
version "5.1.2"
resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-5.1.2.tgz#66d774b4a1d9e12dc75089725af3ac75ec31bed7"
integrity sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==
dependencies:
tldts "^6.1.32"
tr46@^5.1.0:
version "5.1.1"
resolved "https://registry.yarnpkg.com/tr46/-/tr46-5.1.1.tgz#96ae867cddb8fdb64a49cc3059a8d428bcf238ca"
integrity sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==
dependencies:
punycode "^2.3.1"
ts-api-utils@^1.3.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-1.3.0.tgz#4b490e27129f1e8e686b45cc4ab63714dc60eea1"
integrity sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ==
version "1.4.3"
resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-1.4.3.tgz#bfc2215fe6528fecab2b0fba570a2e8a4263b064"
integrity sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==
tslib@2.4.0:
version "2.4.0"
@@ -503,3 +719,50 @@ typescript@4.7.4:
version "4.7.4"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.7.4.tgz#1a88596d1cf47d59507a1bcdfb5b9dfe4d488235"
integrity sha512-C0WQT0gezHuw6AdY1M2jxUO83Rjf0HP7Sk1DtXj6j1EwkQNZrHAg2XPWlq62oqEhYvONq5pkC2Y9oPljWToLmQ==
w3c-xmlserializer@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz#f925ba26855158594d907313cedd1476c5967f6c"
integrity sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==
dependencies:
xml-name-validator "^5.0.0"
webidl-conversions@^7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-7.0.0.tgz#256b4e1882be7debbf01d05f0aa2039778ea080a"
integrity sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==
whatwg-encoding@^3.1.1:
version "3.1.1"
resolved "https://registry.yarnpkg.com/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz#d0f4ef769905d426e1688f3e34381a99b60b76e5"
integrity sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==
dependencies:
iconv-lite "0.6.3"
whatwg-mimetype@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz#bc1bf94a985dc50388d54a9258ac405c3ca2fc0a"
integrity sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==
whatwg-url@^14.0.0, whatwg-url@^14.1.1:
version "14.2.0"
resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-14.2.0.tgz#4ee02d5d725155dae004f6ae95c73e7ef5d95663"
integrity sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==
dependencies:
tr46 "^5.1.0"
webidl-conversions "^7.0.0"
ws@^8.18.0:
version "8.18.2"
resolved "https://registry.yarnpkg.com/ws/-/ws-8.18.2.tgz#42738b2be57ced85f46154320aabb51ab003705a"
integrity sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ==
xml-name-validator@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-5.0.0.tgz#82be9b957f7afdacf961e5980f1bf227c0bf7673"
integrity sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==
xmlchars@^2.2.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/xmlchars/-/xmlchars-2.2.0.tgz#060fe1bcb7f9c76fe2a17db86a9bc3ab894210cb"
integrity sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==