From 943bc077f1b5333ec28c96c321a96bf463fb29a4 Mon Sep 17 00:00:00 2001 From: duanfuxiang Date: Sun, 15 Jun 2025 13:15:39 +0800 Subject: [PATCH] update vector search --- src/components/chat-view/ChatView.tsx | 24 +- src/components/chat-view/HelloInfo.tsx | 10 +- src/components/chat-view/SearchView.tsx | 536 ++++++++++++++++++ .../chat-view/chat-input/SearchButton.tsx | 14 + .../chat-input/SearchInputWithActions.tsx | 192 +++++++ src/core/rag/rag-engine.ts | 4 +- src/lang/locale/zh-cn.ts | 1 + 7 files changed, 775 insertions(+), 6 deletions(-) create mode 100644 src/components/chat-view/SearchView.tsx create mode 100644 src/components/chat-view/chat-input/SearchButton.tsx create mode 100644 src/components/chat-view/chat-input/SearchInputWithActions.tsx diff --git a/src/components/chat-view/ChatView.tsx b/src/components/chat-view/ChatView.tsx index 63829ed..f1e0ab1 100644 --- a/src/components/chat-view/ChatView.tsx +++ b/src/components/chat-view/ChatView.tsx @@ -2,7 +2,7 @@ import * as path from 'path' import { BaseSerializedNode } from '@lexical/clipboard/clipboard' import { useMutation } from '@tanstack/react-query' -import { CircleStop, History, NotebookPen, Plus, Server, SquareSlash } from 'lucide-react' +import { CircleStop, History, NotebookPen, Plus, Search, Server, SquareSlash } from 'lucide-react' import { App, Notice } from 'obsidian' import { forwardRef, @@ -68,6 +68,7 @@ import MarkdownReasoningBlock from './Markdown/MarkdownReasoningBlock' import McpHubView from './McpHubView'; // Moved after MarkdownReasoningBlock import QueryProgress, { QueryProgressState } from './QueryProgress' import ReactMarkdown from './ReactMarkdown' +import SearchView from './SearchView' import SimilaritySearchResults from './SimilaritySearchResults' import WebsiteReadResults from './WebsiteReadResults' @@ -176,7 +177,7 @@ const Chat = forwardRef((props, ref) => { } } - const [tab, setTab] = useState<'chat' | 'commands' | 'custom-mode' | 'mcp'>('chat') + const [tab, setTab] = useState<'chat' | 'commands' | 'custom-mode' | 'mcp' | 'search'>('chat') const [selectedSerializedNodes, setSelectedSerializedNodes] = useState([]) @@ -763,7 +764,8 @@ const Chat = forwardRef((props, ref) => { return item.text } if (item.type === "resource") { - const { blob: _blob, ...rest } = item.resource + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { blob, ...rest } = item.resource return JSON.stringify(rest, null, 2) } return "" @@ -991,6 +993,18 @@ const Chat = forwardRef((props, ref) => { > + ((props, ref) => { addedBlockKey={addedBlockKey} /> + ) : tab === 'search' ? ( +
+ +
) : tab === 'commands' ? (
void; + onNavigate: (tab: 'commands' | 'custom-mode' | 'mcp' | 'search') => void; } const HelloInfo: React.FC = ({ onNavigate }) => { const navigationItems = [ + { + label: '语义搜索', + description: '使用 RAG 在笔记库中进行语义搜索', + icon: , + action: () => onNavigate('search'), + }, { label: t('chat.navigation.commands'), description: t('chat.navigation.commandsDesc'), diff --git a/src/components/chat-view/SearchView.tsx b/src/components/chat-view/SearchView.tsx new file mode 100644 index 0000000..47af31c --- /dev/null +++ b/src/components/chat-view/SearchView.tsx @@ -0,0 +1,536 @@ +import { SerializedEditorState } from 'lexical' +import { ChevronDown, ChevronRight } from 'lucide-react' +import { useCallback, useMemo, useRef, useState } from 'react' +import ReactMarkdown from 'react-markdown' + +import { useApp } from '../../contexts/AppContext' +import { useRAG } from '../../contexts/RAGContext' +import { SelectVector } from '../../database/schema' +import { Mentionable } from '../../types/mentionable' +import { openMarkdownFile } from '../../utils/obsidian' + +import SearchInputWithActions, { SearchInputRef } from './chat-input/SearchInputWithActions' +import { editorStateToPlainText } from './chat-input/utils/editor-state-to-plain-text' + +// 文件分组结果接口 +interface FileGroup { + path: string + fileName: string + maxSimilarity: number + blocks: (Omit & { similarity: number })[] +} + +const SearchView = () => { + const { getRAGEngine } = useRAG() + const app = useApp() + const searchInputRef = useRef(null) + const [searchResults, setSearchResults] = useState<(Omit & { similarity: number })[]>([]) + const [isSearching, setIsSearching] = useState(false) + const [hasSearched, setHasSearched] = useState(false) + // 展开状态管理 - 默认全部展开 + const [expandedFiles, setExpandedFiles] = useState>(new Set()) + // 新增:mentionables 状态管理 + const [mentionables, setMentionables] = useState([]) + const [searchEditorState, setSearchEditorState] = useState(null) + + const handleSearch = useCallback(async (editorState?: SerializedEditorState) => { + let searchTerm = '' + + if (editorState) { + // 使用成熟的函数从 Lexical 编辑器状态中提取文本内容 + searchTerm = editorStateToPlainText(editorState).trim() + } + + if (!searchTerm.trim()) { + setSearchResults([]) + setHasSearched(false) + return + } + + setIsSearching(true) + setHasSearched(true) + + try { + const ragEngine = await getRAGEngine() + const results = await ragEngine.processQuery({ + query: searchTerm, + limit: 50, // 使用用户选择的限制数量 + }) + + setSearchResults(results) + // 默认展开所有文件 + // const uniquePaths = new Set(results.map(r => r.path)) + // setExpandedFiles(new Set(uniquePaths)) + } catch (error) { + console.error('搜索失败:', error) + setSearchResults([]) + } finally { + setIsSearching(false) + } + }, [getRAGEngine]) + + const handleResultClick = (result: Omit & { similarity: number }) => { + openMarkdownFile(app, result.path, result.metadata.startLine) + } + + const toggleFileExpansion = (filePath: string) => { + const newExpandedFiles = new Set(expandedFiles) + if (newExpandedFiles.has(filePath)) { + newExpandedFiles.delete(filePath) + } else { + newExpandedFiles.add(filePath) + } + setExpandedFiles(newExpandedFiles) + } + + // 限制文本显示行数 + const truncateContent = (content: string, maxLines: number = 3) => { + const lines = content.split('\n') + if (lines.length <= maxLines) { + return content + } + return lines.slice(0, maxLines).join('\n') + '...' + } + + // 渲染markdown内容 + const renderMarkdownContent = (content: string, maxLines: number = 3) => { + const truncatedContent = truncateContent(content, maxLines) + return ( +

{children}

, + h2: ({ children }) =>

{children}

, + h3: ({ children }) =>

{children}

, + h4: ({ children }) =>

{children}

, + h5: ({ children }) =>
{children}
, + h6: ({ children }) =>
{children}
, + // 移除图片显示,避免布局问题 + img: () => [图片], + // 代码块样式 + code: ({ children, inline, ...props }: { children: React.ReactNode; inline?: boolean; [key: string]: unknown }) => { + if (inline) { + return {children} + } + return
{children}
+ }, + // 链接样式 + a: ({ href, children }) => ( + {children} + ), + }} + > + {truncatedContent} +
+ ) + } + + // 按文件分组并排序 + const groupedResults = useMemo(() => { + if (!searchResults.length) return [] + + // 按文件路径分组 + const fileGroups = new Map() + + searchResults.forEach(result => { + const filePath = result.path + const fileName = filePath.split('/').pop() || filePath + + if (!fileGroups.has(filePath)) { + fileGroups.set(filePath, { + path: filePath, + fileName, + maxSimilarity: result.similarity, + blocks: [] + }) + } + + const group = fileGroups.get(filePath) + if (group) { + group.blocks.push(result) + // 更新最高相似度 + if (result.similarity > group.maxSimilarity) { + group.maxSimilarity = result.similarity + } + } + }) + + // 对每个文件内的块按相似度排序 + fileGroups.forEach(group => { + group.blocks.sort((a, b) => b.similarity - a.similarity) + }) + + // 将文件按最高相似度排序 + return Array.from(fileGroups.values()).sort((a, b) => b.maxSimilarity - a.maxSimilarity) + }, [searchResults]) + + const totalBlocks = searchResults.length + const totalFiles = groupedResults.length + + return ( +
+ {/* 搜索输入框 */} +
+ +
+ + {/* 结果统计 */} + {hasSearched && !isSearching && ( +
+ {totalFiles} 个文件,{totalBlocks} 个块 +
+ )} + + {/* 搜索进度 */} + {isSearching && ( +
+ 正在搜索... +
+ )} + + {/* 搜索结果 */} +
+ {!isSearching && groupedResults.length > 0 && ( +
+ {groupedResults.map((fileGroup, fileIndex) => ( +
+ {/* 文件头部 */} +
toggleFileExpansion(fileGroup.path)} + > +
+ {expandedFiles.has(fileGroup.path) ? ( + + ) : ( + + )} + {/* {fileIndex + 1} */} + {fileGroup.fileName} + {/* ({fileGroup.path}) */} +
+
+ {/* {fileGroup.blocks.length} 块 */} + {/* + {fileGroup.maxSimilarity.toFixed(3)} + */} +
+
+ + {/* 文件块列表 */} + {expandedFiles.has(fileGroup.path) && ( +
+ {fileGroup.blocks.map((result, blockIndex) => ( +
handleResultClick(result)} + > +
+ {blockIndex + 1} + + L{result.metadata.startLine}-{result.metadata.endLine} + + + {result.similarity.toFixed(3)} + +
+
+ {renderMarkdownContent(result.content)} +
+
+ ))} +
+ )} +
+ ))} +
+ )} + + {!isSearching && hasSearched && groupedResults.length === 0 && ( +
+

未找到相关结果

+
+ )} +
+ + {/* 样式 */} + +
+ ) +} + +export default SearchView + diff --git a/src/components/chat-view/chat-input/SearchButton.tsx b/src/components/chat-view/chat-input/SearchButton.tsx new file mode 100644 index 0000000..1e60908 --- /dev/null +++ b/src/components/chat-view/chat-input/SearchButton.tsx @@ -0,0 +1,14 @@ +import { SearchIcon } from 'lucide-react' + +import { t } from '../../../lang/helpers' + +export function SearchButton({ onClick }: { onClick: () => void }) { + return ( + + ) +} diff --git a/src/components/chat-view/chat-input/SearchInputWithActions.tsx b/src/components/chat-view/chat-input/SearchInputWithActions.tsx new file mode 100644 index 0000000..85e0ae6 --- /dev/null +++ b/src/components/chat-view/chat-input/SearchInputWithActions.tsx @@ -0,0 +1,192 @@ +import { $getRoot, LexicalEditor, SerializedEditorState } from 'lexical' +import { + forwardRef, + useImperativeHandle, + useRef, + useState +} from 'react' + +import { Mentionable } from '../../../types/mentionable' + +import LexicalContentEditable from './LexicalContentEditable' +import { SearchButton } from './SearchButton' + +export type SearchInputRef = { + focus: () => void + clear: () => void +} + +export type SearchInputProps = { + initialSerializedEditorState: SerializedEditorState | null + onChange?: (content: SerializedEditorState) => void + onSubmit: (content: SerializedEditorState, useVaultSearch?: boolean) => void + mentionables?: Mentionable[] + setMentionables?: (mentionables: Mentionable[]) => void + placeholder?: string + autoFocus?: boolean + disabled?: boolean +} + +// 检查编辑器状态是否为空的辅助函数 +const isEditorStateEmpty = (editorState: SerializedEditorState): boolean => { + if (!editorState || !editorState.root || !editorState.root.children) { + return true + } + + const children = editorState.root.children + if (children.length === 0) { + return true + } + + // 检查是否只有空的段落 + if (children.length === 1 && children[0].type === 'paragraph') { + const paragraph = children[0] as any + return !paragraph.children || paragraph.children.length === 0 + } + + return false +} + +const SearchInputWithActions = forwardRef( + ( + { + initialSerializedEditorState, + onChange, + onSubmit, + placeholder = '', + autoFocus = false, + disabled = false, + }, + ref + ) => { + const editorRef = useRef(null) + const contentEditableRef = useRef(null) + const containerRef = useRef(null) + + // 追踪编辑器是否为空 + const [isEmpty, setIsEmpty] = useState(() => + initialSerializedEditorState ? isEditorStateEmpty(initialSerializedEditorState) : true + ) + + // 暴露给父组件的方法 + useImperativeHandle(ref, () => ({ + focus: () => { + contentEditableRef.current?.focus() + }, + clear: () => { + editorRef.current?.update(() => { + const root = $getRoot() + root.clear() + }) + setIsEmpty(true) + } + })) + + const handleSubmit = (options?: { useVaultSearch?: boolean }) => { + const content = editorRef.current?.getEditorState()?.toJSON() + if (content) { + onSubmit(content, options?.useVaultSearch) + } + } + + const handleChange = (content: SerializedEditorState) => { + // 检查内容是否为空并更新状态 + setIsEmpty(isEditorStateEmpty(content)) + // 调用父组件的 onChange 回调 + onChange?.(content) + } + + const onCreateCommand = () => { + // 处理命令创建逻辑 + // 这里可以根据实际需求添加具体实现 + } + + return ( +
+ {placeholder && isEmpty && ( +
+ {placeholder} +
+ )} + { + if (initialSerializedEditorState) { + editor.setEditorState( + editor.parseEditorState(initialSerializedEditorState), + ) + } + }} + editorRef={editorRef} + contentEditableRef={contentEditableRef} + onChange={handleChange} + onEnter={() => handleSubmit()} + autoFocus={autoFocus} + plugins={{ + onEnter: { + onVaultChat: () => { + handleSubmit({ useVaultSearch: true }) + }, + }, + commandPopover: { + anchorElement: containerRef.current, + onCreateCommand: onCreateCommand, + }, + }} + /> + +
+
+ {/* TODO: add model select */} +
+
+ handleSubmit()} /> +
+
+ +
+ ) + }, +) + +SearchInputWithActions.displayName = 'SearchInput' + +export default SearchInputWithActions diff --git a/src/core/rag/rag-engine.ts b/src/core/rag/rag-engine.ts index d51ad2d..ccc1757 100644 --- a/src/core/rag/rag-engine.ts +++ b/src/core/rag/rag-engine.ts @@ -102,6 +102,7 @@ export class RAGEngine { async processQuery({ query, scope, + limit, onQueryProgressChange, }: { query: string @@ -109,6 +110,7 @@ export class RAGEngine { files: string[] folders: string[] } + limit?: number onQueryProgressChange?: (queryProgress: QueryProgressState) => void }): Promise< (Omit & { @@ -134,7 +136,7 @@ export class RAGEngine { this.embeddingModel, { minSimilarity: this.settings.ragOptions.minSimilarity, - limit: this.settings.ragOptions.limit, + limit: limit ?? this.settings.ragOptions.limit, scope, }, ) diff --git a/src/lang/locale/zh-cn.ts b/src/lang/locale/zh-cn.ts index cb7dfa3..6f99118 100644 --- a/src/lang/locale/zh-cn.ts +++ b/src/lang/locale/zh-cn.ts @@ -103,6 +103,7 @@ export default { viewDetails: "查看详情" }, input: { + search: "搜索", submit: "提交", collectedModels: "收集的模型", loading: "加载中...",