update vector search
This commit is contained in:
parent
905c7a4ad7
commit
943bc077f1
@ -2,7 +2,7 @@ import * as path from 'path'
|
|||||||
|
|
||||||
import { BaseSerializedNode } from '@lexical/clipboard/clipboard'
|
import { BaseSerializedNode } from '@lexical/clipboard/clipboard'
|
||||||
import { useMutation } from '@tanstack/react-query'
|
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 { App, Notice } from 'obsidian'
|
||||||
import {
|
import {
|
||||||
forwardRef,
|
forwardRef,
|
||||||
@ -68,6 +68,7 @@ import MarkdownReasoningBlock from './Markdown/MarkdownReasoningBlock'
|
|||||||
import McpHubView from './McpHubView'; // Moved after MarkdownReasoningBlock
|
import McpHubView from './McpHubView'; // Moved after MarkdownReasoningBlock
|
||||||
import QueryProgress, { QueryProgressState } from './QueryProgress'
|
import QueryProgress, { QueryProgressState } from './QueryProgress'
|
||||||
import ReactMarkdown from './ReactMarkdown'
|
import ReactMarkdown from './ReactMarkdown'
|
||||||
|
import SearchView from './SearchView'
|
||||||
import SimilaritySearchResults from './SimilaritySearchResults'
|
import SimilaritySearchResults from './SimilaritySearchResults'
|
||||||
import WebsiteReadResults from './WebsiteReadResults'
|
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[]>([])
|
const [selectedSerializedNodes, setSelectedSerializedNodes] = useState<BaseSerializedNode[]>([])
|
||||||
|
|
||||||
@ -763,7 +764,8 @@ const Chat = forwardRef<ChatRef, ChatProps>((props, ref) => {
|
|||||||
return item.text
|
return item.text
|
||||||
}
|
}
|
||||||
if (item.type === "resource") {
|
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 JSON.stringify(rest, null, 2)
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
@ -991,6 +993,18 @@ const Chat = forwardRef<ChatRef, ChatProps>((props, ref) => {
|
|||||||
>
|
>
|
||||||
<Plus size={18} />
|
<Plus size={18} />
|
||||||
</button>
|
</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
|
<ChatHistory
|
||||||
chatList={chatList}
|
chatList={chatList}
|
||||||
currentConversationId={currentConversationId}
|
currentConversationId={currentConversationId}
|
||||||
@ -1183,6 +1197,10 @@ const Chat = forwardRef<ChatRef, ChatProps>((props, ref) => {
|
|||||||
addedBlockKey={addedBlockKey}
|
addedBlockKey={addedBlockKey}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
) : tab === 'search' ? (
|
||||||
|
<div className="infio-chat-commands">
|
||||||
|
<SearchView />
|
||||||
|
</div>
|
||||||
) : tab === 'commands' ? (
|
) : tab === 'commands' ? (
|
||||||
<div className="infio-chat-commands">
|
<div className="infio-chat-commands">
|
||||||
<CommandsView
|
<CommandsView
|
||||||
|
|||||||
@ -1,14 +1,20 @@
|
|||||||
import { NotebookPen, Server, SquareSlash } from 'lucide-react';
|
import { NotebookPen, Search, Server, SquareSlash } from 'lucide-react';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { t } from '../../lang/helpers';
|
import { t } from '../../lang/helpers';
|
||||||
|
|
||||||
interface HelloInfoProps {
|
interface HelloInfoProps {
|
||||||
onNavigate: (tab: 'commands' | 'custom-mode' | 'mcp') => void;
|
onNavigate: (tab: 'commands' | 'custom-mode' | 'mcp' | 'search') => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const HelloInfo: React.FC<HelloInfoProps> = ({ onNavigate }) => {
|
const HelloInfo: React.FC<HelloInfoProps> = ({ onNavigate }) => {
|
||||||
const navigationItems = [
|
const navigationItems = [
|
||||||
|
{
|
||||||
|
label: '语义搜索',
|
||||||
|
description: '使用 RAG 在笔记库中进行语义搜索',
|
||||||
|
icon: <Search size={20} />,
|
||||||
|
action: () => onNavigate('search'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: t('chat.navigation.commands'),
|
label: t('chat.navigation.commands'),
|
||||||
description: t('chat.navigation.commandsDesc'),
|
description: t('chat.navigation.commandsDesc'),
|
||||||
|
|||||||
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
|
||||||
|
|
||||||
14
src/components/chat-view/chat-input/SearchButton.tsx
Normal file
14
src/components/chat-view/chat-input/SearchButton.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
192
src/components/chat-view/chat-input/SearchInputWithActions.tsx
Normal file
192
src/components/chat-view/chat-input/SearchInputWithActions.tsx
Normal 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
|
||||||
@ -102,6 +102,7 @@ export class RAGEngine {
|
|||||||
async processQuery({
|
async processQuery({
|
||||||
query,
|
query,
|
||||||
scope,
|
scope,
|
||||||
|
limit,
|
||||||
onQueryProgressChange,
|
onQueryProgressChange,
|
||||||
}: {
|
}: {
|
||||||
query: string
|
query: string
|
||||||
@ -109,6 +110,7 @@ export class RAGEngine {
|
|||||||
files: string[]
|
files: string[]
|
||||||
folders: string[]
|
folders: string[]
|
||||||
}
|
}
|
||||||
|
limit?: number
|
||||||
onQueryProgressChange?: (queryProgress: QueryProgressState) => void
|
onQueryProgressChange?: (queryProgress: QueryProgressState) => void
|
||||||
}): Promise<
|
}): Promise<
|
||||||
(Omit<SelectVector, 'embedding'> & {
|
(Omit<SelectVector, 'embedding'> & {
|
||||||
@ -134,7 +136,7 @@ export class RAGEngine {
|
|||||||
this.embeddingModel,
|
this.embeddingModel,
|
||||||
{
|
{
|
||||||
minSimilarity: this.settings.ragOptions.minSimilarity,
|
minSimilarity: this.settings.ragOptions.minSimilarity,
|
||||||
limit: this.settings.ragOptions.limit,
|
limit: limit ?? this.settings.ragOptions.limit,
|
||||||
scope,
|
scope,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@ -103,6 +103,7 @@ export default {
|
|||||||
viewDetails: "查看详情"
|
viewDetails: "查看详情"
|
||||||
},
|
},
|
||||||
input: {
|
input: {
|
||||||
|
search: "搜索",
|
||||||
submit: "提交",
|
submit: "提交",
|
||||||
collectedModels: "收集的模型",
|
collectedModels: "收集的模型",
|
||||||
loading: "加载中...",
|
loading: "加载中...",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user