update vector search

This commit is contained in:
duanfuxiang
2025-06-15 13:15:39 +08:00
parent 905c7a4ad7
commit 943bc077f1
7 changed files with 775 additions and 6 deletions

View File

@@ -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<SelectVector, 'embedding'> & { similarity: number })[]
}
const SearchView = () => {
const { getRAGEngine } = useRAG()
const app = useApp()
const searchInputRef = useRef<SearchInputRef>(null)
const [searchResults, setSearchResults] = useState<(Omit<SelectVector, 'embedding'> & { similarity: number })[]>([])
const [isSearching, setIsSearching] = useState(false)
const [hasSearched, setHasSearched] = useState(false)
// 展开状态管理 - 默认全部展开
const [expandedFiles, setExpandedFiles] = useState<Set<string>>(new Set())
// 新增mentionables 状态管理
const [mentionables, setMentionables] = useState<Mentionable[]>([])
const [searchEditorState, setSearchEditorState] = useState<SerializedEditorState | null>(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<SelectVector, 'embedding'> & { 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 (
<ReactMarkdown
className="obsidian-markdown-content"
components={{
// 简化渲染,移除一些复杂元素
h1: ({ children }) => <h4>{children}</h4>,
h2: ({ children }) => <h4>{children}</h4>,
h3: ({ children }) => <h4>{children}</h4>,
h4: ({ children }) => <h4>{children}</h4>,
h5: ({ children }) => <h5>{children}</h5>,
h6: ({ children }) => <h5>{children}</h5>,
// 移除图片显示,避免布局问题
img: () => <span className="obsidian-image-placeholder">[]</span>,
// 代码块样式
code: ({ children, inline, ...props }: { children: React.ReactNode; inline?: boolean; [key: string]: unknown }) => {
if (inline) {
return <code className="obsidian-inline-code">{children}</code>
}
return <pre className="obsidian-code-block"><code>{children}</code></pre>
},
// 链接样式
a: ({ href, children }) => (
<span className="obsidian-link" title={href}>{children}</span>
),
}}
>
{truncatedContent}
</ReactMarkdown>
)
}
// 按文件分组并排序
const groupedResults = useMemo(() => {
if (!searchResults.length) return []
// 按文件路径分组
const fileGroups = new Map<string, FileGroup>()
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 (
<div className="obsidian-search-container">
{/* 搜索输入框 */}
<div className="obsidian-search-header">
<SearchInputWithActions
ref={searchInputRef}
initialSerializedEditorState={searchEditorState}
onChange={setSearchEditorState}
onSubmit={handleSearch}
mentionables={mentionables}
setMentionables={setMentionables}
placeholder="语义搜索(按回车键搜索)..."
autoFocus={true}
disabled={isSearching}
/>
</div>
{/* 结果统计 */}
{hasSearched && !isSearching && (
<div className="obsidian-search-stats">
{totalFiles} {totalBlocks}
</div>
)}
{/* 搜索进度 */}
{isSearching && (
<div className="obsidian-search-loading">
...
</div>
)}
{/* 搜索结果 */}
<div className="obsidian-search-results">
{!isSearching && groupedResults.length > 0 && (
<div className="obsidian-results-list">
{groupedResults.map((fileGroup, fileIndex) => (
<div key={fileGroup.path} className="obsidian-file-group">
{/* 文件头部 */}
<div
className="obsidian-file-header"
onClick={() => toggleFileExpansion(fileGroup.path)}
>
<div className="obsidian-file-header-left">
{expandedFiles.has(fileGroup.path) ? (
<ChevronDown size={16} className="obsidian-expand-icon" />
) : (
<ChevronRight size={16} className="obsidian-expand-icon" />
)}
{/* <span className="obsidian-file-index">{fileIndex + 1}</span> */}
<span className="obsidian-file-name">{fileGroup.fileName}</span>
{/* <span className="obsidian-file-path">({fileGroup.path})</span> */}
</div>
<div className="obsidian-file-header-right">
{/* <span className="obsidian-file-blocks">{fileGroup.blocks.length} 块</span> */}
{/* <span className="obsidian-file-similarity">
{fileGroup.maxSimilarity.toFixed(3)}
</span> */}
</div>
</div>
{/* 文件块列表 */}
{expandedFiles.has(fileGroup.path) && (
<div className="obsidian-file-blocks">
{fileGroup.blocks.map((result, blockIndex) => (
<div
key={result.id}
className="obsidian-result-item"
onClick={() => handleResultClick(result)}
>
<div className="obsidian-result-header">
<span className="obsidian-result-index">{blockIndex + 1}</span>
<span className="obsidian-result-location">
L{result.metadata.startLine}-{result.metadata.endLine}
</span>
<span className="obsidian-result-similarity">
{result.similarity.toFixed(3)}
</span>
</div>
<div className="obsidian-result-content">
{renderMarkdownContent(result.content)}
</div>
</div>
))}
</div>
)}
</div>
))}
</div>
)}
{!isSearching && hasSearched && groupedResults.length === 0 && (
<div className="obsidian-no-results">
<p></p>
</div>
)}
</div>
{/* 样式 */}
<style>
{`
.obsidian-search-container {
display: flex;
flex-direction: column;
height: 100%;
font-family: var(--font-interface);
}
.obsidian-search-header {
padding: 12px;
}
.obsidian-search-stats {
padding: 8px 12px;
font-size: var(--font-ui-small);
color: var(--text-muted);
}
.obsidian-search-loading {
padding: 20px;
text-align: center;
color: var(--text-muted);
font-size: var(--font-ui-medium);
}
.obsidian-search-results {
flex: 1;
overflow-y: auto;
}
.obsidian-results-list {
display: flex;
flex-direction: column;
}
.obsidian-file-group {
border-bottom: 1px solid var(--background-modifier-border);
}
.obsidian-file-header {
padding: 12px;
background-color: var(--background-secondary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between;
transition: background-color 0.1s ease;
border-bottom: 1px solid var(--background-modifier-border);
}
.obsidian-file-header:hover {
background-color: var(--background-modifier-hover);
}
.obsidian-file-header-left {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
min-width: 0;
}
.obsidian-file-header-right {
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
}
.obsidian-expand-icon {
color: var(--text-muted);
flex-shrink: 0;
}
.obsidian-file-index {
color: var(--text-muted);
font-size: var(--font-ui-small);
font-weight: 500;
min-width: 20px;
flex-shrink: 0;
}
.obsidian-file-name {
color: var(--text-normal);
font-size: var(--font-ui-medium);
font-weight: 500;
flex-shrink: 0;
}
.obsidian-file-path {
color: var(--text-muted);
font-size: var(--font-ui-smaller);
font-family: var(--font-monospace);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-left: 4px;
}
.obsidian-file-blocks {
color: var(--text-muted);
font-size: var(--font-ui-small);
}
.obsidian-file-similarity {
color: var(--text-muted);
font-size: var(--font-ui-smaller);
font-family: var(--font-monospace);
}
.obsidian-file-blocks {
background-color: var(--background-primary);
}
.obsidian-result-item {
padding: 12px 12px 12px 32px;
border-bottom: 1px solid var(--background-modifier-border-focus);
cursor: pointer;
transition: background-color 0.1s ease;
}
.obsidian-result-item:hover {
background-color: var(--background-modifier-hover);
}
.obsidian-result-item:last-child {
border-bottom: none;
}
.obsidian-result-header {
display: flex;
align-items: center;
margin-bottom: 6px;
gap: 8px;
}
.obsidian-result-index {
color: var(--text-muted);
font-size: var(--font-ui-small);
font-weight: 500;
min-width: 16px;
flex-shrink: 0;
}
.obsidian-result-location {
color: var(--text-muted);
font-size: var(--font-ui-smaller);
font-family: var(--font-monospace);
flex-grow: 1;
}
.obsidian-result-similarity {
color: var(--text-muted);
font-size: var(--font-ui-smaller);
font-family: var(--font-monospace);
flex-shrink: 0;
}
.obsidian-result-content {
color: var(--text-normal);
font-size: var(--font-ui-medium);
line-height: 1.4;
word-wrap: break-word;
}
/* Markdown 渲染样式 */
.obsidian-markdown-content {
color: var(--text-normal);
font-size: var(--font-ui-medium);
line-height: 1.4;
}
.obsidian-markdown-content h4,
.obsidian-markdown-content h5 {
margin: 4px 0;
color: var(--text-normal);
font-weight: 600;
}
.obsidian-markdown-content p {
margin: 4px 0;
}
.obsidian-markdown-content ul,
.obsidian-markdown-content ol {
margin: 4px 0;
padding-left: 16px;
}
.obsidian-markdown-content li {
margin: 2px 0;
}
.obsidian-inline-code {
background-color: var(--background-modifier-border);
color: var(--text-accent);
padding: 2px 4px;
border-radius: var(--radius-s);
font-family: var(--font-monospace);
font-size: 0.9em;
}
.obsidian-code-block {
background-color: var(--background-modifier-border);
padding: 8px;
border-radius: var(--radius-s);
margin: 4px 0;
overflow-x: auto;
}
.obsidian-code-block code {
font-family: var(--font-monospace);
font-size: var(--font-ui-smaller);
color: var(--text-normal);
}
.obsidian-link {
color: var(--text-accent);
text-decoration: underline;
cursor: pointer;
}
.obsidian-image-placeholder {
color: var(--text-muted);
font-style: italic;
background-color: var(--background-modifier-border);
padding: 2px 6px;
border-radius: var(--radius-s);
font-size: var(--font-ui-smaller);
}
.obsidian-markdown-content blockquote {
border-left: 3px solid var(--text-accent);
padding-left: 12px;
margin: 4px 0;
color: var(--text-muted);
font-style: italic;
}
.obsidian-markdown-content strong {
font-weight: 600;
color: var(--text-normal);
}
.obsidian-markdown-content em {
font-style: italic;
color: var(--text-muted);
}
.obsidian-no-results {
padding: 40px 20px;
text-align: center;
color: var(--text-muted);
}
.obsidian-no-results p {
margin: 0;
font-size: var(--font-ui-medium);
}
`}
</style>
</div>
)
}
export default SearchView