mirror of
https://github.com/EthanMarti/infio-copilot.git
synced 2026-01-16 08:21:55 +00:00
Optimize the search view component, add model selection functionality, support multiple search modes (notes, insights, all), update internationalization support, improve user interaction prompts, enhance log output, and ensure better user experience and code readability.
This commit is contained in:
parent
3db334c6e8
commit
c89186a40d
@ -193,7 +193,7 @@ const Chat = forwardRef<ChatRef, ChatProps>((props, ref) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const [tab, setTab] = useState<'chat' | 'commands' | 'custom-mode' | 'mcp' | 'search' | 'history' | 'workspace' | 'insights'>('chat')
|
const [tab, setTab] = useState<'chat' | 'commands' | 'custom-mode' | 'mcp' | 'search' | 'history' | 'workspace' | 'insights'>('search')
|
||||||
|
|
||||||
const [selectedSerializedNodes, setSelectedSerializedNodes] = useState<BaseSerializedNode[]>([])
|
const [selectedSerializedNodes, setSelectedSerializedNodes] = useState<BaseSerializedNode[]>([])
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -14,6 +14,7 @@ import { Mentionable } from '../../types/mentionable'
|
|||||||
import { getFilesWithTag } from '../../utils/glob-utils'
|
import { getFilesWithTag } from '../../utils/glob-utils'
|
||||||
import { openMarkdownFile } from '../../utils/obsidian'
|
import { openMarkdownFile } from '../../utils/obsidian'
|
||||||
|
|
||||||
|
import { ModelSelect } from './chat-input/ModelSelect'
|
||||||
import SearchInputWithActions, { SearchInputRef } from './chat-input/SearchInputWithActions'
|
import SearchInputWithActions, { SearchInputRef } from './chat-input/SearchInputWithActions'
|
||||||
import { editorStateToPlainText } from './chat-input/utils/editor-state-to-plain-text'
|
import { editorStateToPlainText } from './chat-input/utils/editor-state-to-plain-text'
|
||||||
|
|
||||||
@ -31,7 +32,22 @@ interface InsightFileGroup {
|
|||||||
fileName: string
|
fileName: string
|
||||||
maxSimilarity: number
|
maxSimilarity: number
|
||||||
insights: Array<{
|
insights: Array<{
|
||||||
id: string
|
id: number
|
||||||
|
insight: string
|
||||||
|
insight_type: string
|
||||||
|
similarity: number
|
||||||
|
source_path: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
// 聚合文件分组结果接口
|
||||||
|
interface AllFileGroup {
|
||||||
|
path: string
|
||||||
|
fileName: string
|
||||||
|
maxSimilarity: number
|
||||||
|
blocks: (Omit<SelectVector, 'embedding'> & { similarity: number })[]
|
||||||
|
insights: Array<{
|
||||||
|
id: number
|
||||||
insight: string
|
insight: string
|
||||||
insight_type: string
|
insight_type: string
|
||||||
similarity: number
|
similarity: number
|
||||||
@ -45,14 +61,14 @@ const SearchView = () => {
|
|||||||
const app = useApp()
|
const app = useApp()
|
||||||
const { settings } = useSettings()
|
const { settings } = useSettings()
|
||||||
const searchInputRef = useRef<SearchInputRef>(null)
|
const searchInputRef = useRef<SearchInputRef>(null)
|
||||||
|
|
||||||
// 工作区管理器
|
// 工作区管理器
|
||||||
const workspaceManager = useMemo(() => {
|
const workspaceManager = useMemo(() => {
|
||||||
return new WorkspaceManager(app)
|
return new WorkspaceManager(app)
|
||||||
}, [app])
|
}, [app])
|
||||||
const [searchResults, setSearchResults] = useState<(Omit<SelectVector, 'embedding'> & { similarity: number })[]>([])
|
const [searchResults, setSearchResults] = useState<(Omit<SelectVector, 'embedding'> & { similarity: number })[]>([])
|
||||||
const [insightResults, setInsightResults] = useState<Array<{
|
const [insightResults, setInsightResults] = useState<Array<{
|
||||||
id: string
|
id: number
|
||||||
insight: string
|
insight: string
|
||||||
insight_type: string
|
insight_type: string
|
||||||
similarity: number
|
similarity: number
|
||||||
@ -60,14 +76,12 @@ const SearchView = () => {
|
|||||||
}>>([])
|
}>>([])
|
||||||
const [isSearching, setIsSearching] = useState(false)
|
const [isSearching, setIsSearching] = useState(false)
|
||||||
const [hasSearched, setHasSearched] = useState(false)
|
const [hasSearched, setHasSearched] = useState(false)
|
||||||
const [searchMode, setSearchMode] = useState<'notes' | 'insights'>('notes') // 搜索模式:笔记或洞察
|
const [searchMode, setSearchMode] = useState<'notes' | 'insights' | 'all'>('all') // 搜索模式:笔记、洞察或全部
|
||||||
// 展开状态管理 - 默认全部展开
|
// 展开状态管理 - 默认全部展开
|
||||||
const [expandedFiles, setExpandedFiles] = useState<Set<string>>(new Set())
|
const [expandedFiles, setExpandedFiles] = useState<Set<string>>(new Set())
|
||||||
// 新增:mentionables 状态管理
|
// 新增:mentionables 状态管理
|
||||||
const [mentionables, setMentionables] = useState<Mentionable[]>([])
|
const [mentionables, setMentionables] = useState<Mentionable[]>([])
|
||||||
const [searchEditorState, setSearchEditorState] = useState<SerializedEditorState | null>(null)
|
const [searchEditorState, setSearchEditorState] = useState<SerializedEditorState | null>(null)
|
||||||
// 当前搜索范围信息
|
|
||||||
const [currentSearchScope, setCurrentSearchScope] = useState<string>('')
|
|
||||||
|
|
||||||
// 统计信息状态
|
// 统计信息状态
|
||||||
const [statisticsInfo, setStatisticsInfo] = useState<{
|
const [statisticsInfo, setStatisticsInfo] = useState<{
|
||||||
@ -79,12 +93,15 @@ const SearchView = () => {
|
|||||||
// 工作区 RAG 向量初始化状态
|
// 工作区 RAG 向量初始化状态
|
||||||
const [isInitializingRAG, setIsInitializingRAG] = useState(false)
|
const [isInitializingRAG, setIsInitializingRAG] = useState(false)
|
||||||
const [ragInitProgress, setRAGInitProgress] = useState<{
|
const [ragInitProgress, setRAGInitProgress] = useState<{
|
||||||
type: 'indexing' | 'querying' | 'querying-done'
|
type: 'indexing' | 'querying' | 'querying-done' | 'reading-mentionables' | 'reading-files'
|
||||||
indexProgress?: {
|
indexProgress?: {
|
||||||
completedChunks: number
|
completedChunks: number
|
||||||
totalChunks: number
|
totalChunks: number
|
||||||
totalFiles: number
|
totalFiles: number
|
||||||
}
|
}
|
||||||
|
currentFile?: string
|
||||||
|
totalFiles?: number
|
||||||
|
completedFiles?: number
|
||||||
} | null>(null)
|
} | null>(null)
|
||||||
const [ragInitSuccess, setRAGInitSuccess] = useState<{
|
const [ragInitSuccess, setRAGInitSuccess] = useState<{
|
||||||
show: boolean
|
show: boolean
|
||||||
@ -92,7 +109,7 @@ const SearchView = () => {
|
|||||||
totalChunks?: number
|
totalChunks?: number
|
||||||
workspaceName?: string
|
workspaceName?: string
|
||||||
}>({ show: false })
|
}>({ show: false })
|
||||||
|
|
||||||
// 删除和确认对话框状态
|
// 删除和确认对话框状态
|
||||||
const [isDeleting, setIsDeleting] = useState(false)
|
const [isDeleting, setIsDeleting] = useState(false)
|
||||||
const [showRAGInitConfirm, setShowRAGInitConfirm] = useState(false)
|
const [showRAGInitConfirm, setShowRAGInitConfirm] = useState(false)
|
||||||
@ -100,38 +117,37 @@ const SearchView = () => {
|
|||||||
|
|
||||||
const handleSearch = useCallback(async (editorState?: SerializedEditorState) => {
|
const handleSearch = useCallback(async (editorState?: SerializedEditorState) => {
|
||||||
let searchTerm = ''
|
let searchTerm = ''
|
||||||
|
|
||||||
if (editorState) {
|
if (editorState) {
|
||||||
// 使用成熟的函数从 Lexical 编辑器状态中提取文本内容
|
// 使用成熟的函数从 Lexical 编辑器状态中提取文本内容
|
||||||
searchTerm = editorStateToPlainText(editorState).trim()
|
searchTerm = editorStateToPlainText(editorState).trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!searchTerm.trim()) {
|
if (!searchTerm.trim()) {
|
||||||
setSearchResults([])
|
setSearchResults([])
|
||||||
setInsightResults([])
|
setInsightResults([])
|
||||||
setHasSearched(false)
|
setHasSearched(false)
|
||||||
setCurrentSearchScope('')
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsSearching(true)
|
setIsSearching(true)
|
||||||
setHasSearched(true)
|
setHasSearched(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 获取当前工作区
|
// 获取当前工作区
|
||||||
let currentWorkspace: Workspace | null = null
|
let currentWorkspace: Workspace | null = null
|
||||||
if (settings.workspace && settings.workspace !== 'vault') {
|
if (settings.workspace && settings.workspace !== 'vault') {
|
||||||
currentWorkspace = await workspaceManager.findByName(String(settings.workspace))
|
currentWorkspace = await workspaceManager.findByName(String(settings.workspace))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 设置搜索范围信息
|
// 设置搜索范围信息(用于调试)
|
||||||
let scopeDescription = ''
|
let scopeDescription = ''
|
||||||
if (currentWorkspace) {
|
if (currentWorkspace) {
|
||||||
scopeDescription = `工作区: ${currentWorkspace.name}`
|
scopeDescription = `工作区: ${currentWorkspace.name}`
|
||||||
} else {
|
} else {
|
||||||
scopeDescription = '整个 Vault'
|
scopeDescription = '整个 Vault'
|
||||||
}
|
}
|
||||||
setCurrentSearchScope(scopeDescription)
|
console.debug('搜索范围:', scopeDescription)
|
||||||
|
|
||||||
// 构建搜索范围
|
// 构建搜索范围
|
||||||
let scope: { files: string[], folders: string[] } | undefined
|
let scope: { files: string[], folders: string[] } | undefined
|
||||||
@ -164,10 +180,10 @@ const SearchView = () => {
|
|||||||
scope: scope,
|
scope: scope,
|
||||||
limit: 50,
|
limit: 50,
|
||||||
})
|
})
|
||||||
|
|
||||||
setSearchResults(results)
|
setSearchResults(results)
|
||||||
setInsightResults([])
|
setInsightResults([])
|
||||||
} else {
|
} else if (searchMode === 'insights') {
|
||||||
// 搜索洞察
|
// 搜索洞察
|
||||||
const transEngine = await getTransEngine()
|
const transEngine = await getTransEngine()
|
||||||
const results = await transEngine.processQuery({
|
const results = await transEngine.processQuery({
|
||||||
@ -176,9 +192,31 @@ const SearchView = () => {
|
|||||||
limit: 50,
|
limit: 50,
|
||||||
minSimilarity: 0.3,
|
minSimilarity: 0.3,
|
||||||
})
|
})
|
||||||
|
|
||||||
setInsightResults(results as any)
|
setInsightResults(results)
|
||||||
setSearchResults([])
|
setSearchResults([])
|
||||||
|
} else {
|
||||||
|
// 搜索全部:同时搜索原始笔记和洞察
|
||||||
|
const ragEngine = await getRAGEngine()
|
||||||
|
const transEngine = await getTransEngine()
|
||||||
|
|
||||||
|
// 并行执行两个搜索
|
||||||
|
const [notesResults, insightsResults] = await Promise.all([
|
||||||
|
ragEngine.processQuery({
|
||||||
|
query: searchTerm,
|
||||||
|
scope: scope,
|
||||||
|
limit: 25, // 每个类型限制25个结果
|
||||||
|
}),
|
||||||
|
transEngine.processQuery({
|
||||||
|
query: searchTerm,
|
||||||
|
scope: scope,
|
||||||
|
limit: 25, // 每个类型限制25个结果
|
||||||
|
minSimilarity: 0.3,
|
||||||
|
})
|
||||||
|
])
|
||||||
|
|
||||||
|
setSearchResults(notesResults)
|
||||||
|
setInsightResults(insightsResults)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('搜索失败:', error)
|
console.error('搜索失败:', error)
|
||||||
@ -203,7 +241,7 @@ const SearchView = () => {
|
|||||||
// 加载统计信息
|
// 加载统计信息
|
||||||
const loadStatistics = useCallback(async () => {
|
const loadStatistics = useCallback(async () => {
|
||||||
setIsLoadingStats(true)
|
setIsLoadingStats(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 获取当前工作区
|
// 获取当前工作区
|
||||||
let currentWorkspace: Workspace | null = null
|
let currentWorkspace: Workspace | null = null
|
||||||
@ -241,7 +279,7 @@ const SearchView = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ragEngine = await getRAGEngine()
|
const ragEngine = await getRAGEngine()
|
||||||
|
|
||||||
// 使用新的 updateWorkspaceIndex 方法
|
// 使用新的 updateWorkspaceIndex 方法
|
||||||
await ragEngine.updateWorkspaceIndex(
|
await ragEngine.updateWorkspaceIndex(
|
||||||
currentWorkspace,
|
currentWorkspace,
|
||||||
@ -256,7 +294,7 @@ const SearchView = () => {
|
|||||||
|
|
||||||
// 显示成功消息
|
// 显示成功消息
|
||||||
console.log(`✅ 工作区 RAG 向量初始化完成: ${currentWorkspace.name}`)
|
console.log(`✅ 工作区 RAG 向量初始化完成: ${currentWorkspace.name}`)
|
||||||
|
|
||||||
// 显示成功状态
|
// 显示成功状态
|
||||||
setRAGInitSuccess({
|
setRAGInitSuccess({
|
||||||
show: true,
|
show: true,
|
||||||
@ -264,7 +302,7 @@ const SearchView = () => {
|
|||||||
totalChunks: ragInitProgress?.indexProgress?.totalChunks || 0,
|
totalChunks: ragInitProgress?.indexProgress?.totalChunks || 0,
|
||||||
workspaceName: currentWorkspace.name
|
workspaceName: currentWorkspace.name
|
||||||
})
|
})
|
||||||
|
|
||||||
// 3秒后自动隐藏成功消息
|
// 3秒后自动隐藏成功消息
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setRAGInitSuccess({ show: false })
|
setRAGInitSuccess({ show: false })
|
||||||
@ -315,10 +353,7 @@ const SearchView = () => {
|
|||||||
setShowRAGInitConfirm(true)
|
setShowRAGInitConfirm(true)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// 确认删除工作区索引
|
|
||||||
const handleDeleteWorkspaceIndex = useCallback(() => {
|
|
||||||
setShowDeleteConfirm(true)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// 确认初始化 RAG 向量
|
// 确认初始化 RAG 向量
|
||||||
const confirmInitWorkspaceRAG = useCallback(async () => {
|
const confirmInitWorkspaceRAG = useCallback(async () => {
|
||||||
@ -426,7 +461,7 @@ const SearchView = () => {
|
|||||||
// 移除图片显示,避免布局问题
|
// 移除图片显示,避免布局问题
|
||||||
img: () => <span className="obsidian-image-placeholder">[图片]</span>,
|
img: () => <span className="obsidian-image-placeholder">[图片]</span>,
|
||||||
// 代码块样式
|
// 代码块样式
|
||||||
code: ({ children, inline }: { children: React.ReactNode; inline?: boolean; [key: string]: unknown }) => {
|
code: ({ children, inline }: { children: React.ReactNode; inline?: boolean;[key: string]: unknown }) => {
|
||||||
if (inline) {
|
if (inline) {
|
||||||
return <code className="obsidian-inline-code">{children}</code>
|
return <code className="obsidian-inline-code">{children}</code>
|
||||||
}
|
}
|
||||||
@ -449,11 +484,11 @@ const SearchView = () => {
|
|||||||
|
|
||||||
// 按文件路径分组
|
// 按文件路径分组
|
||||||
const fileGroups = new Map<string, FileGroup>()
|
const fileGroups = new Map<string, FileGroup>()
|
||||||
|
|
||||||
searchResults.forEach(result => {
|
searchResults.forEach(result => {
|
||||||
const filePath = result.path
|
const filePath = result.path
|
||||||
const fileName = filePath.split('/').pop() || filePath
|
const fileName = filePath.split('/').pop() || filePath
|
||||||
|
|
||||||
if (!fileGroups.has(filePath)) {
|
if (!fileGroups.has(filePath)) {
|
||||||
fileGroups.set(filePath, {
|
fileGroups.set(filePath, {
|
||||||
path: filePath,
|
path: filePath,
|
||||||
@ -462,7 +497,7 @@ const SearchView = () => {
|
|||||||
blocks: []
|
blocks: []
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const group = fileGroups.get(filePath)
|
const group = fileGroups.get(filePath)
|
||||||
if (group) {
|
if (group) {
|
||||||
group.blocks.push(result)
|
group.blocks.push(result)
|
||||||
@ -488,11 +523,11 @@ const SearchView = () => {
|
|||||||
|
|
||||||
// 按文件路径分组
|
// 按文件路径分组
|
||||||
const fileGroups = new Map<string, InsightFileGroup>()
|
const fileGroups = new Map<string, InsightFileGroup>()
|
||||||
|
|
||||||
insightResults.forEach(result => {
|
insightResults.forEach(result => {
|
||||||
const filePath = result.source_path
|
const filePath = result.source_path
|
||||||
const fileName = filePath.split('/').pop() || filePath
|
const fileName = filePath.split('/').pop() || filePath
|
||||||
|
|
||||||
if (!fileGroups.has(filePath)) {
|
if (!fileGroups.has(filePath)) {
|
||||||
fileGroups.set(filePath, {
|
fileGroups.set(filePath, {
|
||||||
path: filePath,
|
path: filePath,
|
||||||
@ -501,7 +536,7 @@ const SearchView = () => {
|
|||||||
insights: []
|
insights: []
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const group = fileGroups.get(filePath)
|
const group = fileGroups.get(filePath)
|
||||||
if (group) {
|
if (group) {
|
||||||
group.insights.push(result)
|
group.insights.push(result)
|
||||||
@ -521,8 +556,63 @@ const SearchView = () => {
|
|||||||
return Array.from(fileGroups.values()).sort((a, b) => b.maxSimilarity - a.maxSimilarity)
|
return Array.from(fileGroups.values()).sort((a, b) => b.maxSimilarity - a.maxSimilarity)
|
||||||
}, [insightResults])
|
}, [insightResults])
|
||||||
|
|
||||||
|
// 按文件分组并排序 - 全部聚合
|
||||||
|
const allGroupedResults = useMemo(() => {
|
||||||
|
if (!searchResults.length && !insightResults.length) return []
|
||||||
|
|
||||||
|
// 合并所有文件路径
|
||||||
|
const allFilePaths = new Set<string>()
|
||||||
|
|
||||||
|
// 从笔记结果中收集文件路径
|
||||||
|
searchResults.forEach(result => {
|
||||||
|
allFilePaths.add(result.path)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 从洞察结果中收集文件路径
|
||||||
|
insightResults.forEach(result => {
|
||||||
|
allFilePaths.add(result.source_path)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 按文件路径分组
|
||||||
|
const fileGroups = new Map<string, AllFileGroup>()
|
||||||
|
|
||||||
|
// 处理每个文件
|
||||||
|
Array.from(allFilePaths).forEach(filePath => {
|
||||||
|
const fileName = filePath.split('/').pop() || filePath
|
||||||
|
|
||||||
|
// 获取该文件的笔记块
|
||||||
|
const fileBlocks = searchResults.filter(result => result.path === filePath)
|
||||||
|
|
||||||
|
// 获取该文件的洞察
|
||||||
|
const fileInsights = insightResults.filter(result => result.source_path === filePath)
|
||||||
|
|
||||||
|
// 计算该文件的最高相似度
|
||||||
|
const blockMaxSimilarity = fileBlocks.length > 0 ? Math.max(...fileBlocks.map(b => b.similarity)) : 0
|
||||||
|
const insightMaxSimilarity = fileInsights.length > 0 ? Math.max(...fileInsights.map(i => i.similarity)) : 0
|
||||||
|
const maxSimilarity = Math.max(blockMaxSimilarity, insightMaxSimilarity)
|
||||||
|
|
||||||
|
if (fileBlocks.length > 0 || fileInsights.length > 0) {
|
||||||
|
// 对块和洞察分别按相似度排序
|
||||||
|
fileBlocks.sort((a, b) => b.similarity - a.similarity)
|
||||||
|
fileInsights.sort((a, b) => b.similarity - a.similarity)
|
||||||
|
|
||||||
|
fileGroups.set(filePath, {
|
||||||
|
path: filePath,
|
||||||
|
fileName,
|
||||||
|
maxSimilarity,
|
||||||
|
blocks: fileBlocks,
|
||||||
|
insights: fileInsights
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 将文件按最高相似度排序
|
||||||
|
return Array.from(fileGroups.values()).sort((a, b) => b.maxSimilarity - a.maxSimilarity)
|
||||||
|
}, [searchResults, insightResults])
|
||||||
|
|
||||||
const totalBlocks = searchResults.length
|
const totalBlocks = searchResults.length
|
||||||
const totalFiles = groupedResults.length
|
const totalFiles = groupedResults.length
|
||||||
|
const totalAllFiles = allGroupedResults.length
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="obsidian-search-container">
|
<div className="obsidian-search-container">
|
||||||
@ -530,29 +620,11 @@ const SearchView = () => {
|
|||||||
<div className="obsidian-search-header-wrapper">
|
<div className="obsidian-search-header-wrapper">
|
||||||
<div className="obsidian-search-title">
|
<div className="obsidian-search-title">
|
||||||
<h3>语义索引</h3>
|
<h3>语义索引</h3>
|
||||||
<div className="obsidian-search-actions">
|
|
||||||
<button
|
|
||||||
onClick={handleInitWorkspaceRAG}
|
|
||||||
disabled={isInitializingRAG || isDeleting || isSearching}
|
|
||||||
className="obsidian-search-init-btn"
|
|
||||||
title={statisticsInfo && (statisticsInfo.totalFiles > 0 || statisticsInfo.totalChunks > 0) ? '更新索引' : '初始化索引'}
|
|
||||||
>
|
|
||||||
{isInitializingRAG ? '🔄 正在初始化...' : (statisticsInfo && (statisticsInfo.totalFiles > 0 || statisticsInfo.totalChunks > 0) ? '🔄 更新索引' : '🚀 初始化索引')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleDeleteWorkspaceIndex}
|
|
||||||
disabled={isDeleting || isInitializingRAG || isSearching}
|
|
||||||
className="obsidian-search-delete-btn"
|
|
||||||
title="清除索引"
|
|
||||||
>
|
|
||||||
{isDeleting ? '🗑️ 正在清除...' : '🗑️ 清除索引'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 统计信息 */}
|
{/* 统计信息 */}
|
||||||
{!isLoadingStats && statisticsInfo && (
|
<div className="obsidian-search-stats">
|
||||||
<div className="obsidian-search-stats">
|
{!isLoadingStats && statisticsInfo && (
|
||||||
<div className="obsidian-search-stats-overview">
|
<div className="obsidian-search-stats-overview">
|
||||||
<div className="obsidian-search-stats-main">
|
<div className="obsidian-search-stats-main">
|
||||||
<span className="obsidian-search-stats-number">{statisticsInfo.totalChunks}</span>
|
<span className="obsidian-search-stats-number">{statisticsInfo.totalChunks}</span>
|
||||||
@ -566,6 +638,79 @@ const SearchView = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="infio-search-model-info">
|
||||||
|
<div className="infio-search-model-row">
|
||||||
|
<span className="infio-search-model-label">嵌入模型:</span>
|
||||||
|
<ModelSelect modelType="embedding" />
|
||||||
|
</div>
|
||||||
|
<div className="obsidian-search-actions">
|
||||||
|
<button
|
||||||
|
onClick={handleInitWorkspaceRAG}
|
||||||
|
disabled={isInitializingRAG || isDeleting || isSearching}
|
||||||
|
className="obsidian-search-init-btn"
|
||||||
|
title={statisticsInfo && (statisticsInfo.totalFiles > 0 || statisticsInfo.totalChunks > 0) ? '更新索引' : '初始化索引'}
|
||||||
|
>
|
||||||
|
{isInitializingRAG ? '正在初始化...' : (statisticsInfo && (statisticsInfo.totalFiles > 0 || statisticsInfo.totalChunks > 0) ? '更新索引' : '初始化索引')}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 索引进度 */}
|
||||||
|
{isInitializingRAG && (
|
||||||
|
<div className="obsidian-rag-initializing">
|
||||||
|
<div className="obsidian-rag-init-header">
|
||||||
|
<h4>正在初始化工作区 RAG 向量索引</h4>
|
||||||
|
<p>为当前工作区的文件建立向量索引,提高搜索精度</p>
|
||||||
|
</div>
|
||||||
|
{ragInitProgress && ragInitProgress.type === 'indexing' && ragInitProgress.indexProgress && (
|
||||||
|
<div className="obsidian-rag-progress">
|
||||||
|
<div className="obsidian-rag-progress-info">
|
||||||
|
<span className="obsidian-rag-progress-stage">建立向量索引</span>
|
||||||
|
<span className="obsidian-rag-progress-counter">
|
||||||
|
{ragInitProgress.indexProgress.completedChunks} / {ragInitProgress.indexProgress.totalChunks} 块
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="obsidian-rag-progress-bar">
|
||||||
|
<div
|
||||||
|
className="obsidian-rag-progress-fill"
|
||||||
|
style={{
|
||||||
|
width: `${(ragInitProgress.indexProgress.completedChunks / Math.max(ragInitProgress.indexProgress.totalChunks, 1)) * 100}%`
|
||||||
|
}}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<div className="obsidian-rag-progress-details">
|
||||||
|
<div className="obsidian-rag-progress-files">
|
||||||
|
共 {ragInitProgress.indexProgress.totalFiles} 个文件
|
||||||
|
</div>
|
||||||
|
<div className="obsidian-rag-progress-percentage">
|
||||||
|
{Math.round((ragInitProgress.indexProgress.completedChunks / Math.max(ragInitProgress.indexProgress.totalChunks, 1)) * 100)}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* RAG 初始化成功消息 */}
|
||||||
|
{ragInitSuccess.show && (
|
||||||
|
<div className="obsidian-rag-success">
|
||||||
|
<div className="obsidian-rag-success-content">
|
||||||
|
<span className="obsidian-rag-success-icon">✅</span>
|
||||||
|
<div className="obsidian-rag-success-text">
|
||||||
|
<span className="obsidian-rag-success-title">
|
||||||
|
工作区 RAG 向量索引初始化完成: {ragInitSuccess.workspaceName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="obsidian-rag-success-close"
|
||||||
|
onClick={() => setRAGInitSuccess({ show: false })}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -581,107 +726,26 @@ const SearchView = () => {
|
|||||||
placeholder="语义搜索(按回车键搜索)..."
|
placeholder="语义搜索(按回车键搜索)..."
|
||||||
autoFocus={true}
|
autoFocus={true}
|
||||||
disabled={isSearching}
|
disabled={isSearching}
|
||||||
|
searchMode={searchMode}
|
||||||
|
onSearchModeChange={setSearchMode}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 搜索模式切换 */}
|
|
||||||
<div className="obsidian-search-mode-toggle">
|
|
||||||
<button
|
|
||||||
className={`obsidian-search-mode-btn ${searchMode === 'notes' ? 'active' : ''}`}
|
|
||||||
onClick={() => setSearchMode('notes')}
|
|
||||||
title="搜索原始笔记内容"
|
|
||||||
>
|
|
||||||
📝 原始笔记
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`obsidian-search-mode-btn ${searchMode === 'insights' ? 'active' : ''}`}
|
|
||||||
onClick={() => setSearchMode('insights')}
|
|
||||||
title="搜索 AI 洞察内容"
|
|
||||||
>
|
|
||||||
🧠 AI 洞察
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 结果统计 */}
|
{/* 索引统计 */}
|
||||||
{hasSearched && !isSearching && (
|
{hasSearched && !isSearching && (
|
||||||
<div className="obsidian-search-stats">
|
<div className="obsidian-search-stats">
|
||||||
<div className="obsidian-search-stats-line">
|
<div className="obsidian-search-stats-line">
|
||||||
{searchMode === 'notes' ? (
|
{searchMode === 'notes' ? (
|
||||||
`${totalFiles} 个文件,${totalBlocks} 个块`
|
`${totalFiles} 个文件,${totalBlocks} 个块`
|
||||||
) : (
|
) : searchMode === 'insights' ? (
|
||||||
`${insightGroupedResults.length} 个文件,${insightResults.length} 个洞察`
|
`${insightGroupedResults.length} 个文件,${insightResults.length} 个洞察`
|
||||||
|
) : (
|
||||||
|
`${totalAllFiles} 个文件,${totalBlocks} 个块,${insightResults.length} 个洞察`
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 搜索进度 */}
|
|
||||||
{isSearching && (
|
|
||||||
<div className="obsidian-search-loading">
|
|
||||||
正在搜索...
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* RAG 初始化进度 */}
|
|
||||||
{isInitializingRAG && (
|
|
||||||
<div className="obsidian-rag-initializing">
|
|
||||||
<div className="obsidian-rag-init-header">
|
|
||||||
<h4>正在初始化工作区 RAG 向量索引</h4>
|
|
||||||
<p>为当前工作区的文件建立向量索引,提高搜索精度</p>
|
|
||||||
</div>
|
|
||||||
{ragInitProgress && ragInitProgress.type === 'indexing' && ragInitProgress.indexProgress && (
|
|
||||||
<div className="obsidian-rag-progress">
|
|
||||||
<div className="obsidian-rag-progress-info">
|
|
||||||
<span className="obsidian-rag-progress-stage">建立向量索引</span>
|
|
||||||
<span className="obsidian-rag-progress-counter">
|
|
||||||
{ragInitProgress.indexProgress.completedChunks} / {ragInitProgress.indexProgress.totalChunks} 块
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="obsidian-rag-progress-bar">
|
|
||||||
<div
|
|
||||||
className="obsidian-rag-progress-fill"
|
|
||||||
style={{
|
|
||||||
width: `${(ragInitProgress.indexProgress.completedChunks / Math.max(ragInitProgress.indexProgress.totalChunks, 1)) * 100}%`
|
|
||||||
}}
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
<div className="obsidian-rag-progress-details">
|
|
||||||
<div className="obsidian-rag-progress-files">
|
|
||||||
共 {ragInitProgress.indexProgress.totalFiles} 个文件
|
|
||||||
</div>
|
|
||||||
<div className="obsidian-rag-progress-percentage">
|
|
||||||
{Math.round((ragInitProgress.indexProgress.completedChunks / Math.max(ragInitProgress.indexProgress.totalChunks, 1)) * 100)}%
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* RAG 初始化成功消息 */}
|
|
||||||
{ragInitSuccess.show && (
|
|
||||||
<div className="obsidian-rag-success">
|
|
||||||
<div className="obsidian-rag-success-content">
|
|
||||||
<span className="obsidian-rag-success-icon">✅</span>
|
|
||||||
<div className="obsidian-rag-success-text">
|
|
||||||
<span className="obsidian-rag-success-title">
|
|
||||||
工作区 RAG 向量索引初始化完成: {ragInitSuccess.workspaceName}
|
|
||||||
</span>
|
|
||||||
<span className="obsidian-rag-success-summary">
|
|
||||||
处理了 {ragInitSuccess.totalFiles} 个文件,生成 {ragInitSuccess.totalChunks} 个向量块
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
className="obsidian-rag-success-close"
|
|
||||||
onClick={() => setRAGInitSuccess({ show: false })}
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 确认删除对话框 */}
|
{/* 确认删除对话框 */}
|
||||||
{showDeleteConfirm && (
|
{showDeleteConfirm && (
|
||||||
<div className="obsidian-confirm-dialog-overlay">
|
<div className="obsidian-confirm-dialog-overlay">
|
||||||
@ -727,20 +791,20 @@ const SearchView = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="obsidian-confirm-dialog-body">
|
<div className="obsidian-confirm-dialog-body">
|
||||||
<p>
|
<p>
|
||||||
{statisticsInfo && (statisticsInfo.totalFiles > 0 || statisticsInfo.totalChunks > 0)
|
{statisticsInfo && (statisticsInfo.totalFiles > 0 || statisticsInfo.totalChunks > 0)
|
||||||
? '将更新当前工作区的向量索引,重新处理所有文件以确保索引最新。'
|
? '将更新当前工作区的向量索引,重新处理所有文件以确保索引最新。'
|
||||||
: '将为当前工作区的所有文件建立向量索引,这将提高语义搜索的准确性。'
|
: '将为当前工作区的所有文件建立向量索引,这将提高语义搜索的准确性。'
|
||||||
}
|
}
|
||||||
</p>
|
</p>
|
||||||
<div className="obsidian-confirm-dialog-info">
|
<div className="obsidian-confirm-dialog-info">
|
||||||
<div className="obsidian-confirm-dialog-info-item">
|
<div className="obsidian-confirm-dialog-info-item">
|
||||||
<strong>嵌入模型:</strong>
|
<strong>嵌入模型:</strong>
|
||||||
<span className="obsidian-confirm-dialog-model">
|
<span className="obsidian-confirm-dialog-model">
|
||||||
{settings.embeddingModelProvider} / {settings.embeddingModelId || '默认模型'}
|
{settings.embeddingModelId}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="obsidian-confirm-dialog-info-item">
|
<div className="obsidian-confirm-dialog-info-item">
|
||||||
<strong>工作区:</strong>
|
<strong>工作区:</strong>
|
||||||
<span className="obsidian-confirm-dialog-workspace">
|
<span className="obsidian-confirm-dialog-workspace">
|
||||||
{settings.workspace === 'vault' ? '整个 Vault' : settings.workspace}
|
{settings.workspace === 'vault' ? '整个 Vault' : settings.workspace}
|
||||||
</span>
|
</span>
|
||||||
@ -768,6 +832,13 @@ const SearchView = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 搜索进度 */}
|
||||||
|
{isSearching && (
|
||||||
|
<div className="obsidian-search-loading">
|
||||||
|
正在搜索...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 搜索结果 */}
|
{/* 搜索结果 */}
|
||||||
<div className="obsidian-search-results">
|
<div className="obsidian-search-results">
|
||||||
{searchMode === 'notes' ? (
|
{searchMode === 'notes' ? (
|
||||||
@ -777,7 +848,7 @@ const SearchView = () => {
|
|||||||
{groupedResults.map((fileGroup) => (
|
{groupedResults.map((fileGroup) => (
|
||||||
<div key={fileGroup.path} className="obsidian-file-group">
|
<div key={fileGroup.path} className="obsidian-file-group">
|
||||||
{/* 文件头部 */}
|
{/* 文件头部 */}
|
||||||
<div
|
<div
|
||||||
className="obsidian-file-header"
|
className="obsidian-file-header"
|
||||||
onClick={() => toggleFileExpansion(fileGroup.path)}
|
onClick={() => toggleFileExpansion(fileGroup.path)}
|
||||||
>
|
>
|
||||||
@ -827,14 +898,14 @@ const SearchView = () => {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
) : (
|
) : searchMode === 'insights' ? (
|
||||||
// AI 洞察搜索结果
|
// AI 洞察搜索结果
|
||||||
!isSearching && insightGroupedResults.length > 0 && (
|
!isSearching && insightGroupedResults.length > 0 && (
|
||||||
<div className="obsidian-results-list">
|
<div className="obsidian-results-list">
|
||||||
{insightGroupedResults.map((fileGroup) => (
|
{insightGroupedResults.map((fileGroup) => (
|
||||||
<div key={fileGroup.path} className="obsidian-file-group">
|
<div key={fileGroup.path} className="obsidian-file-group">
|
||||||
{/* 文件头部 */}
|
{/* 文件头部 */}
|
||||||
<div
|
<div
|
||||||
className="obsidian-file-header"
|
className="obsidian-file-header"
|
||||||
onClick={() => toggleFileExpansion(fileGroup.path)}
|
onClick={() => toggleFileExpansion(fileGroup.path)}
|
||||||
>
|
>
|
||||||
@ -885,21 +956,131 @@ const SearchView = () => {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
) : (
|
||||||
|
// 全部搜索结果:按文件聚合显示原始笔记和洞察
|
||||||
|
!isSearching && allGroupedResults.length > 0 && (
|
||||||
|
<div className="obsidian-results-list">
|
||||||
|
{allGroupedResults.map((fileGroup) => (
|
||||||
|
<div key={fileGroup.path} className="obsidian-file-group">
|
||||||
|
{/* 文件头部 */}
|
||||||
|
<div
|
||||||
|
className="obsidian-file-header"
|
||||||
|
onClick={() => toggleFileExpansion(fileGroup.path)}
|
||||||
|
>
|
||||||
|
<div className="obsidian-file-header-content">
|
||||||
|
<div className="obsidian-file-header-top">
|
||||||
|
<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-name">{fileGroup.fileName}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="obsidian-file-path-row">
|
||||||
|
<span className="obsidian-file-path">{fileGroup.path}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 文件内容:混合显示笔记块和洞察 */}
|
||||||
|
{expandedFiles.has(fileGroup.path) && (
|
||||||
|
<div className="obsidian-file-blocks">
|
||||||
|
{/* AI 洞察 */}
|
||||||
|
{fileGroup.insights.map((insight, insightIndex) => (
|
||||||
|
<div
|
||||||
|
key={`insight-${insight.id}`}
|
||||||
|
className="obsidian-result-item obsidian-result-insight"
|
||||||
|
>
|
||||||
|
<div className="obsidian-result-header">
|
||||||
|
<span className="obsidian-result-index">{insightIndex + 1}</span>
|
||||||
|
<span className="obsidian-result-insight-type">
|
||||||
|
{insight.insight_type.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
<span className="obsidian-result-similarity">
|
||||||
|
{insight.similarity.toFixed(3)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="obsidian-result-content">
|
||||||
|
<div className="obsidian-insight-content">
|
||||||
|
{insight.insight}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{/* 原始笔记块 */}
|
||||||
|
{fileGroup.blocks.map((result, blockIndex) => (
|
||||||
|
<div
|
||||||
|
key={`block-${result.id}`}
|
||||||
|
className="obsidian-result-item obsidian-result-block"
|
||||||
|
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 && (
|
{!isSearching && hasSearched && (
|
||||||
(searchMode === 'notes' && groupedResults.length === 0) ||
|
(searchMode === 'notes' && groupedResults.length === 0) ||
|
||||||
(searchMode === 'insights' && insightGroupedResults.length === 0)
|
(searchMode === 'insights' && insightGroupedResults.length === 0) ||
|
||||||
|
(searchMode === 'all' && allGroupedResults.length === 0)
|
||||||
) && (
|
) && (
|
||||||
<div className="obsidian-no-results">
|
<div className="obsidian-no-results">
|
||||||
<p>未找到相关结果</p>
|
<p>未找到相关结果</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 样式 */}
|
{/* 样式 */}
|
||||||
<style>
|
<style>
|
||||||
{`
|
{`
|
||||||
|
.infio-search-model-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--size-4-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-search-model-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--size-2-2);
|
||||||
|
border: 1px solid var(--background-modifier-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: var(--size-2-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-search-model-label {
|
||||||
|
font-size: var(--font-ui-small);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: var(--font-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-search-model-value {
|
||||||
|
font-size: var(--font-ui-small);
|
||||||
|
color: var(--text-accent);
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: var(--font-monospace);
|
||||||
|
}
|
||||||
|
|
||||||
.obsidian-search-container {
|
.obsidian-search-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@ -1064,38 +1245,6 @@ const SearchView = () => {
|
|||||||
/* padding 由父元素控制 */
|
/* padding 由父元素控制 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.obsidian-search-mode-toggle {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
margin-top: 8px;
|
|
||||||
padding: 4px;
|
|
||||||
background-color: var(--background-modifier-border);
|
|
||||||
border-radius: var(--radius-m);
|
|
||||||
}
|
|
||||||
|
|
||||||
.obsidian-search-mode-btn {
|
|
||||||
flex: 1;
|
|
||||||
padding: 6px 12px;
|
|
||||||
background-color: transparent;
|
|
||||||
border: none;
|
|
||||||
border-radius: var(--radius-s);
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: var(--font-ui-small);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.obsidian-search-mode-btn:hover {
|
|
||||||
background-color: var(--background-modifier-hover);
|
|
||||||
color: var(--text-normal);
|
|
||||||
}
|
|
||||||
|
|
||||||
.obsidian-search-mode-btn.active {
|
|
||||||
background-color: var(--interactive-accent);
|
|
||||||
color: var(--text-on-accent);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.obsidian-search-stats {
|
.obsidian-search-stats {
|
||||||
@ -1395,13 +1544,70 @@ const SearchView = () => {
|
|||||||
cursor: text;
|
cursor: text;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 全部搜索结果分组样式 */
|
||||||
|
.obsidian-result-section {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.obsidian-result-section-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background-color: var(--background-modifier-border);
|
||||||
|
border-radius: var(--radius-s);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.obsidian-result-section-title {
|
||||||
|
color: var(--text-normal);
|
||||||
|
font-size: var(--font-ui-medium);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.obsidian-result-section-count {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: var(--font-ui-small);
|
||||||
|
font-family: var(--font-monospace);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 全部模式下的类型徽章样式 */
|
||||||
|
.obsidian-result-type-badge {
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: var(--radius-s);
|
||||||
|
font-size: var(--font-ui-smaller);
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: var(--font-monospace);
|
||||||
|
margin-right: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.obsidian-result-type-note {
|
||||||
|
background-color: var(--color-blue-light, #e3f2fd);
|
||||||
|
color: var(--color-blue-dark, #1976d2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.obsidian-result-type-insight {
|
||||||
|
background-color: var(--color-amber-light, #fff3e0);
|
||||||
|
color: var(--color-amber-dark, #f57c00);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 全部模式下的结果项样式 */
|
||||||
|
.obsidian-result-block {
|
||||||
|
border-left: 3px solid var(--color-blue, #2196f3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.obsidian-result-insight {
|
||||||
|
border-left: 3px solid var(--color-amber, #ff9800);
|
||||||
|
}
|
||||||
|
|
||||||
/* RAG 初始化进度样式 */
|
/* RAG 初始化进度样式 */
|
||||||
.obsidian-rag-initializing {
|
.obsidian-rag-initializing {
|
||||||
padding: 20px;
|
padding: 12px;
|
||||||
background-color: var(--background-secondary);
|
background-color: var(--background-secondary);
|
||||||
border: 1px solid var(--background-modifier-border);
|
border: 1px solid var(--background-modifier-border);
|
||||||
border-radius: var(--radius-m);
|
border-radius: var(--radius-m);
|
||||||
margin: 12px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.obsidian-rag-init-header {
|
.obsidian-rag-init-header {
|
||||||
@ -1488,7 +1694,7 @@ const SearchView = () => {
|
|||||||
background-color: var(--background-secondary);
|
background-color: var(--background-secondary);
|
||||||
border: 1px solid var(--color-green, #28a745);
|
border: 1px solid var(--color-green, #28a745);
|
||||||
border-radius: var(--radius-m);
|
border-radius: var(--radius-m);
|
||||||
margin: 12px;
|
margin-bottom: 12px;
|
||||||
animation: slideInFromTop 0.3s ease-out;
|
animation: slideInFromTop 0.3s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1702,5 +1908,5 @@ const SearchView = () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default SearchView
|
export default SearchView
|
||||||
|
|
||||||
|
|||||||
@ -31,6 +31,7 @@ import OnMutationPlugin, {
|
|||||||
} from './plugins/on-mutation/OnMutationPlugin'
|
} from './plugins/on-mutation/OnMutationPlugin'
|
||||||
|
|
||||||
export type LexicalContentEditableProps = {
|
export type LexicalContentEditableProps = {
|
||||||
|
rootTheme?: string
|
||||||
editorRef: RefObject<LexicalEditor>
|
editorRef: RefObject<LexicalEditor>
|
||||||
contentEditableRef: RefObject<HTMLDivElement>
|
contentEditableRef: RefObject<HTMLDivElement>
|
||||||
onChange?: (content: SerializedEditorState) => void
|
onChange?: (content: SerializedEditorState) => void
|
||||||
@ -52,6 +53,7 @@ export type LexicalContentEditableProps = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function LexicalContentEditable({
|
export default function LexicalContentEditable({
|
||||||
|
rootTheme,
|
||||||
editorRef,
|
editorRef,
|
||||||
contentEditableRef,
|
contentEditableRef,
|
||||||
onChange,
|
onChange,
|
||||||
@ -68,7 +70,7 @@ export default function LexicalContentEditable({
|
|||||||
const initialConfig: InitialConfigType = {
|
const initialConfig: InitialConfigType = {
|
||||||
namespace: 'LexicalContentEditable',
|
namespace: 'LexicalContentEditable',
|
||||||
theme: {
|
theme: {
|
||||||
root: 'infio-chat-lexical-content-editable-root',
|
root: rootTheme || 'infio-chat-lexical-content-editable-root',
|
||||||
paragraph: 'infio-chat-lexical-content-editable-paragraph',
|
paragraph: 'infio-chat-lexical-content-editable-paragraph',
|
||||||
},
|
},
|
||||||
nodes: [MentionNode],
|
nodes: [MentionNode],
|
||||||
|
|||||||
@ -6,17 +6,17 @@ import { useEffect, useMemo, useRef, useState } from 'react'
|
|||||||
import { useSettings } from '../../../contexts/SettingsContext'
|
import { useSettings } from '../../../contexts/SettingsContext'
|
||||||
import { t } from '../../../lang/helpers'
|
import { t } from '../../../lang/helpers'
|
||||||
import { ApiProvider } from '../../../types/llm/model'
|
import { ApiProvider } from '../../../types/llm/model'
|
||||||
import { GetAllProviders, GetProviderModelIds } from "../../../utils/api"
|
import { GetAllProviders, GetEmbeddingProviders, GetEmbeddingProviderModelIds, GetProviderModelsWithSettings } from "../../../utils/api"
|
||||||
|
|
||||||
// 优化模型名称显示的函数
|
// 优化模型名称显示的函数
|
||||||
const getOptimizedModelName = (modelId: string): string => {
|
const getOptimizedModelName = (modelId: string): string => {
|
||||||
if (!modelId) return modelId;
|
if (!modelId) return modelId;
|
||||||
|
|
||||||
// 限制长度,如果太长则截断并添加省略号
|
// 限制长度,如果太长则截断并添加省略号
|
||||||
if (modelId.length > 25) {
|
if (modelId.length > 25) {
|
||||||
return modelId.substring(0, 22) + '...';
|
return modelId.substring(0, 22) + '...';
|
||||||
}
|
}
|
||||||
|
|
||||||
return modelId;
|
return modelId;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -146,25 +146,69 @@ const HighlightedText: React.FC<{ segments: TextSegment[] }> = ({ segments }) =>
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ModelSelect() {
|
type ModelType = 'chat' | 'insight' | 'apply' | 'embedding'
|
||||||
|
|
||||||
|
interface ModelSelectProps {
|
||||||
|
modelType?: ModelType
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ModelSelect({ modelType = 'chat' }: ModelSelectProps) {
|
||||||
const { settings, setSettings } = useSettings()
|
const { settings, setSettings } = useSettings()
|
||||||
const [isOpen, setIsOpen] = useState(false)
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
const [modelProvider, setModelProvider] = useState(settings.chatModelProvider)
|
|
||||||
const [chatModelId, setChatModelId] = useState(settings.chatModelId)
|
// 根据模型类型获取相应的设置
|
||||||
|
const currentModelProvider = useMemo(() => {
|
||||||
|
switch (modelType) {
|
||||||
|
case 'insight':
|
||||||
|
return settings.insightModelProvider
|
||||||
|
case 'apply':
|
||||||
|
return settings.applyModelProvider
|
||||||
|
case 'embedding':
|
||||||
|
return settings.embeddingModelProvider
|
||||||
|
default:
|
||||||
|
return settings.chatModelProvider
|
||||||
|
}
|
||||||
|
}, [modelType, settings.insightModelProvider, settings.applyModelProvider, settings.embeddingModelProvider, settings.chatModelProvider])
|
||||||
|
|
||||||
|
const currentModelId = useMemo(() => {
|
||||||
|
switch (modelType) {
|
||||||
|
case 'insight':
|
||||||
|
return settings.insightModelId
|
||||||
|
case 'apply':
|
||||||
|
return settings.applyModelId
|
||||||
|
case 'embedding':
|
||||||
|
return settings.embeddingModelId
|
||||||
|
default:
|
||||||
|
return settings.chatModelId
|
||||||
|
}
|
||||||
|
}, [modelType, settings.insightModelId, settings.applyModelId, settings.embeddingModelId, settings.chatModelId])
|
||||||
|
|
||||||
|
const [modelProvider, setModelProvider] = useState(currentModelProvider)
|
||||||
|
const [chatModelId, setChatModelId] = useState(currentModelId)
|
||||||
const [modelIds, setModelIds] = useState<string[]>([])
|
const [modelIds, setModelIds] = useState<string[]>([])
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
const [searchTerm, setSearchTerm] = useState("")
|
const [searchTerm, setSearchTerm] = useState("")
|
||||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||||
const inputRef = useRef<HTMLInputElement>(null)
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
const providers = GetAllProviders()
|
const providers = useMemo(() => {
|
||||||
|
if (modelType === 'embedding') {
|
||||||
|
return GetEmbeddingProviders()
|
||||||
|
}
|
||||||
|
return GetAllProviders()
|
||||||
|
}, [modelType])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchModels = async () => {
|
const fetchModels = async () => {
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
try {
|
try {
|
||||||
const models = await GetProviderModelIds(modelProvider, settings)
|
if (modelType === 'embedding') {
|
||||||
setModelIds(models)
|
const models = GetEmbeddingProviderModelIds(modelProvider)
|
||||||
|
setModelIds(models)
|
||||||
|
} else {
|
||||||
|
const models = await GetProviderModelsWithSettings(modelProvider, settings)
|
||||||
|
setModelIds(Object.keys(models))
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch provider models:', error)
|
console.error('Failed to fetch provider models:', error)
|
||||||
setModelIds([])
|
setModelIds([])
|
||||||
@ -176,16 +220,30 @@ export function ModelSelect() {
|
|||||||
fetchModels()
|
fetchModels()
|
||||||
}, [modelProvider, settings])
|
}, [modelProvider, settings])
|
||||||
|
|
||||||
// Sync chat model id & chat model provider
|
// Sync model id & model provider based on modelType
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setModelProvider(settings.chatModelProvider)
|
setModelProvider(currentModelProvider)
|
||||||
setChatModelId(settings.chatModelId)
|
setChatModelId(currentModelId)
|
||||||
}, [settings.chatModelProvider, settings.chatModelId])
|
}, [currentModelProvider, currentModelId])
|
||||||
|
|
||||||
const searchableItems = useMemo(() => {
|
const searchableItems = useMemo(() => {
|
||||||
|
// 根据模型类型获取相应的收藏列表
|
||||||
|
const getCollectedModels = () => {
|
||||||
|
switch (modelType) {
|
||||||
|
case 'insight':
|
||||||
|
return settings.collectedInsightModels || []
|
||||||
|
case 'apply':
|
||||||
|
return settings.collectedApplyModels || []
|
||||||
|
case 'embedding':
|
||||||
|
return settings.collectedEmbeddingModels || []
|
||||||
|
default:
|
||||||
|
return settings.collectedChatModels || []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 检查是否在收藏列表中
|
// 检查是否在收藏列表中
|
||||||
const isInCollected = (id: string) => {
|
const isInCollected = (id: string) => {
|
||||||
return settings.collectedChatModels?.some(item => item.provider === modelProvider && item.modelId === id) || false;
|
return getCollectedModels().some(item => item.provider === modelProvider && item.modelId === id) || false;
|
||||||
};
|
};
|
||||||
|
|
||||||
return modelIds.map((id) => ({
|
return modelIds.map((id) => ({
|
||||||
@ -194,7 +252,7 @@ export function ModelSelect() {
|
|||||||
provider: modelProvider,
|
provider: modelProvider,
|
||||||
isCollected: isInCollected(id),
|
isCollected: isInCollected(id),
|
||||||
}))
|
}))
|
||||||
}, [modelIds, modelProvider, settings.collectedChatModels])
|
}, [modelIds, modelProvider, modelType, settings.collectedChatModels, settings.collectedInsightModels, settings.collectedApplyModels, settings.collectedEmbeddingModels])
|
||||||
|
|
||||||
const fuse = useMemo(() => {
|
const fuse = useMemo(() => {
|
||||||
return new Fuse<SearchableItem>(searchableItems, {
|
return new Fuse<SearchableItem>(searchableItems, {
|
||||||
@ -229,11 +287,26 @@ export function ModelSelect() {
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
const isCurrentlyCollected = settings.collectedChatModels?.some(
|
// 根据模型类型获取相应的收藏列表
|
||||||
|
const getCollectedModels = () => {
|
||||||
|
switch (modelType) {
|
||||||
|
case 'insight':
|
||||||
|
return settings.collectedInsightModels || []
|
||||||
|
case 'apply':
|
||||||
|
return settings.collectedApplyModels || []
|
||||||
|
case 'embedding':
|
||||||
|
return settings.collectedEmbeddingModels || []
|
||||||
|
default:
|
||||||
|
return settings.collectedChatModels || []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentCollectedModels = getCollectedModels();
|
||||||
|
const isCurrentlyCollected = currentCollectedModels.some(
|
||||||
item => item.provider === modelProvider && item.modelId === id
|
item => item.provider === modelProvider && item.modelId === id
|
||||||
);
|
);
|
||||||
|
|
||||||
let newCollectedModels = settings.collectedChatModels || [];
|
let newCollectedModels = [...currentCollectedModels];
|
||||||
|
|
||||||
if (isCurrentlyCollected) {
|
if (isCurrentlyCollected) {
|
||||||
// remove
|
// remove
|
||||||
@ -245,10 +318,33 @@ export function ModelSelect() {
|
|||||||
newCollectedModels = [...newCollectedModels, { provider: modelProvider, modelId: id }];
|
newCollectedModels = [...newCollectedModels, { provider: modelProvider, modelId: id }];
|
||||||
}
|
}
|
||||||
|
|
||||||
setSettings({
|
// 根据模型类型更新相应的设置
|
||||||
...settings,
|
switch (modelType) {
|
||||||
collectedChatModels: newCollectedModels,
|
case 'insight':
|
||||||
});
|
setSettings({
|
||||||
|
...settings,
|
||||||
|
collectedInsightModels: newCollectedModels,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'apply':
|
||||||
|
setSettings({
|
||||||
|
...settings,
|
||||||
|
collectedApplyModels: newCollectedModels,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'embedding':
|
||||||
|
setSettings({
|
||||||
|
...settings,
|
||||||
|
collectedEmbeddingModels: newCollectedModels,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
setSettings({
|
||||||
|
...settings,
|
||||||
|
collectedChatModels: newCollectedModels,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -258,7 +354,7 @@ export function ModelSelect() {
|
|||||||
{/* <div className="infio-chat-input-model-select__mode-icon">
|
{/* <div className="infio-chat-input-model-select__mode-icon">
|
||||||
<Brain size={16} />
|
<Brain size={16} />
|
||||||
</div> */}
|
</div> */}
|
||||||
<div
|
<div
|
||||||
className="infio-chat-input-model-select__model-name"
|
className="infio-chat-input-model-select__model-name"
|
||||||
title={chatModelId}
|
title={chatModelId}
|
||||||
>
|
>
|
||||||
@ -272,62 +368,128 @@ export function ModelSelect() {
|
|||||||
<DropdownMenu.Portal>
|
<DropdownMenu.Portal>
|
||||||
<DropdownMenu.Content className="infio-popover infio-llm-setting-combobox-dropdown">
|
<DropdownMenu.Content className="infio-popover infio-llm-setting-combobox-dropdown">
|
||||||
{/* collected models */}
|
{/* collected models */}
|
||||||
{settings.collectedChatModels?.length > 0 && (
|
{(() => {
|
||||||
<div className="infio-model-section">
|
const getCollectedModels = () => {
|
||||||
<div className="infio-model-section-title">
|
switch (modelType) {
|
||||||
<Star size={12} className="infio-star-active" /> {t('chat.input.collectedModels')}
|
case 'insight':
|
||||||
</div>
|
return settings.collectedInsightModels || []
|
||||||
<ul className="infio-collected-models-list">
|
case 'apply':
|
||||||
{settings.collectedChatModels.map((collectedModel, index) => (
|
return settings.collectedApplyModels || []
|
||||||
<DropdownMenu.Item
|
case 'embedding':
|
||||||
key={`${collectedModel.provider}-${collectedModel.modelId}`}
|
return settings.collectedEmbeddingModels || []
|
||||||
onSelect={() => {
|
default:
|
||||||
setSettings({
|
return settings.collectedChatModels || []
|
||||||
...settings,
|
}
|
||||||
chatModelProvider: collectedModel.provider,
|
}
|
||||||
chatModelId: collectedModel.modelId,
|
|
||||||
})
|
|
||||||
setChatModelId(collectedModel.modelId)
|
|
||||||
setSearchTerm("")
|
|
||||||
setIsOpen(false)
|
|
||||||
}}
|
|
||||||
className={`infio-llm-setting-combobox-option ${index === selectedIndex ? 'is-selected' : ''}`}
|
|
||||||
onMouseEnter={() => setSelectedIndex(index)}
|
|
||||||
asChild
|
|
||||||
>
|
|
||||||
<li
|
|
||||||
className="infio-llm-setting-model-item infio-collected-model-item"
|
|
||||||
title={`${collectedModel.provider}/${collectedModel.modelId}`}
|
|
||||||
>
|
|
||||||
<div className="infio-model-item-text-wrapper">
|
|
||||||
<span className="infio-provider-badge">{collectedModel.provider}</span>
|
|
||||||
<span title={collectedModel.modelId}>{collectedModel.modelId}</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="infio-model-item-star"
|
|
||||||
title="remove from collected models"
|
|
||||||
>
|
|
||||||
<Star size={16} className="infio-star-active" onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
e.preventDefault();
|
|
||||||
// delete
|
|
||||||
const newCollectedModels = settings.collectedChatModels.filter(
|
|
||||||
item => !(item.provider === collectedModel.provider && item.modelId === collectedModel.modelId)
|
|
||||||
);
|
|
||||||
|
|
||||||
setSettings({
|
const collectedModels = getCollectedModels()
|
||||||
...settings,
|
|
||||||
collectedChatModels: newCollectedModels,
|
return collectedModels.length > 0 ? (
|
||||||
});
|
<div className="infio-model-section">
|
||||||
}} />
|
<div className="infio-model-section-title">
|
||||||
</div>
|
<Star size={12} className="infio-star-active" /> {t('chat.input.collectedModels')}
|
||||||
</li>
|
</div>
|
||||||
</DropdownMenu.Item>
|
<ul className="infio-collected-models-list">
|
||||||
))}
|
{collectedModels.map((collectedModel, index) => (
|
||||||
</ul>
|
<DropdownMenu.Item
|
||||||
<div className="infio-model-separator"></div>
|
key={`${collectedModel.provider}-${collectedModel.modelId}`}
|
||||||
</div>
|
onSelect={() => {
|
||||||
)}
|
// 根据模型类型更新相应的设置
|
||||||
|
switch (modelType) {
|
||||||
|
case 'insight':
|
||||||
|
setSettings({
|
||||||
|
...settings,
|
||||||
|
insightModelProvider: collectedModel.provider,
|
||||||
|
insightModelId: collectedModel.modelId,
|
||||||
|
})
|
||||||
|
break;
|
||||||
|
case 'apply':
|
||||||
|
setSettings({
|
||||||
|
...settings,
|
||||||
|
applyModelProvider: collectedModel.provider,
|
||||||
|
applyModelId: collectedModel.modelId,
|
||||||
|
})
|
||||||
|
break;
|
||||||
|
case 'embedding':
|
||||||
|
setSettings({
|
||||||
|
...settings,
|
||||||
|
embeddingModelProvider: collectedModel.provider,
|
||||||
|
embeddingModelId: collectedModel.modelId,
|
||||||
|
})
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
setSettings({
|
||||||
|
...settings,
|
||||||
|
chatModelProvider: collectedModel.provider,
|
||||||
|
chatModelId: collectedModel.modelId,
|
||||||
|
})
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
setChatModelId(collectedModel.modelId)
|
||||||
|
setSearchTerm("")
|
||||||
|
setIsOpen(false)
|
||||||
|
}}
|
||||||
|
className={`infio-llm-setting-combobox-option ${index === selectedIndex ? 'is-selected' : ''}`}
|
||||||
|
onMouseEnter={() => setSelectedIndex(index)}
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
<li
|
||||||
|
className="infio-llm-setting-model-item infio-collected-model-item"
|
||||||
|
title={`${collectedModel.provider}/${collectedModel.modelId}`}
|
||||||
|
>
|
||||||
|
<div className="infio-model-item-text-wrapper">
|
||||||
|
<span className="infio-provider-badge">{collectedModel.provider}</span>
|
||||||
|
<span title={collectedModel.modelId}>{collectedModel.modelId}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="infio-model-item-star"
|
||||||
|
title="remove from collected models"
|
||||||
|
>
|
||||||
|
<Star size={16} className="infio-star-active" onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
// delete
|
||||||
|
const newCollectedModels = collectedModels.filter(
|
||||||
|
item => !(item.provider === collectedModel.provider && item.modelId === collectedModel.modelId)
|
||||||
|
);
|
||||||
|
|
||||||
|
// 根据模型类型更新相应的设置
|
||||||
|
switch (modelType) {
|
||||||
|
case 'insight':
|
||||||
|
setSettings({
|
||||||
|
...settings,
|
||||||
|
collectedInsightModels: newCollectedModels,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'apply':
|
||||||
|
setSettings({
|
||||||
|
...settings,
|
||||||
|
collectedApplyModels: newCollectedModels,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'embedding':
|
||||||
|
setSettings({
|
||||||
|
...settings,
|
||||||
|
collectedEmbeddingModels: newCollectedModels,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
setSettings({
|
||||||
|
...settings,
|
||||||
|
collectedChatModels: newCollectedModels,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<div className="infio-model-separator"></div>
|
||||||
|
</div>
|
||||||
|
) : null
|
||||||
|
})()}
|
||||||
|
|
||||||
<div className="infio-llm-setting-search-container">
|
<div className="infio-llm-setting-search-container">
|
||||||
<div className="infio-llm-setting-provider-container">
|
<div className="infio-llm-setting-provider-container">
|
||||||
@ -384,11 +546,37 @@ export function ModelSelect() {
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const selectedOption = filteredOptions[selectedIndex]
|
const selectedOption = filteredOptions[selectedIndex]
|
||||||
if (selectedOption) {
|
if (selectedOption) {
|
||||||
setSettings({
|
// 根据模型类型更新相应的设置
|
||||||
...settings,
|
switch (modelType) {
|
||||||
chatModelProvider: modelProvider,
|
case 'insight':
|
||||||
chatModelId: selectedOption.id,
|
setSettings({
|
||||||
})
|
...settings,
|
||||||
|
insightModelProvider: modelProvider,
|
||||||
|
insightModelId: selectedOption.id,
|
||||||
|
})
|
||||||
|
break;
|
||||||
|
case 'apply':
|
||||||
|
setSettings({
|
||||||
|
...settings,
|
||||||
|
applyModelProvider: modelProvider,
|
||||||
|
applyModelId: selectedOption.id,
|
||||||
|
})
|
||||||
|
break;
|
||||||
|
case 'embedding':
|
||||||
|
setSettings({
|
||||||
|
...settings,
|
||||||
|
embeddingModelProvider: modelProvider,
|
||||||
|
embeddingModelId: selectedOption.id,
|
||||||
|
})
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
setSettings({
|
||||||
|
...settings,
|
||||||
|
chatModelProvider: modelProvider,
|
||||||
|
chatModelId: selectedOption.id,
|
||||||
|
})
|
||||||
|
break;
|
||||||
|
}
|
||||||
setChatModelId(selectedOption.id)
|
setChatModelId(selectedOption.id)
|
||||||
setSearchTerm("")
|
setSearchTerm("")
|
||||||
setIsOpen(false)
|
setIsOpen(false)
|
||||||
@ -421,11 +609,37 @@ export function ModelSelect() {
|
|||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter") {
|
if (e.key === "Enter") {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setSettings({
|
// 根据模型类型更新相应的设置
|
||||||
...settings,
|
switch (modelType) {
|
||||||
chatModelProvider: modelProvider,
|
case 'insight':
|
||||||
chatModelId: searchTerm,
|
setSettings({
|
||||||
})
|
...settings,
|
||||||
|
insightModelProvider: modelProvider,
|
||||||
|
insightModelId: searchTerm,
|
||||||
|
})
|
||||||
|
break;
|
||||||
|
case 'apply':
|
||||||
|
setSettings({
|
||||||
|
...settings,
|
||||||
|
applyModelProvider: modelProvider,
|
||||||
|
applyModelId: searchTerm,
|
||||||
|
})
|
||||||
|
break;
|
||||||
|
case 'embedding':
|
||||||
|
setSettings({
|
||||||
|
...settings,
|
||||||
|
embeddingModelProvider: modelProvider,
|
||||||
|
embeddingModelId: searchTerm,
|
||||||
|
})
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
setSettings({
|
||||||
|
...settings,
|
||||||
|
chatModelProvider: modelProvider,
|
||||||
|
chatModelId: searchTerm,
|
||||||
|
})
|
||||||
|
break;
|
||||||
|
}
|
||||||
setChatModelId(searchTerm)
|
setChatModelId(searchTerm)
|
||||||
setIsOpen(false)
|
setIsOpen(false)
|
||||||
}
|
}
|
||||||
@ -448,11 +662,37 @@ export function ModelSelect() {
|
|||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
key={option.id}
|
key={option.id}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
setSettings({
|
// 根据模型类型更新相应的设置
|
||||||
...settings,
|
switch (modelType) {
|
||||||
chatModelProvider: modelProvider,
|
case 'insight':
|
||||||
chatModelId: option.id,
|
setSettings({
|
||||||
})
|
...settings,
|
||||||
|
insightModelProvider: modelProvider,
|
||||||
|
insightModelId: option.id,
|
||||||
|
})
|
||||||
|
break;
|
||||||
|
case 'apply':
|
||||||
|
setSettings({
|
||||||
|
...settings,
|
||||||
|
applyModelProvider: modelProvider,
|
||||||
|
applyModelId: option.id,
|
||||||
|
})
|
||||||
|
break;
|
||||||
|
case 'embedding':
|
||||||
|
setSettings({
|
||||||
|
...settings,
|
||||||
|
embeddingModelProvider: modelProvider,
|
||||||
|
embeddingModelId: option.id,
|
||||||
|
})
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
setSettings({
|
||||||
|
...settings,
|
||||||
|
chatModelProvider: modelProvider,
|
||||||
|
chatModelId: option.id,
|
||||||
|
})
|
||||||
|
break;
|
||||||
|
}
|
||||||
setChatModelId(option.id)
|
setChatModelId(option.id)
|
||||||
setSearchTerm("")
|
setSearchTerm("")
|
||||||
setIsOpen(false)
|
setIsOpen(false)
|
||||||
@ -460,9 +700,21 @@ export function ModelSelect() {
|
|||||||
className={`infio-llm-setting-combobox-option ${isSelected ? 'is-selected' : ''}`}
|
className={`infio-llm-setting-combobox-option ${isSelected ? 'is-selected' : ''}`}
|
||||||
onMouseEnter={() => {
|
onMouseEnter={() => {
|
||||||
// 计算正确的鼠标悬停索引
|
// 计算正确的鼠标悬停索引
|
||||||
|
const getCollectedModels = () => {
|
||||||
|
switch (modelType) {
|
||||||
|
case 'insight':
|
||||||
|
return settings.collectedInsightModels || []
|
||||||
|
case 'apply':
|
||||||
|
return settings.collectedApplyModels || []
|
||||||
|
case 'embedding':
|
||||||
|
return settings.collectedEmbeddingModels || []
|
||||||
|
default:
|
||||||
|
return settings.collectedChatModels || []
|
||||||
|
}
|
||||||
|
}
|
||||||
const hoverIndex = searchTerm
|
const hoverIndex = searchTerm
|
||||||
? index
|
? index
|
||||||
: index + settings.collectedChatModels?.length;
|
: index + getCollectedModels().length;
|
||||||
setSelectedIndex(hoverIndex);
|
setSelectedIndex(hoverIndex);
|
||||||
}}
|
}}
|
||||||
asChild
|
asChild
|
||||||
@ -475,7 +727,7 @@ export function ModelSelect() {
|
|||||||
{searchTerm ? (
|
{searchTerm ? (
|
||||||
<HighlightedText segments={option.html} />
|
<HighlightedText segments={option.html} />
|
||||||
) : (
|
) : (
|
||||||
<span title={option.id}>{option.id}</span>
|
<span title={option.id}>{option.id}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@ -617,9 +869,8 @@ export function ModelSelect() {
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
appearance: none;
|
appearance: none;
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
background-color: var(--background-primary);
|
|
||||||
border: 1px solid var(--background-modifier-border);
|
border: 1px solid var(--background-modifier-border);
|
||||||
border-radius: 6px;
|
border-radius: 4px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
@ -634,7 +885,6 @@ export function ModelSelect() {
|
|||||||
|
|
||||||
.infio-llm-setting-provider-switch:hover {
|
.infio-llm-setting-provider-switch:hover {
|
||||||
border-color: var(--interactive-accent);
|
border-color: var(--interactive-accent);
|
||||||
background-color: var(--background-primary-alt);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.infio-llm-setting-provider-switch:focus {
|
.infio-llm-setting-provider-switch:focus {
|
||||||
@ -728,12 +978,9 @@ export function ModelSelect() {
|
|||||||
.infio-provider-badge {
|
.infio-provider-badge {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
background-color: var(--background-modifier-hover);
|
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
margin-right: 6px;
|
margin-right: 6px;
|
||||||
color: var(--text-muted);
|
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
|
||||||
}
|
}
|
||||||
`}
|
`}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -6,10 +6,12 @@ import {
|
|||||||
useState
|
useState
|
||||||
} from 'react'
|
} from 'react'
|
||||||
|
|
||||||
|
|
||||||
import { Mentionable } from '../../../types/mentionable'
|
import { Mentionable } from '../../../types/mentionable'
|
||||||
|
|
||||||
import LexicalContentEditable from './LexicalContentEditable'
|
import LexicalContentEditable from './LexicalContentEditable'
|
||||||
import { SearchButton } from './SearchButton'
|
import { SearchButton } from './SearchButton'
|
||||||
|
import { SearchModeSelect } from './SearchModeSelect'
|
||||||
|
|
||||||
export type SearchInputRef = {
|
export type SearchInputRef = {
|
||||||
focus: () => void
|
focus: () => void
|
||||||
@ -25,26 +27,28 @@ export type SearchInputProps = {
|
|||||||
placeholder?: string
|
placeholder?: string
|
||||||
autoFocus?: boolean
|
autoFocus?: boolean
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
|
searchMode?: 'notes' | 'insights' | 'all'
|
||||||
|
onSearchModeChange?: (mode: 'notes' | 'insights' | 'all') => void
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查编辑器状态是否为空的辅助函数
|
// 检查编辑器状态是否为空
|
||||||
const isEditorStateEmpty = (editorState: SerializedEditorState): boolean => {
|
const isEditorStateEmpty = (editorState: SerializedEditorState): boolean => {
|
||||||
if (!editorState || !editorState.root || !editorState.root.children) {
|
try {
|
||||||
|
const root = editorState.root
|
||||||
|
if (!root || !root.children) return true
|
||||||
|
|
||||||
|
// 检查是否有实际内容
|
||||||
|
const hasContent = root.children.some((child: any) => {
|
||||||
|
if (child.type === 'paragraph') {
|
||||||
|
return child.children && child.children.length > 0
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
return !hasContent
|
||||||
|
} catch (error) {
|
||||||
return true
|
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>(
|
const SearchInputWithActions = forwardRef<SearchInputRef, SearchInputProps>(
|
||||||
@ -56,6 +60,8 @@ const SearchInputWithActions = forwardRef<SearchInputRef, SearchInputProps>(
|
|||||||
placeholder = '',
|
placeholder = '',
|
||||||
autoFocus = false,
|
autoFocus = false,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
|
searchMode = 'all',
|
||||||
|
onSearchModeChange,
|
||||||
},
|
},
|
||||||
ref
|
ref
|
||||||
) => {
|
) => {
|
||||||
@ -112,6 +118,7 @@ const SearchInputWithActions = forwardRef<SearchInputRef, SearchInputProps>(
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<LexicalContentEditable
|
<LexicalContentEditable
|
||||||
|
rootTheme="infio-search-lexical-content-editable-root"
|
||||||
initialEditorState={(editor) => {
|
initialEditorState={(editor) => {
|
||||||
if (initialSerializedEditorState) {
|
if (initialSerializedEditorState) {
|
||||||
editor.setEditorState(
|
editor.setEditorState(
|
||||||
@ -139,7 +146,13 @@ const SearchInputWithActions = forwardRef<SearchInputRef, SearchInputProps>(
|
|||||||
|
|
||||||
<div className="infio-chat-user-input-controls">
|
<div className="infio-chat-user-input-controls">
|
||||||
<div className="infio-chat-user-input-controls__model-select-container">
|
<div className="infio-chat-user-input-controls__model-select-container">
|
||||||
{/* TODO: add model select */}
|
{onSearchModeChange && (
|
||||||
|
<SearchModeSelect
|
||||||
|
searchMode={searchMode}
|
||||||
|
onSearchModeChange={onSearchModeChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div className="infio-chat-user-input-controls__buttons">
|
<div className="infio-chat-user-input-controls__buttons">
|
||||||
<SearchButton onClick={() => handleSubmit()} />
|
<SearchButton onClick={() => handleSubmit()} />
|
||||||
|
|||||||
164
src/components/chat-view/chat-input/SearchModeSelect.tsx
Normal file
164
src/components/chat-view/chat-input/SearchModeSelect.tsx
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
|
||||||
|
import { ChevronDown, ChevronUp, FileText, Lightbulb, Globe } from 'lucide-react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
interface SearchModeSelectProps {
|
||||||
|
searchMode: 'notes' | 'insights' | 'all'
|
||||||
|
onSearchModeChange: (mode: 'notes' | 'insights' | 'all') => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SearchModeSelect({ searchMode, onSearchModeChange }: SearchModeSelectProps) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
|
||||||
|
const searchModes = [
|
||||||
|
{
|
||||||
|
value: 'all' as const,
|
||||||
|
name: '全部',
|
||||||
|
icon: <Globe size={14} />,
|
||||||
|
description: '聚合搜索原始笔记和 AI 洞察'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'notes' as const,
|
||||||
|
name: '原始笔记',
|
||||||
|
icon: <FileText size={14} />,
|
||||||
|
description: '搜索原始笔记内容'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'insights' as const,
|
||||||
|
name: 'AI 洞察',
|
||||||
|
icon: <Lightbulb size={14} />,
|
||||||
|
description: '搜索 AI 洞察内容'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const currentMode = searchModes.find((m) => m.value === searchMode)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DropdownMenu.Root open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<DropdownMenu.Trigger className="infio-chat-input-search-mode-select">
|
||||||
|
<span className="infio-search-mode-icon">{currentMode?.icon}</span>
|
||||||
|
<div className="infio-chat-input-search-mode-select__mode-name">
|
||||||
|
{currentMode?.name}
|
||||||
|
</div>
|
||||||
|
<div className="infio-chat-input-search-mode-select__icon">
|
||||||
|
{isOpen ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
|
||||||
|
</div>
|
||||||
|
</DropdownMenu.Trigger>
|
||||||
|
|
||||||
|
<DropdownMenu.Portal>
|
||||||
|
<DropdownMenu.Content
|
||||||
|
className="infio-popover infio-search-mode-select-content">
|
||||||
|
<ul>
|
||||||
|
{searchModes.map((mode) => (
|
||||||
|
<DropdownMenu.Item
|
||||||
|
key={mode.value}
|
||||||
|
onSelect={() => {
|
||||||
|
onSearchModeChange(mode.value)
|
||||||
|
}}
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
<li className="infio-search-mode-item">
|
||||||
|
<div className="infio-search-mode-left">
|
||||||
|
<span className="infio-search-mode-icon">{mode.icon}</span>
|
||||||
|
<div className="infio-search-mode-info">
|
||||||
|
<span className="infio-search-mode-name">{mode.name}</span>
|
||||||
|
<span className="infio-search-mode-description">{mode.description}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Portal>
|
||||||
|
</DropdownMenu.Root>
|
||||||
|
<style>{`
|
||||||
|
button.infio-chat-input-search-mode-select {
|
||||||
|
background-color: transparent;
|
||||||
|
box-shadow: none;
|
||||||
|
border: 1px solid var(--background-modifier-border);
|
||||||
|
padding: var(--size-2-1) var(--size-2-2);
|
||||||
|
font-size: var(--font-smallest);
|
||||||
|
font-weight: var(--font-medium);
|
||||||
|
color: var(--text-muted);
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
height: auto;
|
||||||
|
max-width: 100%;
|
||||||
|
gap: var(--size-2-2);
|
||||||
|
border-radius: var(--radius-l);
|
||||||
|
transition: all 0.15s ease-in-out;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--text-normal);
|
||||||
|
background-color: var(--background-modifier-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-chat-input-search-mode-select__mode-name {
|
||||||
|
flex-shrink: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-chat-input-search-mode-select__icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-search-mode-select-content {
|
||||||
|
min-width: auto !important;
|
||||||
|
width: fit-content !important;
|
||||||
|
max-width: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-search-mode-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--size-4-2) var(--size-4-2);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-search-mode-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--size-2-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-search-mode-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--text-accent);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-search-mode-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--size-2-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-search-mode-name {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-weight: var(--font-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-search-mode-description {
|
||||||
|
font-size: var(--font-smallest);
|
||||||
|
color: var(--text-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -254,7 +254,7 @@ export class VectorManager {
|
|||||||
|
|
||||||
await backOff(
|
await backOff(
|
||||||
async () => {
|
async () => {
|
||||||
// 在嵌入之前处理 markdown,只处理一次
|
// 在嵌入之前处理 markdown
|
||||||
const cleanedBatchData = batchChunks.map(chunk => {
|
const cleanedBatchData = batchChunks.map(chunk => {
|
||||||
const cleanContent = removeMarkdown(chunk.content).replace(/\0/g, '')
|
const cleanContent = removeMarkdown(chunk.content).replace(/\0/g, '')
|
||||||
return { chunk, cleanContent }
|
return { chunk, cleanContent }
|
||||||
|
|||||||
@ -8,36 +8,153 @@ interface EmbedResult {
|
|||||||
vec: number[];
|
vec: number[];
|
||||||
tokens: number;
|
tokens: number;
|
||||||
embed_input?: string;
|
embed_input?: string;
|
||||||
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 定义工作器消息的参数类型
|
||||||
|
interface LoadParams {
|
||||||
|
model_key: string;
|
||||||
|
use_gpu?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EmbedBatchParams {
|
||||||
|
inputs: EmbedInput[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type WorkerParams = LoadParams | EmbedBatchParams | string | undefined;
|
||||||
|
|
||||||
interface WorkerMessage {
|
interface WorkerMessage {
|
||||||
method: string;
|
method: string;
|
||||||
params: any;
|
params: WorkerParams;
|
||||||
id: number;
|
id: number;
|
||||||
worker_id?: string;
|
worker_id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WorkerResponse {
|
interface WorkerResponse {
|
||||||
id: number;
|
id: number;
|
||||||
result?: any;
|
result?: unknown;
|
||||||
error?: string;
|
error?: string;
|
||||||
worker_id?: string;
|
worker_id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 定义 Transformers.js 相关类型
|
||||||
|
interface TransformersEnv {
|
||||||
|
allowLocalModels: boolean;
|
||||||
|
allowRemoteModels: boolean;
|
||||||
|
backends: {
|
||||||
|
onnx: {
|
||||||
|
wasm: {
|
||||||
|
numThreads: number;
|
||||||
|
simd: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
useFS: boolean;
|
||||||
|
useBrowserCache: boolean;
|
||||||
|
remoteHost?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PipelineOptions {
|
||||||
|
quantized?: boolean;
|
||||||
|
progress_callback?: (progress: unknown) => void;
|
||||||
|
device?: string;
|
||||||
|
dtype?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ModelInfo {
|
||||||
|
loaded: boolean;
|
||||||
|
model_key: string;
|
||||||
|
use_gpu: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TokenizerResult {
|
||||||
|
input_ids: {
|
||||||
|
data: number[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GlobalTransformers {
|
||||||
|
pipelineFactory: (task: string, model: string, options?: PipelineOptions) => Promise<unknown>;
|
||||||
|
AutoTokenizer: {
|
||||||
|
from_pretrained: (model: string) => Promise<unknown>;
|
||||||
|
};
|
||||||
|
env: TransformersEnv;
|
||||||
|
}
|
||||||
|
|
||||||
// 全局变量
|
// 全局变量
|
||||||
let model: any = null;
|
let model: ModelInfo | null = null;
|
||||||
let pipeline: any = null;
|
let pipeline: unknown = null;
|
||||||
let tokenizer: any = null;
|
let tokenizer: unknown = null;
|
||||||
let processing_message = false;
|
let processing_message = false;
|
||||||
let transformersLoaded = false;
|
let transformersLoaded = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试一个网络端点是否可访问
|
||||||
|
* @param {string} url 要测试的 URL
|
||||||
|
* @param {number} timeout 超时时间 (毫秒)
|
||||||
|
* @returns {Promise<boolean>} 如果可访问则返回 true,否则返回 false
|
||||||
|
*/
|
||||||
|
async function testEndpoint(url: string, timeout = 3000): Promise<boolean> {
|
||||||
|
// AbortController 用于在超时后取消 fetch 请求
|
||||||
|
const controller = new AbortController();
|
||||||
|
const signal = controller.signal;
|
||||||
|
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
console.log(`请求 ${url} 超时。`);
|
||||||
|
controller.abort();
|
||||||
|
}, timeout);
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log(`正在测试端点: ${url}`);
|
||||||
|
// 我们使用 'HEAD' 方法,因为它只请求头部信息,非常快速,适合做存活检测。
|
||||||
|
// 'no-cors' 模式允许我们在浏览器环境中进行跨域请求以进行简单的可达性测试,
|
||||||
|
// 即使我们不能读取响应内容,请求成功也意味着网络是通的。
|
||||||
|
await fetch(url, { method: 'HEAD', mode: 'no-cors', signal });
|
||||||
|
|
||||||
|
// 如果 fetch 成功,清除超时定时器并返回 true
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
console.log(`端点 ${url} 可访问。`);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
// 如果发生网络错误或请求被中止 (超时),则进入 catch 块
|
||||||
|
clearTimeout(timeoutId); // 同样需要清除定时器
|
||||||
|
console.warn(`无法访问端点 ${url}:`, error instanceof Error && error.name === 'AbortError' ? '超时' : (error as Error).message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化 Hugging Face 端点,如果默认的不可用,则自动切换到备用镜像。
|
||||||
|
*/
|
||||||
|
async function initializeEndpoint(): Promise<void> {
|
||||||
|
const defaultEndpoint = 'https://huggingface.co';
|
||||||
|
const fallbackEndpoint = 'https://hf-mirror.com';
|
||||||
|
|
||||||
|
const isDefaultReachable = await testEndpoint(defaultEndpoint);
|
||||||
|
|
||||||
|
const globalTransformers = globalThis as unknown as { transformers?: GlobalTransformers };
|
||||||
|
|
||||||
|
if (!isDefaultReachable) {
|
||||||
|
console.log(`默认端点不可达,将切换到备用镜像: ${fallbackEndpoint}`);
|
||||||
|
// 这是关键步骤:在代码中设置 endpoint
|
||||||
|
if (globalTransformers.transformers?.env) {
|
||||||
|
globalTransformers.transformers.env.remoteHost = fallbackEndpoint;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(`将使用默认端点: ${defaultEndpoint}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 动态导入 Transformers.js
|
// 动态导入 Transformers.js
|
||||||
async function loadTransformers() {
|
async function loadTransformers(): Promise<void> {
|
||||||
if (transformersLoaded) return;
|
if (transformersLoaded) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log('Loading Transformers.js...');
|
console.log('Loading Transformers.js...');
|
||||||
|
|
||||||
|
// 首先初始化端点
|
||||||
|
await initializeEndpoint();
|
||||||
|
|
||||||
// 尝试使用旧版本的 Transformers.js,它在 Worker 中更稳定
|
// 尝试使用旧版本的 Transformers.js,它在 Worker 中更稳定
|
||||||
const { pipeline: pipelineFactory, env, AutoTokenizer } = await import('@xenova/transformers');
|
const { pipeline: pipelineFactory, env, AutoTokenizer } = await import('@xenova/transformers');
|
||||||
|
|
||||||
@ -53,9 +170,12 @@ async function loadTransformers() {
|
|||||||
env.useFS = false;
|
env.useFS = false;
|
||||||
env.useBrowserCache = true;
|
env.useBrowserCache = true;
|
||||||
|
|
||||||
(globalThis as any).pipelineFactory = pipelineFactory;
|
const globalTransformers = globalThis as unknown as { transformers?: GlobalTransformers };
|
||||||
(globalThis as any).AutoTokenizer = AutoTokenizer;
|
globalTransformers.transformers = {
|
||||||
(globalThis as any).env = env;
|
pipelineFactory,
|
||||||
|
AutoTokenizer,
|
||||||
|
env
|
||||||
|
};
|
||||||
|
|
||||||
transformersLoaded = true;
|
transformersLoaded = true;
|
||||||
console.log('Transformers.js loaded successfully');
|
console.log('Transformers.js loaded successfully');
|
||||||
@ -65,22 +185,27 @@ async function loadTransformers() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadModel(modelKey: string, useGpu: boolean = false) {
|
async function loadModel(modelKey: string, useGpu: boolean = false): Promise<{ model_loaded: boolean }> {
|
||||||
try {
|
try {
|
||||||
console.log(`Loading model: ${modelKey}, GPU: ${useGpu}`);
|
console.log(`Loading model: ${modelKey}, GPU: ${useGpu}`);
|
||||||
|
|
||||||
// 确保 Transformers.js 已加载
|
// 确保 Transformers.js 已加载
|
||||||
await loadTransformers();
|
await loadTransformers();
|
||||||
|
|
||||||
const pipelineFactory = (globalThis as any).pipelineFactory;
|
const globalTransformers = globalThis as unknown as { transformers?: GlobalTransformers };
|
||||||
const AutoTokenizer = (globalThis as any).AutoTokenizer;
|
const transformers = globalTransformers.transformers;
|
||||||
const env = (globalThis as any).env;
|
|
||||||
|
if (!transformers) {
|
||||||
|
throw new Error('Transformers.js not loaded');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { pipelineFactory, AutoTokenizer } = transformers;
|
||||||
|
|
||||||
// 配置管道选项
|
// 配置管道选项
|
||||||
const pipelineOpts: any = {
|
const pipelineOpts: PipelineOptions = {
|
||||||
quantized: true,
|
quantized: true,
|
||||||
// 修复进度回调,添加错误处理
|
// 修复进度回调,添加错误处理
|
||||||
progress_callback: (progress: any) => {
|
progress_callback: (progress: unknown) => {
|
||||||
try {
|
try {
|
||||||
if (progress && typeof progress === 'object') {
|
if (progress && typeof progress === 'object') {
|
||||||
// console.log('Model loading progress:', progress);
|
// console.log('Model loading progress:', progress);
|
||||||
@ -96,9 +221,9 @@ async function loadModel(modelKey: string, useGpu: boolean = false) {
|
|||||||
if (useGpu) {
|
if (useGpu) {
|
||||||
try {
|
try {
|
||||||
// 检查 WebGPU 支持
|
// 检查 WebGPU 支持
|
||||||
console.log("useGpu", useGpu)
|
console.log("useGpu", useGpu);
|
||||||
if (typeof navigator !== 'undefined' && 'gpu' in navigator) {
|
if (typeof navigator !== 'undefined' && 'gpu' in navigator) {
|
||||||
const gpu = (navigator as any).gpu;
|
const gpu = (navigator as { gpu?: { requestAdapter?: () => unknown } }).gpu;
|
||||||
if (gpu && typeof gpu.requestAdapter === 'function') {
|
if (gpu && typeof gpu.requestAdapter === 'function') {
|
||||||
console.log('[Transformers] Attempting to use GPU');
|
console.log('[Transformers] Attempting to use GPU');
|
||||||
pipelineOpts.device = 'webgpu';
|
pipelineOpts.device = 'webgpu';
|
||||||
@ -137,21 +262,17 @@ async function loadModel(modelKey: string, useGpu: boolean = false) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function unloadModel() {
|
async function unloadModel(): Promise<{ model_unloaded: boolean }> {
|
||||||
try {
|
try {
|
||||||
console.log('Unloading model...');
|
console.log('Unloading model...');
|
||||||
|
|
||||||
if (pipeline) {
|
if (pipeline && typeof pipeline === 'object' && 'destroy' in pipeline) {
|
||||||
if (pipeline.destroy) {
|
const pipelineWithDestroy = pipeline as { destroy: () => void };
|
||||||
pipeline.destroy();
|
pipelineWithDestroy.destroy();
|
||||||
}
|
|
||||||
pipeline = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tokenizer) {
|
|
||||||
tokenizer = null;
|
|
||||||
}
|
}
|
||||||
|
pipeline = null;
|
||||||
|
|
||||||
|
tokenizer = null;
|
||||||
model = null;
|
model = null;
|
||||||
|
|
||||||
console.log('Model unloaded successfully');
|
console.log('Model unloaded successfully');
|
||||||
@ -163,13 +284,14 @@ async function unloadModel() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function countTokens(input: string) {
|
async function countTokens(input: string): Promise<{ tokens: number }> {
|
||||||
try {
|
try {
|
||||||
if (!tokenizer) {
|
if (!tokenizer) {
|
||||||
throw new Error('Tokenizer not loaded');
|
throw new Error('Tokenizer not loaded');
|
||||||
}
|
}
|
||||||
|
|
||||||
const { input_ids } = await tokenizer(input);
|
const tokenizerWithCall = tokenizer as (input: string) => Promise<TokenizerResult>;
|
||||||
|
const { input_ids } = await tokenizerWithCall(input);
|
||||||
return { tokens: input_ids.data.length };
|
return { tokens: input_ids.data.length };
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -249,7 +371,8 @@ async function processBatch(batchInputs: EmbedInput[]): Promise<EmbedResult[]> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// 生成嵌入向量
|
// 生成嵌入向量
|
||||||
const resp = await pipeline(embedInputs, { pooling: 'mean', normalize: true });
|
const pipelineCall = pipeline as (inputs: string[], options: { pooling: string; normalize: boolean }) => Promise<{ data: number[] }[]>;
|
||||||
|
const resp = await pipelineCall(embedInputs, { pooling: 'mean', normalize: true });
|
||||||
|
|
||||||
// 处理结果
|
// 处理结果
|
||||||
return batchInputs.map((item, i) => ({
|
return batchInputs.map((item, i) => ({
|
||||||
@ -262,10 +385,11 @@ async function processBatch(batchInputs: EmbedInput[]): Promise<EmbedResult[]> {
|
|||||||
console.error('Error processing batch:', error);
|
console.error('Error processing batch:', error);
|
||||||
|
|
||||||
// 如果批处理失败,尝试逐个处理
|
// 如果批处理失败,尝试逐个处理
|
||||||
return Promise.all(
|
const results = await Promise.all(
|
||||||
batchInputs.map(async (item) => {
|
batchInputs.map(async (item): Promise<EmbedResult> => {
|
||||||
try {
|
try {
|
||||||
const result = await pipeline(item.embed_input, { pooling: 'mean', normalize: true });
|
const pipelineCall = pipeline as (input: string, options: { pooling: string; normalize: boolean }) => Promise<{ data: number[] }[]>;
|
||||||
|
const result = await pipelineCall(item.embed_input, { pooling: 'mean', normalize: true });
|
||||||
const tokenCount = await countTokens(item.embed_input);
|
const tokenCount = await countTokens(item.embed_input);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -279,11 +403,13 @@ async function processBatch(batchInputs: EmbedInput[]): Promise<EmbedResult[]> {
|
|||||||
vec: [],
|
vec: [],
|
||||||
tokens: 0,
|
tokens: 0,
|
||||||
embed_input: item.embed_input,
|
embed_input: item.embed_input,
|
||||||
error: (singleError as Error).message
|
error: singleError instanceof Error ? singleError.message : 'Unknown error'
|
||||||
} as any;
|
};
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return results;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -291,12 +417,13 @@ async function processMessage(data: WorkerMessage): Promise<WorkerResponse> {
|
|||||||
const { method, params, id, worker_id } = data;
|
const { method, params, id, worker_id } = data;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let result: any;
|
let result: unknown;
|
||||||
|
|
||||||
switch (method) {
|
switch (method) {
|
||||||
case 'load':
|
case 'load':
|
||||||
console.log('Load method called with params:', params);
|
console.log('Load method called with params:', params);
|
||||||
result = await loadModel(params.model_key, params.use_gpu || false);
|
const loadParams = params as LoadParams;
|
||||||
|
result = await loadModel(loadParams.model_key, loadParams.use_gpu || false);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'unload':
|
case 'unload':
|
||||||
@ -318,7 +445,8 @@ async function processMessage(data: WorkerMessage): Promise<WorkerResponse> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
processing_message = true;
|
processing_message = true;
|
||||||
result = await embedBatch(params.inputs);
|
const embedParams = params as EmbedBatchParams;
|
||||||
|
result = await embedBatch(embedParams.inputs);
|
||||||
processing_message = false;
|
processing_message = false;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@ -336,7 +464,8 @@ async function processMessage(data: WorkerMessage): Promise<WorkerResponse> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
processing_message = true;
|
processing_message = true;
|
||||||
result = await countTokens(params);
|
const tokenParams = params as string;
|
||||||
|
result = await countTokens(tokenParams);
|
||||||
processing_message = false;
|
processing_message = false;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@ -349,7 +478,7 @@ async function processMessage(data: WorkerMessage): Promise<WorkerResponse> {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error processing message:', error);
|
console.error('Error processing message:', error);
|
||||||
processing_message = false;
|
processing_message = false;
|
||||||
return { id, error: (error as Error).message, worker_id };
|
return { id, error: error instanceof Error ? error.message : 'Unknown error', worker_id };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -367,14 +496,14 @@ self.addEventListener('message', async (event) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await processMessage(event.data);
|
const response = await processMessage(event.data as WorkerMessage);
|
||||||
console.log('Worker sending response:', response);
|
console.log('Worker sending response:', response);
|
||||||
self.postMessage(response);
|
self.postMessage(response);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Unhandled error in worker message handler:', error);
|
console.error('Unhandled error in worker message handler:', error);
|
||||||
self.postMessage({
|
self.postMessage({
|
||||||
id: event.data?.id || -1,
|
id: (event.data as { id?: number })?.id || -1,
|
||||||
error: `Worker error: ${error.message || 'Unknown error'}`
|
error: `Worker error: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@ -282,6 +282,24 @@ export const InfioSettingsSchema = z.object({
|
|||||||
modelId: z.string(),
|
modelId: z.string(),
|
||||||
})).catch([]),
|
})).catch([]),
|
||||||
|
|
||||||
|
// Insight Model start list
|
||||||
|
collectedInsightModels: z.array(z.object({
|
||||||
|
provider: z.nativeEnum(ApiProvider),
|
||||||
|
modelId: z.string(),
|
||||||
|
})).catch([]),
|
||||||
|
|
||||||
|
// Apply Model start list
|
||||||
|
collectedApplyModels: z.array(z.object({
|
||||||
|
provider: z.nativeEnum(ApiProvider),
|
||||||
|
modelId: z.string(),
|
||||||
|
})).catch([]),
|
||||||
|
|
||||||
|
// Embedding Model start list
|
||||||
|
collectedEmbeddingModels: z.array(z.object({
|
||||||
|
provider: z.nativeEnum(ApiProvider),
|
||||||
|
modelId: z.string(),
|
||||||
|
})).catch([]),
|
||||||
|
|
||||||
// Active Provider Tab (for UI state)
|
// Active Provider Tab (for UI state)
|
||||||
activeProviderTab: z.nativeEnum(ApiProvider).catch(ApiProvider.Infio),
|
activeProviderTab: z.nativeEnum(ApiProvider).catch(ApiProvider.Infio),
|
||||||
|
|
||||||
|
|||||||
18
styles.css
18
styles.css
@ -828,6 +828,24 @@ input[type='text'].infio-chat-list-dropdown-item-title-input {
|
|||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.infio-search-lexical-content-editable-root {
|
||||||
|
min-height: 36px;
|
||||||
|
max-height: 500px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-search-lexical-content-editable-root .mention {
|
||||||
|
background-color: var(--tag-background);
|
||||||
|
color: var(--tag-color);
|
||||||
|
padding: var(--size-2-1) calc(var(--size-2-1));
|
||||||
|
border-radius: var(--radius-s);
|
||||||
|
background-color: var(--tag-background);
|
||||||
|
color: var(--tag-color);
|
||||||
|
padding: 0 calc(var(--size-2-1));
|
||||||
|
border-radius: var(--radius-s);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
.infio-chat-lexical-content-editable-paragraph {
|
.infio-chat-lexical-content-editable-paragraph {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user