From 9984527e85d8df68e01f88314f7c689d53db63e5 Mon Sep 17 00:00:00 2001 From: travertexg Date: Mon, 9 Jun 2025 15:15:16 +0000 Subject: [PATCH] feat: Enhance file search with core plugin and Omnisearch integration - Introduces a new match_search_files tool for fuzzy/keyword search, integrating with Obsidian's core search plugin and updating Omnisearch integration for improved file search capabilities. - Adds settings for selecting search backends (core plugin, Omnisearch, ripgrep) for both regex and match searches. - Updates language files, prompts, and types to support the new functionality. - Restructures search-related files for better organization. --- src/components/chat-view/ChatView.tsx | 40 +++++++-- .../MarkdownMatchSearchFilesBlock.tsx | 52 ++++++++++++ src/components/chat-view/ReactMarkdown.tsx | 10 +++ src/core/prompts/sections/capabilities.ts | 22 +++-- src/core/prompts/sections/rules.ts | 4 +- src/core/prompts/tools/search-files.ts | 24 +++++- src/core/search/match/coreplugin-match.ts | 85 +++++++++++++++++++ .../match/omnisearch-match.ts} | 16 ++-- src/core/search/regex/coreplugin-regex.ts | 17 ++++ .../regex/ripgrep-regex.ts} | 2 +- .../search-common.ts} | 0 src/lang/locale/en.ts | 6 +- src/lang/locale/zh-cn.ts | 6 +- src/settings/SettingTab.tsx | 24 +++++- src/types/apply.ts | 10 ++- src/types/settings.ts | 5 +- src/utils/glob-utils.ts | 4 + src/utils/parse-infio-block.ts | 35 ++++++++ 18 files changed, 326 insertions(+), 36 deletions(-) create mode 100644 src/components/chat-view/Markdown/MarkdownMatchSearchFilesBlock.tsx create mode 100644 src/core/search/match/coreplugin-match.ts rename src/core/{regex/omnisearch-index.ts => search/match/omnisearch-match.ts} (87%) create mode 100644 src/core/search/regex/coreplugin-regex.ts rename src/core/{regex/ripgrep-index.ts => search/regex/ripgrep-regex.ts} (99%) rename src/core/{regex/regex-common.ts => search/search-common.ts} (100%) diff --git a/src/components/chat-view/ChatView.tsx b/src/components/chat-view/ChatView.tsx index 5035a91..a477843 100644 --- a/src/components/chat-view/ChatView.tsx +++ b/src/components/chat-view/ChatView.tsx @@ -29,8 +29,10 @@ import { LLMBaseUrlNotSetException, LLMModelNotSetException, } from '../../core/llm/exception' -import { regexSearchFilesWithRipgrep } from '../../core/regex/ripgrep-index' -import { regexSearchFilesWithOmnisearch } from '../../core/regex/omnisearch-index' +import { searchFilesWithCorePlugin } from '../../core/search/match/coreplugin-match' +import { searchFilesWithOmnisearch } from '../../core/search/match/omnisearch-match' +import { regexSearchFilesWithRipgrep } from '../../core/search/regex/ripgrep-regex' +import { regexSearchFilesWithCorePlugin } from '../../core/search/regex/coreplugin-regex' import { useChatHistory } from '../../hooks/use-chat-history' import { useCustomModes } from '../../hooks/use-custom-mode' import { t } from '../../lang/helpers' @@ -608,15 +610,37 @@ const Chat = forwardRef((props, ref) => { mentionables: [], } } - } else if (toolArgs.type === 'regex_search_files') { - // @ts-expect-error Obsidian API type mismatch - const searchBackend = settings.regexSearchBackend - const baseVaultPath = String(app.vault.adapter.getBasePath()) - const absolutePath = path.join(baseVaultPath, toolArgs.filepath) + } else if (toolArgs.type === 'match_search_files') { + const searchBackend = settings.matchSearchBackend let results: string; if (searchBackend === 'omnisearch') { - results = await regexSearchFilesWithOmnisearch(absolutePath, toolArgs.regex, app) + results = await searchFilesWithOmnisearch(toolArgs.query, app) } else { + results = await searchFilesWithCorePlugin(toolArgs.query, app) + } + const formattedContent = `[match_search_files for '${toolArgs.filepath}'] Result:\n${results}\n`; + return { + type: 'match_search_files', + applyMsgId, + applyStatus: ApplyStatus.Applied, + returnMsg: { + role: 'user', + applyStatus: ApplyStatus.Idle, + content: null, + promptContent: formattedContent, + id: uuidv4(), + mentionables: [], + } + } + } else if (toolArgs.type === 'regex_search_files') { + const searchBackend = settings.regexSearchBackend + let results: string; + if (searchBackend === 'coreplugin') { + results = await regexSearchFilesWithCorePlugin(toolArgs.regex, app) + } else { + // @ts-expect-error Obsidian API type mismatch + const baseVaultPath = String(app.vault.adapter.getBasePath()) + const absolutePath = path.join(baseVaultPath, toolArgs.filepath) const ripgrepPath = settings.ripgrepPath results = await regexSearchFilesWithRipgrep(absolutePath, toolArgs.regex, ripgrepPath) } diff --git a/src/components/chat-view/Markdown/MarkdownMatchSearchFilesBlock.tsx b/src/components/chat-view/Markdown/MarkdownMatchSearchFilesBlock.tsx new file mode 100644 index 0000000..757a3d8 --- /dev/null +++ b/src/components/chat-view/Markdown/MarkdownMatchSearchFilesBlock.tsx @@ -0,0 +1,52 @@ +import { FileSearch } from 'lucide-react' +import React from 'react' + +import { useApp } from "../../../contexts/AppContext" +import { t } from '../../../lang/helpers' +import { ApplyStatus, MatchSearchFilesToolArgs } from "../../../types/apply" +import { openMarkdownFile } from "../../../utils/obsidian" + +export default function MarkdownMatchSearchFilesBlock({ + applyStatus, + onApply, + path, + query, + finish +}: { + applyStatus: ApplyStatus + onApply: (args: MatchSearchFilesToolArgs) => void + path: string, + query: string, + finish: boolean +}) { + const app = useApp() + + const handleClick = () => { + openMarkdownFile(app, path) + } + + React.useEffect(() => { + if (finish && applyStatus === ApplyStatus.Idle) { + onApply({ + type: 'match_search_files', + filepath: path, + query: query, + file_pattern: ".md", + }) + } + }, [finish]) + + return ( +
+
+
+ + {t('chat.reactMarkdown.matchSearchInPath').replace('{query}', query).replace('{path}', path)} +
+
+
+ ) +} diff --git a/src/components/chat-view/ReactMarkdown.tsx b/src/components/chat-view/ReactMarkdown.tsx index 2db19d7..1797fad 100644 --- a/src/components/chat-view/ReactMarkdown.tsx +++ b/src/components/chat-view/ReactMarkdown.tsx @@ -13,6 +13,7 @@ import MarkdownFetchUrlsContentBlock from './Markdown/MarkdownFetchUrlsContentBl import MarkdownListFilesBlock from './Markdown/MarkdownListFilesBlock' import MarkdownReadFileBlock from './Markdown/MarkdownReadFileBlock' import MarkdownReasoningBlock from './Markdown/MarkdownReasoningBlock' +import MarkdownMatchSearchFilesBlock from './Markdown/MarkdownMatchSearchFilesBlock' import MarkdownRegexSearchFilesBlock from './Markdown/MarkdownRegexSearchFilesBlock' import MarkdownSearchAndReplace from './Markdown/MarkdownSearchAndReplace' import MarkdownSearchWebBlock from './Markdown/MarkdownSearchWebBlock' @@ -117,6 +118,15 @@ function ReactMarkdown({ recursive={block.recursive} finish={block.finish} /> + ) : block.type === 'match_search_files' ? ( + ) : block.type === 'regex_search_files' ? ( +Directory path here +Your keyword/phrase here + + +Example: Requesting to search for all Markdown files containing 'test' in the current directory + +. +test +` +} + export function getRegexSearchFilesDescription(args: ToolArgs): string { return `## regex_search_files Description: Request to perform a regex search across files in a specified directory, providing context-rich results. This tool searches for patterns or specific content across multiple files, displaying each match with encapsulating context. diff --git a/src/core/search/match/coreplugin-match.ts b/src/core/search/match/coreplugin-match.ts new file mode 100644 index 0000000..f57b0eb --- /dev/null +++ b/src/core/search/match/coreplugin-match.ts @@ -0,0 +1,85 @@ +import { App } from "obsidian"; +import { + MAX_RESULTS, + truncateLine, + SearchResult, + formatResults, +} from '../search-common'; + +/** + * Searches using Obsidian's core search plugin and builds context for each match. + * + * @param app The Obsidian App instance. + * @param query The query to search for. + * @returns A promise that resolves to a formatted string of search results. + */ +export async function searchFilesWithCorePlugin( + query: string, + app: App, +): Promise { + const searchPlugin = (app as any).internalPlugins.plugins['global-search']?.instance; + if (!searchPlugin) { + throw new Error("Core search plugin is not available."); + } + + // The core search function is not officially documented and may change. + // This is based on community findings and common usage in other plugins. + const searchResults = await new Promise((resolve) => { + const unregister = searchPlugin.on("search-results", (results: any) => { + unregister(); + resolve(results); + }); + searchPlugin.openGlobalSearch(query); + }); + + const results: SearchResult[] = []; + const vault = app.vault; + + for (const fileMatches of Object.values(searchResults) as any) { + if (results.length >= MAX_RESULTS) { + break; + } + + const file = vault.getAbstractFileByPath(fileMatches.file.path); + if (!file || !('read' in file)) { + continue; + } + + const content = await vault.cachedRead(file as any); + const lines = content.split('\n'); + + for (const match of fileMatches.result.content) { + if (results.length >= MAX_RESULTS) { + break; + } + + const [matchText, startOffset] = match; + let charCount = 0; + let lineNumber = 0; + let column = 0; + let lineContent = ""; + + for (let i = 0; i < lines.length; i++) { + const lineLength = lines[i].length + 1; // +1 for the newline character + if (charCount + lineLength > startOffset) { + lineNumber = i + 1; + column = startOffset - charCount + 1; + lineContent = lines[i]; + break; + } + charCount += lineLength; + } + + results.push({ + file: fileMatches.file.path, + line: lineNumber, + column: column, + match: truncateLine(lineContent.trimEnd()), + beforeContext: lineNumber > 1 ? [truncateLine(lines[lineNumber - 2].trimEnd())] : [], + afterContext: lineNumber < lines.length ? [truncateLine(lines[lineNumber].trimEnd())] : [], + }); + } + } + + return formatResults(results, ".\\"); +} \ No newline at end of file diff --git a/src/core/regex/omnisearch-index.ts b/src/core/search/match/omnisearch-match.ts similarity index 87% rename from src/core/regex/omnisearch-index.ts rename to src/core/search/match/omnisearch-match.ts index 15322c5..5ab16f4 100644 --- a/src/core/regex/omnisearch-index.ts +++ b/src/core/search/match/omnisearch-match.ts @@ -4,9 +4,7 @@ import { truncateLine, SearchResult, formatResults, -} from './regex-common'; - -// --- Omnisearch API and Helper Types --- +} from '../search-common'; type SearchMatchApi = { match: string; @@ -69,14 +67,12 @@ function findLineAndColumnFromOffset( } /** - * 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. + * Searches using Omnisearch and builds context for each match. * @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, +export async function searchFilesWithOmnisearch( query: string, app: App, ): Promise { @@ -87,8 +83,8 @@ export async function regexSearchFilesWithOmnisearch( ); } - // 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. + // Omnisearch is not a regex engine. + // 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."); @@ -132,7 +128,7 @@ export async function regexSearchFilesWithOmnisearch( } } - return formatResults(results, vaultPath); + return formatResults(results, ".\\"); } catch (error) { console.error("Error during Omnisearch processing:", error); return "An error occurred during the search."; diff --git a/src/core/search/regex/coreplugin-regex.ts b/src/core/search/regex/coreplugin-regex.ts new file mode 100644 index 0000000..d29ad54 --- /dev/null +++ b/src/core/search/regex/coreplugin-regex.ts @@ -0,0 +1,17 @@ +import { App } from "obsidian"; +import { searchFilesWithCorePlugin } from '../match/coreplugin-match' + +/** + * Performs a regular expression search using Obsidian's core search plugin. + * + * @param app The Obsidian App instance. + * @param regex The regular expression to search for. + * @returns A promise that resolves to a formatted string of search results. + */ +export async function regexSearchFilesWithCorePlugin( + regex: string, + app: App, +): Promise { + const query = "/" + regex + "/"; + return searchFilesWithCorePlugin(query, app); +} \ No newline at end of file diff --git a/src/core/regex/ripgrep-index.ts b/src/core/search/regex/ripgrep-regex.ts similarity index 99% rename from src/core/regex/ripgrep-index.ts rename to src/core/search/regex/ripgrep-regex.ts index eb972dc..fa3bf39 100644 --- a/src/core/regex/ripgrep-index.ts +++ b/src/core/search/regex/ripgrep-regex.ts @@ -8,7 +8,7 @@ import { truncateLine, SearchResult, formatResults -} from './regex-common' +} from '../search-common' const isWindows = /^win/.test(process.platform) const binName = isWindows ? "rg.exe" : "rg" diff --git a/src/core/regex/regex-common.ts b/src/core/search/search-common.ts similarity index 100% rename from src/core/regex/regex-common.ts rename to src/core/search/search-common.ts diff --git a/src/lang/locale/en.ts b/src/lang/locale/en.ts index 868ecc0..3f2b8c5 100644 --- a/src/lang/locale/en.ts +++ b/src/lang/locale/en.ts @@ -81,6 +81,7 @@ export default { copy: "Copy", editOrApplyDiff: "{mode}: {path}", loading: "Loading...", + matchSearchInPath: 'match search files "{query}" in {path}', regexSearchInPath: 'regex search files "{regex}" in {path}', createNewNote: "Create new note", copyMsg: "Copy message", @@ -227,10 +228,13 @@ export default { auto: 'Auto', semantic: 'Semantic', regex: 'Regex', + match: 'Match', regexBackend: 'Regex search backend', regexBackendDescription: 'Choose the backend for regex search method.', + matchBackend: 'Match search backend', + matchBackendDescription: 'Choose the backend for match search method.', ripgrep: 'ripgrep', - omnisearch: 'Omnisearch', + coreplugin: 'Core plugin', ripgrepPath: 'ripgrep path', ripgrepPathDescription: 'Path to the ripgrep binary. When using ripgrep regex search, this is required.', }, diff --git a/src/lang/locale/zh-cn.ts b/src/lang/locale/zh-cn.ts index 8fe4c84..05c813d 100644 --- a/src/lang/locale/zh-cn.ts +++ b/src/lang/locale/zh-cn.ts @@ -82,6 +82,7 @@ export default { copy: "复制", editOrApplyDiff: "{mode}:{path}", loading: "加载中...", + matchSearchInPath: '在 {path} 中匹配搜索文件 "{query}"', regexSearchInPath: '在 {path} 中正则搜索文件 "{regex}"', createNewNote: "创建新笔记", copyMsg: "复制消息", @@ -228,10 +229,13 @@ export default { auto: '自动', semantic: '语义', regex: '正则', + match: '匹配', regexBackend: '正则搜索后端', regexBackendDescription: '选择正则搜索的后端。', + matchBackend: '匹配搜索后端', + matchBackendDescription: '选择匹配搜索的后端。', ripgrep: 'ripgrep', - omnisearch: 'Omnisearch', + coreplugin: '核心插件', ripgrepPath: 'ripgrep 路径', ripgrepPathDescription: 'ripgrep 二进制文件的路径。使用 ripgrep 正则搜索时需要此项。', }, diff --git a/src/settings/SettingTab.tsx b/src/settings/SettingTab.tsx index fba345e..ac0fde0 100644 --- a/src/settings/SettingTab.tsx +++ b/src/settings/SettingTab.tsx @@ -155,26 +155,42 @@ export class InfioSettingTab extends PluginSettingTab { .addOption('auto', t('settings.FilesSearch.auto')) .addOption('semantic', t('settings.FilesSearch.semantic')) .addOption('regex', t('settings.FilesSearch.regex')) + .addOption('match', t('settings.FilesSearch.match')) .setValue(this.plugin.settings.filesSearchMethod) .onChange(async (value) => { await this.plugin.setSettings({ ...this.plugin.settings, - filesSearchMethod: value as 'regex' | 'semantic' | 'auto', + filesSearchMethod: value as 'match' | 'regex' | 'semantic' | 'auto', }) }), ) - new Setting(containerEl) + 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')) + .addOption('coreplugin', t('settings.FilesSearch.coreplugin')) .setValue(this.plugin.settings.regexSearchBackend) .onChange(async (value) => { await this.plugin.setSettings({ ...this.plugin.settings, - regexSearchBackend: value as 'ripgrep' | 'omnisearch', + regexSearchBackend: value as 'ripgrep' | 'coreplugin', + }) + }), + ) + new Setting(containerEl) + .setName(t('settings.FilesSearch.matchBackend')) + .setDesc(t('settings.FilesSearch.matchBackendDescription')) + .addDropdown((dropdown) => + dropdown + .addOption('coreplugin', t('settings.FilesSearch.coreplugin')) + .addOption('omnisearch', t('settings.FilesSearch.omnisearch')) + .setValue(this.plugin.settings.matchSearchBackend) + .onChange(async (value) => { + await this.plugin.setSettings({ + ...this.plugin.settings, + matchSearchBackend: value as 'coreplugin' | 'omnisearch', }) }), ) diff --git a/src/types/apply.ts b/src/types/apply.ts index 60fca7b..184d83a 100644 --- a/src/types/apply.ts +++ b/src/types/apply.ts @@ -20,6 +20,14 @@ export type ListFilesToolArgs = { recursive?: boolean; } +export type MatchSearchFilesToolArgs = { + type: 'match_search_files'; + filepath?: string; + query?: string; + file_pattern?: string; + finish?: boolean; +} + export type RegexSearchFilesToolArgs = { type: 'regex_search_files'; filepath?: string; @@ -97,4 +105,4 @@ export type UseMcpToolArgs = { parameters: Record; } -export type ToolArgs = ReadFileToolArgs | WriteToFileToolArgs | InsertContentToolArgs | SearchAndReplaceToolArgs | ListFilesToolArgs | RegexSearchFilesToolArgs | SemanticSearchFilesToolArgs | SearchWebToolArgs | FetchUrlsContentToolArgs | SwitchModeToolArgs | ApplyDiffToolArgs | UseMcpToolArgs; +export type ToolArgs = ReadFileToolArgs | WriteToFileToolArgs | InsertContentToolArgs | SearchAndReplaceToolArgs | ListFilesToolArgs | MatchSearchFilesToolArgs | RegexSearchFilesToolArgs | SemanticSearchFilesToolArgs | SearchWebToolArgs | FetchUrlsContentToolArgs | SwitchModeToolArgs | ApplyDiffToolArgs | UseMcpToolArgs; diff --git a/src/types/settings.ts b/src/types/settings.ts index a67ae9f..582e655 100644 --- a/src/types/settings.ts +++ b/src/types/settings.ts @@ -260,8 +260,9 @@ export const InfioSettingsSchema = z.object({ jinaApiKey: z.string().catch(''), // Files Search - filesSearchMethod: z.enum(['regex', 'semantic', 'auto']).catch('auto'), - regexSearchBackend: z.enum(['omnisearch', 'ripgrep']).catch('ripgrep'), + filesSearchMethod: z.enum(['match', 'regex', 'semantic', 'auto']).catch('auto'), + regexSearchBackend: z.enum(['coreplugin', 'ripgrep']).catch('ripgrep'), + matchSearchBackend: z.enum(['omnisearch', 'coreplugin']).catch('coreplugin'), ripgrepPath: z.string().catch(''), /// [compatible] diff --git a/src/utils/glob-utils.ts b/src/utils/glob-utils.ts index 044b088..77c6489 100644 --- a/src/utils/glob-utils.ts +++ b/src/utils/glob-utils.ts @@ -28,6 +28,10 @@ export const listFilesAndFolders = async (vault: Vault, path: string) => { return [] } +export const matchSearchFiles = async (vault: Vault, path: string, query: string, file_pattern: string) => { + +} + export const regexSearchFiles = async (vault: Vault, path: string, regex: string, file_pattern: string) => { } diff --git a/src/utils/parse-infio-block.ts b/src/utils/parse-infio-block.ts index 3c5f149..7d79008 100644 --- a/src/utils/parse-infio-block.ts +++ b/src/utils/parse-infio-block.ts @@ -59,6 +59,11 @@ export type ParsedMsgBlock = path: string recursive?: boolean finish: boolean + } | { + type: 'match_search_files' + path: string + query: string + finish: boolean } | { type: 'regex_search_files' path: string @@ -226,6 +231,36 @@ export function parseMsgBlocks( finish: node.sourceCodeLocation.endTag !== undefined }) lastEndOffset = endOffset + } else if (node.nodeName === 'match_search_files') { + if (!node.sourceCodeLocation) { + throw new Error('sourceCodeLocation is undefined') + } + const startOffset = node.sourceCodeLocation.startOffset + const endOffset = node.sourceCodeLocation.endOffset + if (startOffset > lastEndOffset) { + parsedResult.push({ + type: 'string', + content: input.slice(lastEndOffset, startOffset), + }) + } + let path: string | undefined + let query: string | undefined + + for (const childNode of node.childNodes) { + if (childNode.nodeName === 'path' && childNode.childNodes.length > 0) { + path = childNode.childNodes[0].value + } else if (childNode.nodeName === 'query' && childNode.childNodes.length > 0) { + query = childNode.childNodes[0].value + } + } + + parsedResult.push({ + type: 'match_search_files', + path: path, + query: query, + finish: node.sourceCodeLocation.endTag !== undefined + }) + lastEndOffset = endOffset } else if (node.nodeName === 'regex_search_files') { if (!node.sourceCodeLocation) { throw new Error('sourceCodeLocation is undefined')