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

@ -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<ChatRef, ChatProps>((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<BaseSerializedNode[]>([])
@ -763,7 +764,8 @@ const Chat = forwardRef<ChatRef, ChatProps>((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<ChatRef, ChatProps>((props, ref) => {
>
<Plus size={18} />
</button>
<button
onClick={() => {
if (tab === 'search') {
setTab('chat')
} else {
setTab('search')
}
}}
className="infio-chat-list-dropdown"
>
<Search size={18} color={tab === 'search' ? 'var(--text-accent)' : 'var(--text-color)'} />
</button>
<ChatHistory
chatList={chatList}
currentConversationId={currentConversationId}
@ -1183,6 +1197,10 @@ const Chat = forwardRef<ChatRef, ChatProps>((props, ref) => {
addedBlockKey={addedBlockKey}
/>
</>
) : tab === 'search' ? (
<div className="infio-chat-commands">
<SearchView />
</div>
) : tab === 'commands' ? (
<div className="infio-chat-commands">
<CommandsView

View File

@ -1,14 +1,20 @@
import { NotebookPen, Server, SquareSlash } from 'lucide-react';
import { NotebookPen, Search, Server, SquareSlash } from 'lucide-react';
import React from 'react';
import { t } from '../../lang/helpers';
interface HelloInfoProps {
onNavigate: (tab: 'commands' | 'custom-mode' | 'mcp') => void;
onNavigate: (tab: 'commands' | 'custom-mode' | 'mcp' | 'search') => void;
}
const HelloInfo: React.FC<HelloInfoProps> = ({ onNavigate }) => {
const navigationItems = [
{
label: '语义搜索',
description: '使用 RAG 在笔记库中进行语义搜索',
icon: <Search size={20} />,
action: () => onNavigate('search'),
},
{
label: t('chat.navigation.commands'),
description: t('chat.navigation.commandsDesc'),

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

View File

@ -0,0 +1,14 @@
import { SearchIcon } from 'lucide-react'
import { t } from '../../../lang/helpers'
export function SearchButton({ onClick }: { onClick: () => void }) {
return (
<button className="infio-chat-user-input-submit-button" onClick={onClick}>
{t('chat.input.search')}
<div className="infio-chat-user-input-submit-button-icons">
<SearchIcon size={12} />
</div>
</button>
)
}

View File

@ -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<SearchInputRef, SearchInputProps>(
(
{
initialSerializedEditorState,
onChange,
onSubmit,
placeholder = '',
autoFocus = false,
disabled = false,
},
ref
) => {
const editorRef = useRef<LexicalEditor | null>(null)
const contentEditableRef = useRef<HTMLDivElement>(null)
const containerRef = useRef<HTMLDivElement>(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 (
<div
className={`infio-chat-user-input-container ${disabled ? 'disabled' : ''}`}
ref={containerRef}
>
{placeholder && isEmpty && (
<div className="infio-input-placeholder">
{placeholder}
</div>
)}
<LexicalContentEditable
initialEditorState={(editor) => {
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,
},
}}
/>
<div className="infio-chat-user-input-controls">
<div className="infio-chat-user-input-controls__model-select-container">
{/* TODO: add model select */}
</div>
<div className="infio-chat-user-input-controls__buttons">
<SearchButton onClick={() => handleSubmit()} />
</div>
</div>
<style>
{`
.infio-chat-user-input-container.disabled {
opacity: 0.6;
pointer-events: none;
}
.infio-input-placeholder {
position: absolute;
color: var(--text-muted);
pointer-events: none;
z-index: 1;
padding: calc(var(--size-2-2) + 1px) var(--size-4-2);
font-size: var(--font-ui-small);
}
.infio-search-button {
position: absolute;
bottom: var(--size-2-2);
right: var(--size-2-2);
display: flex;
align-items: center;
justify-content: center;
width: var(--size-4-4);
height: var(--size-4-4);
padding: 0;
background-color: transparent;
border: none;
box-shadow: none;
color: var(--text-muted);
cursor: pointer;
border-radius: var(--radius-s);
transition: all 0.15s ease-in-out;
z-index: 10;
}
`}
</style>
</div>
)
},
)
SearchInputWithActions.displayName = 'SearchInput'
export default SearchInputWithActions

View File

@ -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<SelectVector, 'embedding'> & {
@ -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,
},
)

View File

@ -103,6 +103,7 @@ export default {
viewDetails: "查看详情"
},
input: {
search: "搜索",
submit: "提交",
collectedModels: "收集的模型",
loading: "加载中...",