update vector search
This commit is contained in:
536
src/components/chat-view/SearchView.tsx
Normal file
536
src/components/chat-view/SearchView.tsx
Normal 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
|
||||
|
||||
Reference in New Issue
Block a user