feat: Add Omnisearch support for regex search
This commit introduces the option to use Omnisearch as a backend for the regex search functionality, in addition to the existing ripgrep backend.
This commit is contained in:
parent
8915b84b04
commit
350a49cef9
@ -29,7 +29,8 @@ import {
|
|||||||
LLMBaseUrlNotSetException,
|
LLMBaseUrlNotSetException,
|
||||||
LLMModelNotSetException,
|
LLMModelNotSetException,
|
||||||
} from '../../core/llm/exception'
|
} from '../../core/llm/exception'
|
||||||
import { regexSearchFiles } from '../../core/ripgrep'
|
import { regexSearchFilesWithRipgrep } from '../../core/regex/ripgrep-index'
|
||||||
|
import { regexSearchFilesWithOmnisearch } from '../../core/regex/omnisearch-index'
|
||||||
import { useChatHistory } from '../../hooks/use-chat-history'
|
import { useChatHistory } from '../../hooks/use-chat-history'
|
||||||
import { useCustomModes } from '../../hooks/use-custom-mode'
|
import { useCustomModes } from '../../hooks/use-custom-mode'
|
||||||
import { t } from '../../lang/helpers'
|
import { t } from '../../lang/helpers'
|
||||||
@ -609,10 +610,16 @@ const Chat = forwardRef<ChatRef, ChatProps>((props, ref) => {
|
|||||||
}
|
}
|
||||||
} else if (toolArgs.type === 'regex_search_files') {
|
} else if (toolArgs.type === 'regex_search_files') {
|
||||||
// @ts-expect-error Obsidian API type mismatch
|
// @ts-expect-error Obsidian API type mismatch
|
||||||
|
const searchBackend = settings.regexSearchBackend
|
||||||
const baseVaultPath = String(app.vault.adapter.getBasePath())
|
const baseVaultPath = String(app.vault.adapter.getBasePath())
|
||||||
const ripgrepPath = settings.ripgrepPath
|
|
||||||
const absolutePath = path.join(baseVaultPath, toolArgs.filepath)
|
const absolutePath = path.join(baseVaultPath, toolArgs.filepath)
|
||||||
const results = await regexSearchFiles(absolutePath, toolArgs.regex, ripgrepPath)
|
let results: string;
|
||||||
|
if (searchBackend === 'omnisearch') {
|
||||||
|
results = await regexSearchFilesWithOmnisearch(absolutePath, toolArgs.regex, app)
|
||||||
|
} else {
|
||||||
|
const ripgrepPath = settings.ripgrepPath
|
||||||
|
results = await regexSearchFilesWithRipgrep(absolutePath, toolArgs.regex, ripgrepPath)
|
||||||
|
}
|
||||||
const formattedContent = `[regex_search_files for '${toolArgs.filepath}'] Result:\n${results}\n`;
|
const formattedContent = `[regex_search_files for '${toolArgs.filepath}'] Result:\n${results}\n`;
|
||||||
return {
|
return {
|
||||||
type: 'regex_search_files',
|
type: 'regex_search_files',
|
||||||
|
|||||||
140
src/core/regex/omnisearch-index.ts
Normal file
140
src/core/regex/omnisearch-index.ts
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
import { App } from "obsidian";
|
||||||
|
import {
|
||||||
|
MAX_RESULTS,
|
||||||
|
truncateLine,
|
||||||
|
SearchResult,
|
||||||
|
formatResults,
|
||||||
|
} from './regex-common';
|
||||||
|
|
||||||
|
// --- Omnisearch API and Helper Types ---
|
||||||
|
|
||||||
|
type SearchMatchApi = {
|
||||||
|
match: string;
|
||||||
|
offset: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ResultNoteApi = {
|
||||||
|
score: number;
|
||||||
|
vault: string;
|
||||||
|
path: string;
|
||||||
|
basename: string;
|
||||||
|
foundWords: string[];
|
||||||
|
matches: SearchMatchApi[];
|
||||||
|
excerpt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type OmnisearchApi = {
|
||||||
|
search: (query: string) => Promise<ResultNoteApi[]>;
|
||||||
|
// ... other API methods
|
||||||
|
};
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
omnisearch: OmnisearchApi;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the Omnisearch plugin's API is available.
|
||||||
|
* @returns {boolean} True if the API is ready, false otherwise.
|
||||||
|
*/
|
||||||
|
function isOmnisearchAvailable(): boolean {
|
||||||
|
return window.omnisearch && typeof window.omnisearch.search === "function";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds the line number, column number, and content for a given character offset in a file.
|
||||||
|
* @param allLines All lines in the file.
|
||||||
|
* @param offset The character offset of the match.
|
||||||
|
* @returns An object with line number, column number, and the full line content.
|
||||||
|
*/
|
||||||
|
function findLineAndColumnFromOffset(
|
||||||
|
allLines: string[],
|
||||||
|
offset: number
|
||||||
|
): { lineNumber: number; columnNumber: number; lineContent: string } {
|
||||||
|
let charCount = 0;
|
||||||
|
for (let i = 0; i < allLines.length; i++) {
|
||||||
|
const line = allLines[i];
|
||||||
|
// The line ending length (1 for \n, 2 for \r\n) can vary.
|
||||||
|
// A simple +1 is a reasonable approximation for this calculation.
|
||||||
|
const lineEndOffset = charCount + line.length + 1;
|
||||||
|
|
||||||
|
if (offset < lineEndOffset) {
|
||||||
|
const columnNumber = offset - charCount;
|
||||||
|
return { lineNumber: i, columnNumber, lineContent: line };
|
||||||
|
}
|
||||||
|
charCount = lineEndOffset;
|
||||||
|
}
|
||||||
|
return { lineNumber: -1, columnNumber: -1, lineContent: "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Searches using Omnisearch and builds context for each match to replicate ripgrep's output.
|
||||||
|
* @param vaultPath The absolute path of the vault for making relative paths.
|
||||||
|
* @param query The search query for Omnisearch. Note: Omnisearch does not support full regex.
|
||||||
|
* @param app The Obsidian App instance.
|
||||||
|
* @returns A formatted string of search results.
|
||||||
|
*/
|
||||||
|
export async function regexSearchFilesWithOmnisearch(
|
||||||
|
vaultPath: string,
|
||||||
|
query: string,
|
||||||
|
app: App,
|
||||||
|
): Promise<string> {
|
||||||
|
try {
|
||||||
|
if (!isOmnisearchAvailable()) {
|
||||||
|
throw new Error(
|
||||||
|
"Omnisearch plugin not found or not active. Please install and enable it to use this search feature."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Omnisearch is not a regex engine. The function name is kept for consistency
|
||||||
|
// but the `query` will be treated as a keyword/fuzzy search by the plugin.
|
||||||
|
const apiResults = await window.omnisearch.search(query);
|
||||||
|
if (!apiResults || apiResults.length === 0) {
|
||||||
|
throw new Error("No results found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const results: SearchResult[] = [];
|
||||||
|
|
||||||
|
for (const result of apiResults) {
|
||||||
|
if (results.length >= MAX_RESULTS) {
|
||||||
|
break; // Stop processing new files if we have enough results
|
||||||
|
}
|
||||||
|
if (!result.matches || result.matches.length === 0) continue;
|
||||||
|
|
||||||
|
const fileContent = await app.vault.adapter.read(result.path);
|
||||||
|
const allLines = fileContent.split("\n");
|
||||||
|
|
||||||
|
for (const match of result.matches) {
|
||||||
|
if (results.length >= MAX_RESULTS) {
|
||||||
|
break; // Stop processing matches if we have enough results
|
||||||
|
}
|
||||||
|
|
||||||
|
const { lineNumber, columnNumber, lineContent } = findLineAndColumnFromOffset(
|
||||||
|
allLines,
|
||||||
|
match.offset
|
||||||
|
);
|
||||||
|
|
||||||
|
if (lineNumber === -1) continue;
|
||||||
|
|
||||||
|
const searchResult: SearchResult = {
|
||||||
|
file: result.path,
|
||||||
|
line: lineNumber + 1, // ripgrep is 1-based, so we adjust
|
||||||
|
column: columnNumber + 1,
|
||||||
|
match: truncateLine(lineContent.trimEnd()),
|
||||||
|
beforeContext: lineNumber > 0 ? [truncateLine(allLines[lineNumber - 1].trimEnd())] : [],
|
||||||
|
afterContext:
|
||||||
|
lineNumber < allLines.length - 1
|
||||||
|
? [truncateLine(allLines[lineNumber + 1].trimEnd())]
|
||||||
|
: [],
|
||||||
|
};
|
||||||
|
results.push(searchResult);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatResults(results, vaultPath);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error during Omnisearch processing:", error);
|
||||||
|
return "An error occurred during the search.";
|
||||||
|
}
|
||||||
|
}
|
||||||
63
src/core/regex/regex-common.ts
Normal file
63
src/core/regex/regex-common.ts
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import * as path from "path"
|
||||||
|
|
||||||
|
// Constants
|
||||||
|
export const MAX_RESULTS = 300
|
||||||
|
export const MAX_LINE_LENGTH = 500
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Truncates a line if it exceeds the maximum length
|
||||||
|
* @param line The line to truncate
|
||||||
|
* @param maxLength The maximum allowed length (defaults to MAX_LINE_LENGTH)
|
||||||
|
* @returns The truncated line, or the original line if it's shorter than maxLength
|
||||||
|
*/
|
||||||
|
export function truncateLine(line: string, maxLength: number = MAX_LINE_LENGTH): string {
|
||||||
|
return line.length > maxLength ? line.substring(0, maxLength) + " [truncated...]" : line
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchResult {
|
||||||
|
file: string
|
||||||
|
line: number
|
||||||
|
column?: number
|
||||||
|
match?: string
|
||||||
|
beforeContext: string[]
|
||||||
|
afterContext: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatResults(results: SearchResult[], cwd: string): string {
|
||||||
|
const groupedResults: { [key: string]: SearchResult[] } = {}
|
||||||
|
|
||||||
|
let output = ""
|
||||||
|
if (results.length >= MAX_RESULTS) {
|
||||||
|
output += `Showing first ${MAX_RESULTS} of ${MAX_RESULTS}+ results. Use a more specific search if necessary.\n\n`
|
||||||
|
} else {
|
||||||
|
output += `Found ${results.length === 1 ? "1 result" : `${results.length.toLocaleString()} results`}.\n\n`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group results by file name
|
||||||
|
results.slice(0, MAX_RESULTS).forEach((result) => {
|
||||||
|
const relativeFilePath = path.relative(cwd, result.file)
|
||||||
|
if (!groupedResults[relativeFilePath]) {
|
||||||
|
groupedResults[relativeFilePath] = []
|
||||||
|
}
|
||||||
|
groupedResults[relativeFilePath].push(result)
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const [filePath, fileResults] of Object.entries(groupedResults)) {
|
||||||
|
output += `${filePath.toPosix()}\n│----\n`
|
||||||
|
|
||||||
|
fileResults.forEach((result, index) => {
|
||||||
|
const allLines = [...result.beforeContext, result.match, ...result.afterContext]
|
||||||
|
allLines.forEach((line) => {
|
||||||
|
output += `│${line?.trimEnd() ?? ""}\n`
|
||||||
|
})
|
||||||
|
|
||||||
|
if (index < fileResults.length - 1) {
|
||||||
|
output += "│----\n"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
output += "│----\n\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
return output.trim()
|
||||||
|
}
|
||||||
@ -3,33 +3,16 @@ import * as childProcess from "child_process"
|
|||||||
import * as fs from "fs"
|
import * as fs from "fs"
|
||||||
import * as path from "path"
|
import * as path from "path"
|
||||||
import * as readline from "readline"
|
import * as readline from "readline"
|
||||||
|
import {
|
||||||
|
MAX_RESULTS,
|
||||||
|
truncateLine,
|
||||||
|
SearchResult,
|
||||||
|
formatResults
|
||||||
|
} from './regex-common'
|
||||||
|
|
||||||
const isWindows = /^win/.test(process.platform)
|
const isWindows = /^win/.test(process.platform)
|
||||||
const binName = isWindows ? "rg.exe" : "rg"
|
const binName = isWindows ? "rg.exe" : "rg"
|
||||||
|
|
||||||
interface SearchResult {
|
|
||||||
file: string
|
|
||||||
line: number
|
|
||||||
column: number
|
|
||||||
match: string
|
|
||||||
beforeContext: string[]
|
|
||||||
afterContext: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Constants
|
|
||||||
const MAX_RESULTS = 300
|
|
||||||
const MAX_LINE_LENGTH = 500
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Truncates a line if it exceeds the maximum length
|
|
||||||
* @param line The line to truncate
|
|
||||||
* @param maxLength The maximum allowed length (defaults to MAX_LINE_LENGTH)
|
|
||||||
* @returns The truncated line, or the original line if it's shorter than maxLength
|
|
||||||
*/
|
|
||||||
export function truncateLine(line: string, maxLength: number = MAX_LINE_LENGTH): string {
|
|
||||||
return line.length > maxLength ? line.substring(0, maxLength) + " [truncated...]" : line
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getBinPath(ripgrepPath: string): Promise<string | undefined> {
|
async function getBinPath(ripgrepPath: string): Promise<string | undefined> {
|
||||||
const binPath = path.join(ripgrepPath, binName)
|
const binPath = path.join(ripgrepPath, binName)
|
||||||
return (await pathExists(binPath)) ? binPath : undefined
|
return (await pathExists(binPath)) ? binPath : undefined
|
||||||
@ -83,7 +66,7 @@ async function execRipgrep(bin: string, args: string[]): Promise<string> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function regexSearchFiles(
|
export async function regexSearchFilesWithRipgrep(
|
||||||
directoryPath: string,
|
directoryPath: string,
|
||||||
regex: string,
|
regex: string,
|
||||||
ripgrepPath: string,
|
ripgrepPath: string,
|
||||||
@ -162,42 +145,3 @@ export async function regexSearchFiles(
|
|||||||
|
|
||||||
return formatResults(results, directoryPath)
|
return formatResults(results, directoryPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatResults(results: SearchResult[], cwd: string): string {
|
|
||||||
const groupedResults: { [key: string]: SearchResult[] } = {}
|
|
||||||
|
|
||||||
let output = ""
|
|
||||||
if (results.length >= MAX_RESULTS) {
|
|
||||||
output += `Showing first ${MAX_RESULTS} of ${MAX_RESULTS}+ results. Use a more specific search if necessary.\n\n`
|
|
||||||
} else {
|
|
||||||
output += `Found ${results.length === 1 ? "1 result" : `${results.length.toLocaleString()} results`}.\n\n`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Group results by file name
|
|
||||||
results.slice(0, MAX_RESULTS).forEach((result) => {
|
|
||||||
const relativeFilePath = path.relative(cwd, result.file)
|
|
||||||
if (!groupedResults[relativeFilePath]) {
|
|
||||||
groupedResults[relativeFilePath] = []
|
|
||||||
}
|
|
||||||
groupedResults[relativeFilePath].push(result)
|
|
||||||
})
|
|
||||||
|
|
||||||
for (const [filePath, fileResults] of Object.entries(groupedResults)) {
|
|
||||||
output += `${filePath.toPosix()}\n│----\n`
|
|
||||||
|
|
||||||
fileResults.forEach((result, index) => {
|
|
||||||
const allLines = [...result.beforeContext, result.match, ...result.afterContext]
|
|
||||||
allLines.forEach((line) => {
|
|
||||||
output += `│${line?.trimEnd() ?? ""}\n`
|
|
||||||
})
|
|
||||||
|
|
||||||
if (index < fileResults.length - 1) {
|
|
||||||
output += "│----\n"
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
output += "│----\n\n"
|
|
||||||
}
|
|
||||||
|
|
||||||
return output.trim()
|
|
||||||
}
|
|
||||||
@ -227,8 +227,12 @@ export default {
|
|||||||
auto: 'Auto',
|
auto: 'Auto',
|
||||||
semantic: 'Semantic',
|
semantic: 'Semantic',
|
||||||
regex: 'Regex',
|
regex: 'Regex',
|
||||||
|
regexBackend: 'Regex search backend',
|
||||||
|
regexBackendDescription: 'Choose the backend for regex search method.',
|
||||||
|
ripgrep: 'ripgrep',
|
||||||
|
omnisearch: 'Omnisearch',
|
||||||
ripgrepPath: 'ripgrep path',
|
ripgrepPath: 'ripgrep path',
|
||||||
ripgrepPathDescription: 'Path to the ripgrep binary. When using regex search, this is required.',
|
ripgrepPathDescription: 'Path to the ripgrep binary. When using ripgrep regex search, this is required.',
|
||||||
},
|
},
|
||||||
|
|
||||||
// Chat Behavior Section
|
// Chat Behavior Section
|
||||||
|
|||||||
@ -228,8 +228,12 @@ export default {
|
|||||||
auto: '自动',
|
auto: '自动',
|
||||||
semantic: '语义',
|
semantic: '语义',
|
||||||
regex: '正则',
|
regex: '正则',
|
||||||
|
regexBackend: '正则搜索后端',
|
||||||
|
regexBackendDescription: '选择正则搜索的后端。',
|
||||||
|
ripgrep: 'ripgrep',
|
||||||
|
omnisearch: 'Omnisearch',
|
||||||
ripgrepPath: 'ripgrep 路径',
|
ripgrepPath: 'ripgrep 路径',
|
||||||
ripgrepPathDescription: 'ripgrep 二进制文件的路径。使用正则搜索时需要此项。',
|
ripgrepPathDescription: 'ripgrep 二进制文件的路径。使用 ripgrep 正则搜索时需要此项。',
|
||||||
},
|
},
|
||||||
|
|
||||||
// 聊天行为部分
|
// 聊天行为部分
|
||||||
|
|||||||
@ -163,6 +163,21 @@ export class InfioSettingTab extends PluginSettingTab {
|
|||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
new Setting(containerEl)
|
||||||
|
.setName(t('settings.FilesSearch.regexBackend'))
|
||||||
|
.setDesc(t('settings.FilesSearch.regexBackendDescription'))
|
||||||
|
.addDropdown((dropdown) =>
|
||||||
|
dropdown
|
||||||
|
.addOption('ripgrep', t('settings.FilesSearch.ripgrep'))
|
||||||
|
.addOption('omnisearch', t('settings.FilesSearch.omnisearch'))
|
||||||
|
.setValue(this.plugin.settings.regexSearchBackend)
|
||||||
|
.onChange(async (value) => {
|
||||||
|
await this.plugin.setSettings({
|
||||||
|
...this.plugin.settings,
|
||||||
|
regexSearchBackend: value as 'ripgrep' | 'omnisearch',
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
new Setting(containerEl)
|
new Setting(containerEl)
|
||||||
.setName(t('settings.FilesSearch.ripgrepPath'))
|
.setName(t('settings.FilesSearch.ripgrepPath'))
|
||||||
.setDesc(t('settings.FilesSearch.ripgrepPathDescription'))
|
.setDesc(t('settings.FilesSearch.ripgrepPathDescription'))
|
||||||
|
|||||||
@ -261,6 +261,7 @@ export const InfioSettingsSchema = z.object({
|
|||||||
|
|
||||||
// Files Search
|
// Files Search
|
||||||
filesSearchMethod: z.enum(['regex', 'semantic', 'auto']).catch('auto'),
|
filesSearchMethod: z.enum(['regex', 'semantic', 'auto']).catch('auto'),
|
||||||
|
regexSearchBackend: z.enum(['omnisearch', 'ripgrep']).catch('ripgrep'),
|
||||||
ripgrepPath: z.string().catch(''),
|
ripgrepPath: z.string().catch(''),
|
||||||
|
|
||||||
/// [compatible]
|
/// [compatible]
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user