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[]>([])
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react'
|
||||
import { ChevronDown, ChevronRight, RotateCcw } from 'lucide-react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { useApp } from '../../contexts/AppContext'
|
||||
@ -12,6 +12,8 @@ import { t } from '../../lang/helpers'
|
||||
import { getFilesWithTag } from '../../utils/glob-utils'
|
||||
import { openMarkdownFile } from '../../utils/obsidian'
|
||||
|
||||
import { ModelSelect } from './chat-input/ModelSelect'
|
||||
|
||||
// 洞察源分组结果接口
|
||||
interface InsightFileGroup {
|
||||
path: string
|
||||
@ -201,8 +203,8 @@ const InsightView = () => {
|
||||
const result = await transEngine.initWorkspaceInsight({
|
||||
workspace: currentWorkspace,
|
||||
model: {
|
||||
provider: settings.applyModelProvider,
|
||||
modelId: settings.applyModelId,
|
||||
provider: settings.insightModelProvider || settings.chatModelProvider,
|
||||
modelId: settings.insightModelId || settings.chatModelId,
|
||||
},
|
||||
onProgress: (progress) => {
|
||||
setInitProgress({
|
||||
@ -260,10 +262,7 @@ const InsightView = () => {
|
||||
}
|
||||
}, [getTransEngine, settings, workspaceManager, loadInsights])
|
||||
|
||||
// 确认删除工作区洞察
|
||||
const handleDeleteWorkspaceInsights = useCallback(() => {
|
||||
setShowDeleteConfirm(true)
|
||||
}, [])
|
||||
|
||||
|
||||
// 确认初始化/更新洞察
|
||||
const handleInitWorkspaceInsights = useCallback(() => {
|
||||
@ -522,76 +521,85 @@ const InsightView = () => {
|
||||
<div className="obsidian-insight-title">
|
||||
<h3>{t('insights.title')}</h3>
|
||||
<div className="obsidian-insight-actions">
|
||||
<button
|
||||
onClick={handleInitWorkspaceInsights}
|
||||
disabled={isInitializing || isLoading || isDeleting}
|
||||
className="obsidian-insight-init-btn"
|
||||
title={hasLoaded && insightResults.length > 0 ? t('insights.tooltips.update') : t('insights.tooltips.initialize')}
|
||||
>
|
||||
{isInitializing ? t('insights.initializing') : (hasLoaded && insightResults.length > 0 ? t('insights.updateInsights') : t('insights.initializeInsights'))}
|
||||
</button>
|
||||
<button
|
||||
{/* <button
|
||||
onClick={handleDeleteWorkspaceInsights}
|
||||
disabled={isDeleting || isLoading || isInitializing}
|
||||
className="obsidian-insight-delete-btn"
|
||||
title={t('insights.tooltips.clear')}
|
||||
>
|
||||
{isDeleting ? t('insights.deleting') : t('insights.clearInsights')}
|
||||
</button>
|
||||
</button> */}
|
||||
<button
|
||||
onClick={loadInsights}
|
||||
disabled={isLoading || isInitializing || isDeleting}
|
||||
className="obsidian-insight-refresh-btn"
|
||||
title={isLoading ? t('insights.loading') : t('insights.refresh')}
|
||||
>
|
||||
{isLoading ? t('insights.loading') : t('insights.refresh')}
|
||||
<RotateCcw size={16} className={isLoading ? 'spinning' : ''} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 结果统计 */}
|
||||
{/* 结果统计 & 洞察操作 */}
|
||||
<div className="infio-insight-stats">
|
||||
{hasLoaded && !isLoading && (
|
||||
<div className="obsidian-insight-stats">
|
||||
<div className="obsidian-insight-stats-overview">
|
||||
<div className="obsidian-insight-stats-main">
|
||||
<span className="obsidian-insight-stats-number">{insightResults.length}</span>
|
||||
<span className="obsidian-insight-stats-label">个洞察</span>
|
||||
<div className="infio-insight-stats-overview">
|
||||
<div className="infio-insight-stats-main">
|
||||
<span className="infio-insight-stats-number">{insightResults.length}</span>
|
||||
<span className="infio-insight-stats-label">个洞察</span>
|
||||
</div>
|
||||
<div className="obsidian-insight-stats-breakdown">
|
||||
<div className="infio-insight-stats-breakdown">
|
||||
{insightGroupedResults.length > 0 && (
|
||||
<div className="obsidian-insight-stats-items">
|
||||
<div className="infio-insight-stats-items">
|
||||
{insightGroupedResults.filter(g => g.groupType === 'workspace').length > 0 && (
|
||||
<div className="obsidian-insight-stats-item">
|
||||
<span className="obsidian-insight-stats-item-icon">🌐</span>
|
||||
<span className="obsidian-insight-stats-item-value">
|
||||
<div className="infio-insight-stats-item">
|
||||
<span className="infio-insight-stats-item-icon">🌐</span>
|
||||
<span className="infio-insight-stats-item-value">
|
||||
{insightGroupedResults.filter(g => g.groupType === 'workspace').length}
|
||||
</span>
|
||||
<span className="obsidian-insight-stats-item-label">工作区</span>
|
||||
<span className="infio-insight-stats-item-label">工作区</span>
|
||||
</div>
|
||||
)}
|
||||
{insightGroupedResults.filter(g => g.groupType === 'folder').length > 0 && (
|
||||
<div className="obsidian-insight-stats-item">
|
||||
<span className="obsidian-insight-stats-item-icon">📂</span>
|
||||
<span className="obsidian-insight-stats-item-value">
|
||||
<div className="infio-insight-stats-item">
|
||||
<span className="infio-insight-stats-item-icon">📂</span>
|
||||
<span className="infio-insight-stats-item-value">
|
||||
{insightGroupedResults.filter(g => g.groupType === 'folder').length}
|
||||
</span>
|
||||
<span className="obsidian-insight-stats-item-label">文件夹</span>
|
||||
<span className="infio-insight-stats-item-label">文件夹</span>
|
||||
</div>
|
||||
)}
|
||||
{insightGroupedResults.filter(g => g.groupType === 'file').length > 0 && (
|
||||
<div className="obsidian-insight-stats-item">
|
||||
<span className="obsidian-insight-stats-item-icon">📄</span>
|
||||
<span className="obsidian-insight-stats-item-value">
|
||||
<div className="infio-insight-stats-item">
|
||||
<span className="infio-insight-stats-item-icon">📄</span>
|
||||
<span className="infio-insight-stats-item-value">
|
||||
{insightGroupedResults.filter(g => g.groupType === 'file').length}
|
||||
</span>
|
||||
<span className="obsidian-insight-stats-item-label">文件</span>
|
||||
<span className="infio-insight-stats-item-label">文件</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="infio-insight-model-info">
|
||||
<div className="infio-insight-model-row">
|
||||
<span className="infio-insight-model-label">洞察模型:</span>
|
||||
<ModelSelect modelType="insight" />
|
||||
</div>
|
||||
<div className="infio-insight-actions">
|
||||
<button
|
||||
onClick={handleInitWorkspaceInsights}
|
||||
disabled={isInitializing || isLoading || isDeleting}
|
||||
className="infio-insight-primary-btn"
|
||||
title={hasLoaded && insightResults.length > 0 ? t('insights.tooltips.update') : t('insights.tooltips.initialize')}
|
||||
>
|
||||
{isInitializing ? t('insights.initializing') : (hasLoaded && insightResults.length > 0 ? t('insights.updateInsights') : t('insights.initializeInsights'))}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 加载进度 */}
|
||||
@ -723,7 +731,7 @@ const InsightView = () => {
|
||||
<div className="obsidian-confirm-dialog-info-item">
|
||||
<strong>{t('insights.initConfirm.modelLabel')}</strong>
|
||||
<span className="obsidian-confirm-dialog-model">
|
||||
{settings.chatModelProvider}/{settings.chatModelId || t('insights.initConfirm.defaultModel')}
|
||||
{settings.insightModelId}
|
||||
</span>
|
||||
</div>
|
||||
<div className="obsidian-confirm-dialog-info-item">
|
||||
@ -865,7 +873,7 @@ const InsightView = () => {
|
||||
}
|
||||
|
||||
.obsidian-insight-header {
|
||||
padding: 12px;
|
||||
padding: var(--size-4-3);
|
||||
border-bottom: 1px solid var(--background-modifier-border);
|
||||
}
|
||||
|
||||
@ -873,7 +881,7 @@ const InsightView = () => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
margin-bottom: var(--size-4-2);
|
||||
}
|
||||
|
||||
.obsidian-insight-title h3 {
|
||||
@ -885,60 +893,25 @@ const InsightView = () => {
|
||||
|
||||
.obsidian-insight-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.obsidian-insight-init-btn {
|
||||
padding: 6px 12px;
|
||||
background-color: var(--interactive-accent);
|
||||
border: none;
|
||||
border-radius: var(--radius-s);
|
||||
color: var(--text-on-accent);
|
||||
font-size: var(--font-ui-small);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.obsidian-insight-init-btn:hover:not(:disabled) {
|
||||
background-color: var(--interactive-accent-hover);
|
||||
}
|
||||
|
||||
.obsidian-insight-init-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.obsidian-insight-delete-btn {
|
||||
padding: 6px 12px;
|
||||
background-color: #dc3545;
|
||||
border: none;
|
||||
border-radius: var(--radius-s);
|
||||
color: white;
|
||||
font-size: var(--font-ui-small);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.obsidian-insight-delete-btn:hover:not(:disabled) {
|
||||
background-color: #c82333;
|
||||
}
|
||||
|
||||
.obsidian-insight-delete-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
gap: var(--size-4-2);
|
||||
}
|
||||
|
||||
.obsidian-insight-refresh-btn {
|
||||
padding: 6px 12px;
|
||||
background-color: var(--interactive-normal);
|
||||
border: none;
|
||||
border-radius: var(--radius-s);
|
||||
color: var(--text-normal);
|
||||
font-size: var(--font-ui-small);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: transparent !important;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
color: var(--text-muted);
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
width: 24px !important;
|
||||
height: 24px !important;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--background-modifier-hover) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.obsidian-insight-refresh-btn:hover:not(:disabled) {
|
||||
@ -950,118 +923,155 @@ const InsightView = () => {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.obsidian-insight-stats {
|
||||
.infio-insight-stats {
|
||||
background-color: var(--background-secondary);
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-radius: var(--radius-m);
|
||||
padding: 12px;
|
||||
border-radius: var(--radius-s);
|
||||
padding: var(--size-4-2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--size-4-4);
|
||||
}
|
||||
|
||||
.obsidian-insight-stats-overview {
|
||||
.infio-insight-stats-overview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.obsidian-insight-stats-main {
|
||||
.infio-insight-stats-main {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
gap: var(--size-2-2);
|
||||
}
|
||||
|
||||
.obsidian-insight-stats-number {
|
||||
.infio-insight-stats-number {
|
||||
font-size: var(--font-ui-large);
|
||||
font-weight: 700;
|
||||
color: var(--text-accent);
|
||||
font-family: var(--font-monospace);
|
||||
}
|
||||
|
||||
.obsidian-insight-stats-label {
|
||||
.infio-insight-stats-label {
|
||||
font-size: var(--font-ui-medium);
|
||||
color: var(--text-normal);
|
||||
font-weight: 500;
|
||||
font-weight: var(--font-medium);
|
||||
}
|
||||
|
||||
.obsidian-insight-stats-breakdown {
|
||||
.infio-insight-stats-breakdown {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.obsidian-insight-stats-items {
|
||||
.infio-insight-stats-items {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
gap: var(--size-2-3);
|
||||
}
|
||||
|
||||
.obsidian-insight-stats-item {
|
||||
.infio-insight-stats-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
gap: var(--size-2-1);
|
||||
padding: var(--size-2-1) var(--size-2-2);
|
||||
background-color: var(--background-modifier-border);
|
||||
border-radius: var(--radius-s);
|
||||
}
|
||||
|
||||
.obsidian-insight-stats-item-icon {
|
||||
font-size: 12px;
|
||||
.infio-insight-stats-item-icon {
|
||||
font-size: var(--font-ui-smaller);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.obsidian-insight-stats-item-value {
|
||||
.infio-insight-stats-item-value {
|
||||
font-size: var(--font-ui-small);
|
||||
font-weight: 600;
|
||||
color: var(--text-normal);
|
||||
font-family: var(--font-monospace);
|
||||
}
|
||||
|
||||
.obsidian-insight-stats-item-label {
|
||||
.infio-insight-stats-item-label {
|
||||
font-size: var(--font-ui-smaller);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.obsidian-insight-scope {
|
||||
.infio-insight-model-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 8px;
|
||||
background-color: var(--background-modifier-border-hover);
|
||||
border-radius: var(--radius-s);
|
||||
justify-content: space-between;
|
||||
gap: var(--size-4-3);
|
||||
}
|
||||
|
||||
.obsidian-insight-scope-label {
|
||||
font-size: var(--font-ui-smaller);
|
||||
.infio-insight-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-insight-model-label {
|
||||
font-size: var(--font-ui-small);
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
font-weight: var(--font-medium);
|
||||
}
|
||||
|
||||
.obsidian-insight-scope-value {
|
||||
font-size: var(--font-ui-smaller);
|
||||
.infio-insight-model-value {
|
||||
font-size: var(--font-ui-small);
|
||||
color: var(--text-accent);
|
||||
font-weight: 600;
|
||||
font-family: var(--font-monospace);
|
||||
}
|
||||
|
||||
.infio-insight-actions {
|
||||
display: flex;
|
||||
gap: var(--size-2-2);
|
||||
}
|
||||
|
||||
.infio-insight-primary-btn {
|
||||
padding: var(--size-2-2) var(--size-4-3);
|
||||
background-color: var(--interactive-accent-hover);
|
||||
border: none;
|
||||
border-radius: var(--radius-s);
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-ui-small);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease-in-out;
|
||||
font-weight: var(--font-medium);
|
||||
}
|
||||
|
||||
.infio-insight-primary-btn:hover:not(:disabled) {
|
||||
background-color: var(--interactive-accent-hover);
|
||||
}
|
||||
|
||||
.infio-insight-primary-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.obsidian-insight-loading {
|
||||
padding: 20px;
|
||||
padding: var(--size-4-8);
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-ui-medium);
|
||||
}
|
||||
|
||||
.obsidian-insight-initializing {
|
||||
padding: 20px;
|
||||
padding: var(--size-4-8);
|
||||
background-color: var(--background-secondary);
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-radius: var(--radius-m);
|
||||
margin: 12px;
|
||||
border-radius: var(--radius-s);
|
||||
margin: var(--size-4-3);
|
||||
}
|
||||
|
||||
.obsidian-insight-init-header {
|
||||
text-align: center;
|
||||
margin-bottom: 16px;
|
||||
margin-bottom: var(--size-4-4);
|
||||
}
|
||||
|
||||
.obsidian-insight-init-header h4 {
|
||||
margin: 0 0 8px 0;
|
||||
margin: 0 0 var(--size-2-2) 0;
|
||||
color: var(--text-normal);
|
||||
font-size: var(--font-ui-medium);
|
||||
font-weight: 600;
|
||||
@ -1075,7 +1085,7 @@ const InsightView = () => {
|
||||
|
||||
.obsidian-insight-progress {
|
||||
background-color: var(--background-primary);
|
||||
padding: 12px;
|
||||
padding: var(--size-4-3);
|
||||
border-radius: var(--radius-s);
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
}
|
||||
@ -1084,13 +1094,13 @@ const InsightView = () => {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
margin-bottom: var(--size-2-2);
|
||||
}
|
||||
|
||||
.obsidian-insight-progress-stage {
|
||||
color: var(--text-normal);
|
||||
font-size: var(--font-ui-small);
|
||||
font-weight: 500;
|
||||
font-weight: var(--font-medium);
|
||||
}
|
||||
|
||||
.obsidian-insight-progress-counter {
|
||||
@ -1105,7 +1115,7 @@ const InsightView = () => {
|
||||
background-color: var(--background-modifier-border);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 8px;
|
||||
margin-bottom: var(--size-2-2);
|
||||
}
|
||||
|
||||
.obsidian-insight-progress-fill {
|
||||
@ -1119,15 +1129,15 @@ const InsightView = () => {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
margin-bottom: var(--size-2-2);
|
||||
}
|
||||
|
||||
.obsidian-insight-progress-item {
|
||||
color: var(--text-normal);
|
||||
font-size: var(--font-ui-small);
|
||||
font-weight: 500;
|
||||
font-weight: var(--font-medium);
|
||||
flex: 1;
|
||||
margin-right: 12px;
|
||||
margin-right: var(--size-4-3);
|
||||
}
|
||||
|
||||
.obsidian-insight-progress-percentage {
|
||||
@ -1139,8 +1149,8 @@ const InsightView = () => {
|
||||
}
|
||||
|
||||
.obsidian-insight-progress-log {
|
||||
margin-top: 8px;
|
||||
padding: 8px;
|
||||
margin-top: var(--size-2-2);
|
||||
padding: var(--size-2-2);
|
||||
background-color: var(--background-modifier-border-hover);
|
||||
border-radius: var(--radius-s);
|
||||
font-size: var(--font-ui-smaller);
|
||||
@ -1149,7 +1159,7 @@ const InsightView = () => {
|
||||
.obsidian-insight-progress-log-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 4px;
|
||||
margin-bottom: var(--size-2-1);
|
||||
}
|
||||
|
||||
.obsidian-insight-progress-log-item:last-child {
|
||||
@ -1158,9 +1168,9 @@ const InsightView = () => {
|
||||
|
||||
.obsidian-insight-progress-log-label {
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
font-weight: var(--font-medium);
|
||||
flex-shrink: 0;
|
||||
margin-right: 8px;
|
||||
margin-right: var(--size-2-2);
|
||||
}
|
||||
|
||||
.obsidian-insight-progress-log-value {
|
||||
@ -1173,30 +1183,30 @@ const InsightView = () => {
|
||||
|
||||
.obsidian-insight-success {
|
||||
background-color: var(--background-secondary);
|
||||
border: 1px solid var(--color-green, #28a745);
|
||||
border-radius: var(--radius-m);
|
||||
margin: 12px;
|
||||
border: 1px solid var(--color-green, #10b981);
|
||||
border-radius: var(--radius-s);
|
||||
margin: var(--size-4-3);
|
||||
animation: slideInFromTop 0.3s ease-out;
|
||||
}
|
||||
|
||||
.obsidian-insight-success-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
gap: var(--size-4-3);
|
||||
padding: var(--size-4-3) var(--size-4-4);
|
||||
}
|
||||
|
||||
.obsidian-insight-success-icon {
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
color: var(--color-green, #28a745);
|
||||
color: var(--color-green, #10b981);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.obsidian-insight-success-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
gap: var(--size-2-1);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
@ -1208,12 +1218,6 @@ const InsightView = () => {
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.obsidian-insight-success-summary {
|
||||
font-size: var(--font-ui-small);
|
||||
color: var(--text-muted);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.obsidian-insight-success-close {
|
||||
background: none;
|
||||
border: none;
|
||||
@ -1221,9 +1225,9 @@ const InsightView = () => {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
padding: var(--size-2-1);
|
||||
border-radius: var(--radius-s);
|
||||
transition: all 0.2s ease;
|
||||
transition: all 0.15s ease-in-out;
|
||||
flex-shrink: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
@ -1263,10 +1267,10 @@ const InsightView = () => {
|
||||
}
|
||||
|
||||
.obsidian-file-header {
|
||||
padding: 12px;
|
||||
padding: var(--size-4-3);
|
||||
background-color: var(--background-secondary);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.1s ease;
|
||||
transition: background-color 0.15s ease-in-out;
|
||||
border-bottom: 1px solid var(--background-modifier-border);
|
||||
}
|
||||
|
||||
@ -1277,7 +1281,7 @@ const InsightView = () => {
|
||||
.obsidian-file-header-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
gap: var(--size-2-1);
|
||||
}
|
||||
|
||||
.obsidian-file-header-top {
|
||||
@ -1289,7 +1293,7 @@ const InsightView = () => {
|
||||
.obsidian-file-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: var(--size-2-2);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
@ -1297,7 +1301,7 @@ const InsightView = () => {
|
||||
.obsidian-file-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: var(--size-2-2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@ -1305,32 +1309,32 @@ const InsightView = () => {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-ui-smaller);
|
||||
background-color: var(--background-modifier-border);
|
||||
padding: 2px 6px;
|
||||
padding: var(--size-2-1) var(--size-2-2);
|
||||
border-radius: var(--radius-s);
|
||||
font-weight: 500;
|
||||
font-weight: var(--font-medium);
|
||||
}
|
||||
|
||||
.obsidian-file-path-row {
|
||||
margin-left: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
gap: var(--size-2-1);
|
||||
}
|
||||
|
||||
.obsidian-insight-types {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-top: 4px;
|
||||
gap: var(--size-2-1);
|
||||
margin-top: var(--size-2-1);
|
||||
}
|
||||
|
||||
.obsidian-insight-type-tag {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-ui-smaller);
|
||||
background-color: var(--background-modifier-border-hover);
|
||||
padding: 1px 4px;
|
||||
padding: 1px var(--size-2-1);
|
||||
border-radius: var(--radius-s);
|
||||
font-weight: 500;
|
||||
font-weight: var(--font-medium);
|
||||
}
|
||||
|
||||
.obsidian-expand-icon {
|
||||
@ -1341,7 +1345,7 @@ const InsightView = () => {
|
||||
.obsidian-file-name {
|
||||
color: var(--text-normal);
|
||||
font-size: var(--font-ui-medium);
|
||||
font-weight: 500;
|
||||
font-weight: var(--font-medium);
|
||||
flex-shrink: 0;
|
||||
user-select: text;
|
||||
cursor: text;
|
||||
@ -1361,10 +1365,10 @@ const InsightView = () => {
|
||||
}
|
||||
|
||||
.obsidian-result-item {
|
||||
padding: 12px 12px 12px 32px;
|
||||
padding: var(--size-4-3) var(--size-4-3) var(--size-4-3) var(--size-4-8);
|
||||
border-bottom: 1px solid var(--background-modifier-border-focus);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.1s ease;
|
||||
transition: background-color 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
.obsidian-result-item:hover {
|
||||
@ -1379,14 +1383,14 @@ const InsightView = () => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 6px;
|
||||
gap: 8px;
|
||||
margin-bottom: var(--size-2-2);
|
||||
gap: var(--size-2-2);
|
||||
}
|
||||
|
||||
.obsidian-result-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: var(--size-2-2);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
@ -1398,14 +1402,14 @@ const InsightView = () => {
|
||||
}
|
||||
|
||||
.obsidian-delete-insight-btn {
|
||||
padding: 2px 6px;
|
||||
padding: var(--size-2-1) var(--size-2-2);
|
||||
background-color: transparent;
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-radius: var(--radius-s);
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-ui-smaller);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
transition: all 0.15s ease-in-out;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@ -1414,21 +1418,20 @@ const InsightView = () => {
|
||||
}
|
||||
|
||||
.obsidian-delete-insight-btn:hover:not(:disabled) {
|
||||
background-color: #dc3545;
|
||||
border-color: #dc3545;
|
||||
color: white;
|
||||
background-color: var(--text-error);
|
||||
border-color: var(--text-error);
|
||||
color: var(--text-on-accent);
|
||||
}
|
||||
|
||||
.obsidian-delete-insight-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.obsidian-result-index {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-ui-small);
|
||||
font-weight: 500;
|
||||
font-weight: var(--font-medium);
|
||||
min-width: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@ -1438,7 +1441,7 @@ const InsightView = () => {
|
||||
font-size: var(--font-ui-smaller);
|
||||
font-weight: 600;
|
||||
background-color: var(--background-modifier-border);
|
||||
padding: 2px 6px;
|
||||
padding: var(--size-2-1) var(--size-2-2);
|
||||
border-radius: var(--radius-s);
|
||||
flex-grow: 1;
|
||||
}
|
||||
@ -1469,13 +1472,13 @@ const InsightView = () => {
|
||||
}
|
||||
|
||||
.obsidian-no-results {
|
||||
padding: 40px 20px;
|
||||
padding: var(--size-4-16) var(--size-4-8);
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.obsidian-no-results p {
|
||||
margin: 8px 0;
|
||||
margin: var(--size-2-2) 0;
|
||||
font-size: var(--font-ui-medium);
|
||||
}
|
||||
|
||||
@ -1511,7 +1514,7 @@ const InsightView = () => {
|
||||
}
|
||||
|
||||
.obsidian-confirm-dialog-header {
|
||||
padding: 16px 20px;
|
||||
padding: var(--size-4-4) var(--size-4-8);
|
||||
border-bottom: 1px solid var(--background-modifier-border);
|
||||
background-color: var(--background-secondary);
|
||||
}
|
||||
@ -1524,32 +1527,32 @@ const InsightView = () => {
|
||||
}
|
||||
|
||||
.obsidian-confirm-dialog-body {
|
||||
padding: 20px;
|
||||
padding: var(--size-4-8);
|
||||
color: var(--text-normal);
|
||||
font-size: var(--font-ui-medium);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.obsidian-confirm-dialog-body p {
|
||||
margin: 0 0 12px 0;
|
||||
margin: 0 0 var(--size-4-3) 0;
|
||||
}
|
||||
|
||||
.obsidian-confirm-dialog-warning {
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-radius: var(--radius-s);
|
||||
padding: 12px;
|
||||
margin: 12px 0;
|
||||
padding: var(--size-4-3);
|
||||
margin: var(--size-4-3) 0;
|
||||
color: var(--text-error);
|
||||
font-size: var(--font-ui-small);
|
||||
font-weight: 500;
|
||||
font-weight: var(--font-medium);
|
||||
}
|
||||
|
||||
.obsidian-confirm-dialog-scope {
|
||||
background-color: var(--background-secondary);
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-radius: var(--radius-s);
|
||||
padding: 8px 12px;
|
||||
margin: 12px 0 0 0;
|
||||
padding: var(--size-2-2) var(--size-4-3);
|
||||
margin: var(--size-4-3) 0 0 0;
|
||||
font-size: var(--font-ui-small);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
@ -1558,15 +1561,15 @@ const InsightView = () => {
|
||||
background-color: var(--background-secondary);
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-radius: var(--radius-s);
|
||||
padding: 12px;
|
||||
margin: 12px 0;
|
||||
padding: var(--size-4-3);
|
||||
margin: var(--size-4-3) 0;
|
||||
}
|
||||
|
||||
.obsidian-confirm-dialog-info-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
margin-bottom: var(--size-2-2);
|
||||
font-size: var(--font-ui-small);
|
||||
}
|
||||
|
||||
@ -1576,7 +1579,7 @@ const InsightView = () => {
|
||||
|
||||
.obsidian-confirm-dialog-info-item strong {
|
||||
color: var(--text-normal);
|
||||
margin-right: 12px;
|
||||
margin-right: var(--size-4-3);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@ -1591,24 +1594,24 @@ const InsightView = () => {
|
||||
}
|
||||
|
||||
.obsidian-confirm-dialog-footer {
|
||||
padding: 16px 20px;
|
||||
padding: var(--size-4-4) var(--size-4-8);
|
||||
border-top: 1px solid var(--background-modifier-border);
|
||||
background-color: var(--background-secondary);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
gap: var(--size-4-3);
|
||||
}
|
||||
|
||||
.obsidian-confirm-dialog-cancel-btn {
|
||||
padding: 8px 16px;
|
||||
padding: var(--size-2-2) var(--size-4-4);
|
||||
background-color: var(--interactive-normal);
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-radius: var(--radius-s);
|
||||
color: var(--text-normal);
|
||||
font-size: var(--font-ui-small);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
font-weight: 500;
|
||||
transition: all 0.15s ease-in-out;
|
||||
font-weight: var(--font-medium);
|
||||
}
|
||||
|
||||
.obsidian-confirm-dialog-cancel-btn:hover {
|
||||
@ -1616,20 +1619,20 @@ const InsightView = () => {
|
||||
}
|
||||
|
||||
.obsidian-confirm-dialog-confirm-btn {
|
||||
padding: 8px 16px;
|
||||
background-color: #dc3545;
|
||||
border: 1px solid #dc3545;
|
||||
padding: var(--size-2-2) var(--size-4-4);
|
||||
background-color: var(--text-error);
|
||||
border: 1px solid var(--text-error);
|
||||
border-radius: var(--radius-s);
|
||||
color: white;
|
||||
color: var(--text-on-accent);
|
||||
font-size: var(--font-ui-small);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
font-weight: 500;
|
||||
transition: all 0.15s ease-in-out;
|
||||
font-weight: var(--font-medium);
|
||||
}
|
||||
|
||||
.obsidian-confirm-dialog-confirm-btn:hover {
|
||||
background-color: #c82333;
|
||||
border-color: #c82333;
|
||||
background-color: var(--text-error);
|
||||
opacity: 0.8;
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
|
||||
@ -14,6 +14,7 @@ import { Mentionable } from '../../types/mentionable'
|
||||
import { getFilesWithTag } from '../../utils/glob-utils'
|
||||
import { openMarkdownFile } from '../../utils/obsidian'
|
||||
|
||||
import { ModelSelect } from './chat-input/ModelSelect'
|
||||
import SearchInputWithActions, { SearchInputRef } from './chat-input/SearchInputWithActions'
|
||||
import { editorStateToPlainText } from './chat-input/utils/editor-state-to-plain-text'
|
||||
|
||||
@ -31,7 +32,22 @@ interface InsightFileGroup {
|
||||
fileName: string
|
||||
maxSimilarity: number
|
||||
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_type: string
|
||||
similarity: number
|
||||
@ -52,7 +68,7 @@ const SearchView = () => {
|
||||
}, [app])
|
||||
const [searchResults, setSearchResults] = useState<(Omit<SelectVector, 'embedding'> & { similarity: number })[]>([])
|
||||
const [insightResults, setInsightResults] = useState<Array<{
|
||||
id: string
|
||||
id: number
|
||||
insight: string
|
||||
insight_type: string
|
||||
similarity: number
|
||||
@ -60,14 +76,12 @@ const SearchView = () => {
|
||||
}>>([])
|
||||
const [isSearching, setIsSearching] = 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())
|
||||
// 新增:mentionables 状态管理
|
||||
const [mentionables, setMentionables] = useState<Mentionable[]>([])
|
||||
const [searchEditorState, setSearchEditorState] = useState<SerializedEditorState | null>(null)
|
||||
// 当前搜索范围信息
|
||||
const [currentSearchScope, setCurrentSearchScope] = useState<string>('')
|
||||
|
||||
// 统计信息状态
|
||||
const [statisticsInfo, setStatisticsInfo] = useState<{
|
||||
@ -79,12 +93,15 @@ const SearchView = () => {
|
||||
// 工作区 RAG 向量初始化状态
|
||||
const [isInitializingRAG, setIsInitializingRAG] = useState(false)
|
||||
const [ragInitProgress, setRAGInitProgress] = useState<{
|
||||
type: 'indexing' | 'querying' | 'querying-done'
|
||||
type: 'indexing' | 'querying' | 'querying-done' | 'reading-mentionables' | 'reading-files'
|
||||
indexProgress?: {
|
||||
completedChunks: number
|
||||
totalChunks: number
|
||||
totalFiles: number
|
||||
}
|
||||
currentFile?: string
|
||||
totalFiles?: number
|
||||
completedFiles?: number
|
||||
} | null>(null)
|
||||
const [ragInitSuccess, setRAGInitSuccess] = useState<{
|
||||
show: boolean
|
||||
@ -110,7 +127,6 @@ const SearchView = () => {
|
||||
setSearchResults([])
|
||||
setInsightResults([])
|
||||
setHasSearched(false)
|
||||
setCurrentSearchScope('')
|
||||
return
|
||||
}
|
||||
|
||||
@ -124,14 +140,14 @@ const SearchView = () => {
|
||||
currentWorkspace = await workspaceManager.findByName(String(settings.workspace))
|
||||
}
|
||||
|
||||
// 设置搜索范围信息
|
||||
// 设置搜索范围信息(用于调试)
|
||||
let scopeDescription = ''
|
||||
if (currentWorkspace) {
|
||||
scopeDescription = `工作区: ${currentWorkspace.name}`
|
||||
} else {
|
||||
scopeDescription = '整个 Vault'
|
||||
}
|
||||
setCurrentSearchScope(scopeDescription)
|
||||
console.debug('搜索范围:', scopeDescription)
|
||||
|
||||
// 构建搜索范围
|
||||
let scope: { files: string[], folders: string[] } | undefined
|
||||
@ -167,7 +183,7 @@ const SearchView = () => {
|
||||
|
||||
setSearchResults(results)
|
||||
setInsightResults([])
|
||||
} else {
|
||||
} else if (searchMode === 'insights') {
|
||||
// 搜索洞察
|
||||
const transEngine = await getTransEngine()
|
||||
const results = await transEngine.processQuery({
|
||||
@ -177,8 +193,30 @@ const SearchView = () => {
|
||||
minSimilarity: 0.3,
|
||||
})
|
||||
|
||||
setInsightResults(results as any)
|
||||
setInsightResults(results)
|
||||
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) {
|
||||
console.error('搜索失败:', error)
|
||||
@ -315,10 +353,7 @@ const SearchView = () => {
|
||||
setShowRAGInitConfirm(true)
|
||||
}, [])
|
||||
|
||||
// 确认删除工作区索引
|
||||
const handleDeleteWorkspaceIndex = useCallback(() => {
|
||||
setShowDeleteConfirm(true)
|
||||
}, [])
|
||||
|
||||
|
||||
// 确认初始化 RAG 向量
|
||||
const confirmInitWorkspaceRAG = useCallback(async () => {
|
||||
@ -521,8 +556,63 @@ const SearchView = () => {
|
||||
return Array.from(fileGroups.values()).sort((a, b) => b.maxSimilarity - a.maxSimilarity)
|
||||
}, [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 totalFiles = groupedResults.length
|
||||
const totalAllFiles = allGroupedResults.length
|
||||
|
||||
return (
|
||||
<div className="obsidian-search-container">
|
||||
@ -530,29 +620,11 @@ const SearchView = () => {
|
||||
<div className="obsidian-search-header-wrapper">
|
||||
<div className="obsidian-search-title">
|
||||
<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>
|
||||
|
||||
{/* 统计信息 */}
|
||||
{!isLoadingStats && statisticsInfo && (
|
||||
<div className="obsidian-search-stats">
|
||||
{!isLoadingStats && statisticsInfo && (
|
||||
<div className="obsidian-search-stats-overview">
|
||||
<div className="obsidian-search-stats-main">
|
||||
<span className="obsidian-search-stats-number">{statisticsInfo.totalChunks}</span>
|
||||
@ -566,64 +638,27 @@ const SearchView = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 搜索输入框 */}
|
||||
<div className="obsidian-search-input-section">
|
||||
<SearchInputWithActions
|
||||
ref={searchInputRef}
|
||||
initialSerializedEditorState={searchEditorState}
|
||||
onChange={setSearchEditorState}
|
||||
onSubmit={handleSearch}
|
||||
mentionables={mentionables}
|
||||
setMentionables={setMentionables}
|
||||
placeholder="语义搜索(按回车键搜索)..."
|
||||
autoFocus={true}
|
||||
disabled={isSearching}
|
||||
/>
|
||||
|
||||
{/* 搜索模式切换 */}
|
||||
<div className="obsidian-search-mode-toggle">
|
||||
<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
|
||||
className={`obsidian-search-mode-btn ${searchMode === 'notes' ? 'active' : ''}`}
|
||||
onClick={() => setSearchMode('notes')}
|
||||
title="搜索原始笔记内容"
|
||||
onClick={handleInitWorkspaceRAG}
|
||||
disabled={isInitializingRAG || isDeleting || isSearching}
|
||||
className="obsidian-search-init-btn"
|
||||
title={statisticsInfo && (statisticsInfo.totalFiles > 0 || statisticsInfo.totalChunks > 0) ? '更新索引' : '初始化索引'}
|
||||
>
|
||||
📝 原始笔记
|
||||
</button>
|
||||
<button
|
||||
className={`obsidian-search-mode-btn ${searchMode === 'insights' ? 'active' : ''}`}
|
||||
onClick={() => setSearchMode('insights')}
|
||||
title="搜索 AI 洞察内容"
|
||||
>
|
||||
🧠 AI 洞察
|
||||
{isInitializingRAG ? '正在初始化...' : (statisticsInfo && (statisticsInfo.totalFiles > 0 || statisticsInfo.totalChunks > 0) ? '更新索引' : '初始化索引')}
|
||||
</button>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 结果统计 */}
|
||||
{hasSearched && !isSearching && (
|
||||
<div className="obsidian-search-stats">
|
||||
<div className="obsidian-search-stats-line">
|
||||
{searchMode === 'notes' ? (
|
||||
`${totalFiles} 个文件,${totalBlocks} 个块`
|
||||
) : (
|
||||
`${insightGroupedResults.length} 个文件,${insightResults.length} 个洞察`
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 搜索进度 */}
|
||||
{isSearching && (
|
||||
<div className="obsidian-search-loading">
|
||||
正在搜索...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* RAG 初始化进度 */}
|
||||
{/* 索引进度 */}
|
||||
{isInitializingRAG && (
|
||||
<div className="obsidian-rag-initializing">
|
||||
<div className="obsidian-rag-init-header">
|
||||
@ -668,9 +703,6 @@ const SearchView = () => {
|
||||
<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"
|
||||
@ -682,6 +714,38 @@ const SearchView = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 搜索输入框 */}
|
||||
<div className="obsidian-search-input-section">
|
||||
<SearchInputWithActions
|
||||
ref={searchInputRef}
|
||||
initialSerializedEditorState={searchEditorState}
|
||||
onChange={setSearchEditorState}
|
||||
onSubmit={handleSearch}
|
||||
mentionables={mentionables}
|
||||
setMentionables={setMentionables}
|
||||
placeholder="语义搜索(按回车键搜索)..."
|
||||
autoFocus={true}
|
||||
disabled={isSearching}
|
||||
searchMode={searchMode}
|
||||
onSearchModeChange={setSearchMode}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 索引统计 */}
|
||||
{hasSearched && !isSearching && (
|
||||
<div className="obsidian-search-stats">
|
||||
<div className="obsidian-search-stats-line">
|
||||
{searchMode === 'notes' ? (
|
||||
`${totalFiles} 个文件,${totalBlocks} 个块`
|
||||
) : searchMode === 'insights' ? (
|
||||
`${insightGroupedResults.length} 个文件,${insightResults.length} 个洞察`
|
||||
) : (
|
||||
`${totalAllFiles} 个文件,${totalBlocks} 个块,${insightResults.length} 个洞察`
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* 确认删除对话框 */}
|
||||
{showDeleteConfirm && (
|
||||
<div className="obsidian-confirm-dialog-overlay">
|
||||
@ -736,7 +800,7 @@ const SearchView = () => {
|
||||
<div className="obsidian-confirm-dialog-info-item">
|
||||
<strong>嵌入模型:</strong>
|
||||
<span className="obsidian-confirm-dialog-model">
|
||||
{settings.embeddingModelProvider} / {settings.embeddingModelId || '默认模型'}
|
||||
{settings.embeddingModelId}
|
||||
</span>
|
||||
</div>
|
||||
<div className="obsidian-confirm-dialog-info-item">
|
||||
@ -768,6 +832,13 @@ const SearchView = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 搜索进度 */}
|
||||
{isSearching && (
|
||||
<div className="obsidian-search-loading">
|
||||
正在搜索...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 搜索结果 */}
|
||||
<div className="obsidian-search-results">
|
||||
{searchMode === 'notes' ? (
|
||||
@ -827,7 +898,7 @@ const SearchView = () => {
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
) : searchMode === 'insights' ? (
|
||||
// AI 洞察搜索结果
|
||||
!isSearching && insightGroupedResults.length > 0 && (
|
||||
<div className="obsidian-results-list">
|
||||
@ -885,11 +956,92 @@ const SearchView = () => {
|
||||
))}
|
||||
</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 && (
|
||||
(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">
|
||||
<p>未找到相关结果</p>
|
||||
@ -900,6 +1052,35 @@ const SearchView = () => {
|
||||
{/* 样式 */}
|
||||
<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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@ -1064,38 +1245,6 @@ const SearchView = () => {
|
||||
/* 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 {
|
||||
@ -1395,13 +1544,70 @@ const SearchView = () => {
|
||||
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 初始化进度样式 */
|
||||
.obsidian-rag-initializing {
|
||||
padding: 20px;
|
||||
padding: 12px;
|
||||
background-color: var(--background-secondary);
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-radius: var(--radius-m);
|
||||
margin: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.obsidian-rag-init-header {
|
||||
@ -1488,7 +1694,7 @@ const SearchView = () => {
|
||||
background-color: var(--background-secondary);
|
||||
border: 1px solid var(--color-green, #28a745);
|
||||
border-radius: var(--radius-m);
|
||||
margin: 12px;
|
||||
margin-bottom: 12px;
|
||||
animation: slideInFromTop 0.3s ease-out;
|
||||
}
|
||||
|
||||
|
||||
@ -31,6 +31,7 @@ import OnMutationPlugin, {
|
||||
} from './plugins/on-mutation/OnMutationPlugin'
|
||||
|
||||
export type LexicalContentEditableProps = {
|
||||
rootTheme?: string
|
||||
editorRef: RefObject<LexicalEditor>
|
||||
contentEditableRef: RefObject<HTMLDivElement>
|
||||
onChange?: (content: SerializedEditorState) => void
|
||||
@ -52,6 +53,7 @@ export type LexicalContentEditableProps = {
|
||||
}
|
||||
|
||||
export default function LexicalContentEditable({
|
||||
rootTheme,
|
||||
editorRef,
|
||||
contentEditableRef,
|
||||
onChange,
|
||||
@ -68,7 +70,7 @@ export default function LexicalContentEditable({
|
||||
const initialConfig: InitialConfigType = {
|
||||
namespace: 'LexicalContentEditable',
|
||||
theme: {
|
||||
root: 'infio-chat-lexical-content-editable-root',
|
||||
root: rootTheme || 'infio-chat-lexical-content-editable-root',
|
||||
paragraph: 'infio-chat-lexical-content-editable-paragraph',
|
||||
},
|
||||
nodes: [MentionNode],
|
||||
|
||||
@ -6,7 +6,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useSettings } from '../../../contexts/SettingsContext'
|
||||
import { t } from '../../../lang/helpers'
|
||||
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 => {
|
||||
@ -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 [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 [isLoading, setIsLoading] = useState(true)
|
||||
const [searchTerm, setSearchTerm] = useState("")
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const providers = GetAllProviders()
|
||||
const providers = useMemo(() => {
|
||||
if (modelType === 'embedding') {
|
||||
return GetEmbeddingProviders()
|
||||
}
|
||||
return GetAllProviders()
|
||||
}, [modelType])
|
||||
|
||||
useEffect(() => {
|
||||
const fetchModels = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const models = await GetProviderModelIds(modelProvider, settings)
|
||||
if (modelType === 'embedding') {
|
||||
const models = GetEmbeddingProviderModelIds(modelProvider)
|
||||
setModelIds(models)
|
||||
} else {
|
||||
const models = await GetProviderModelsWithSettings(modelProvider, settings)
|
||||
setModelIds(Object.keys(models))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch provider models:', error)
|
||||
setModelIds([])
|
||||
@ -176,16 +220,30 @@ export function ModelSelect() {
|
||||
fetchModels()
|
||||
}, [modelProvider, settings])
|
||||
|
||||
// Sync chat model id & chat model provider
|
||||
// Sync model id & model provider based on modelType
|
||||
useEffect(() => {
|
||||
setModelProvider(settings.chatModelProvider)
|
||||
setChatModelId(settings.chatModelId)
|
||||
}, [settings.chatModelProvider, settings.chatModelId])
|
||||
setModelProvider(currentModelProvider)
|
||||
setChatModelId(currentModelId)
|
||||
}, [currentModelProvider, currentModelId])
|
||||
|
||||
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) => {
|
||||
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) => ({
|
||||
@ -194,7 +252,7 @@ export function ModelSelect() {
|
||||
provider: modelProvider,
|
||||
isCollected: isInCollected(id),
|
||||
}))
|
||||
}, [modelIds, modelProvider, settings.collectedChatModels])
|
||||
}, [modelIds, modelProvider, modelType, settings.collectedChatModels, settings.collectedInsightModels, settings.collectedApplyModels, settings.collectedEmbeddingModels])
|
||||
|
||||
const fuse = useMemo(() => {
|
||||
return new Fuse<SearchableItem>(searchableItems, {
|
||||
@ -229,11 +287,26 @@ export function ModelSelect() {
|
||||
e.stopPropagation();
|
||||
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
|
||||
);
|
||||
|
||||
let newCollectedModels = settings.collectedChatModels || [];
|
||||
let newCollectedModels = [...currentCollectedModels];
|
||||
|
||||
if (isCurrentlyCollected) {
|
||||
// remove
|
||||
@ -245,10 +318,33 @@ export function ModelSelect() {
|
||||
newCollectedModels = [...newCollectedModels, { provider: modelProvider, modelId: id }];
|
||||
}
|
||||
|
||||
// 根据模型类型更新相应的设置
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@ -272,21 +368,63 @@ export function ModelSelect() {
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content className="infio-popover infio-llm-setting-combobox-dropdown">
|
||||
{/* collected models */}
|
||||
{settings.collectedChatModels?.length > 0 && (
|
||||
{(() => {
|
||||
const getCollectedModels = () => {
|
||||
switch (modelType) {
|
||||
case 'insight':
|
||||
return settings.collectedInsightModels || []
|
||||
case 'apply':
|
||||
return settings.collectedApplyModels || []
|
||||
case 'embedding':
|
||||
return settings.collectedEmbeddingModels || []
|
||||
default:
|
||||
return settings.collectedChatModels || []
|
||||
}
|
||||
}
|
||||
|
||||
const collectedModels = getCollectedModels()
|
||||
|
||||
return collectedModels.length > 0 ? (
|
||||
<div className="infio-model-section">
|
||||
<div className="infio-model-section-title">
|
||||
<Star size={12} className="infio-star-active" /> {t('chat.input.collectedModels')}
|
||||
</div>
|
||||
<ul className="infio-collected-models-list">
|
||||
{settings.collectedChatModels.map((collectedModel, index) => (
|
||||
{collectedModels.map((collectedModel, index) => (
|
||||
<DropdownMenu.Item
|
||||
key={`${collectedModel.provider}-${collectedModel.modelId}`}
|
||||
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)
|
||||
@ -311,14 +449,37 @@ export function ModelSelect() {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
// delete
|
||||
const newCollectedModels = settings.collectedChatModels.filter(
|
||||
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>
|
||||
@ -327,7 +488,8 @@ export function ModelSelect() {
|
||||
</ul>
|
||||
<div className="infio-model-separator"></div>
|
||||
</div>
|
||||
)}
|
||||
) : null
|
||||
})()}
|
||||
|
||||
<div className="infio-llm-setting-search-container">
|
||||
<div className="infio-llm-setting-provider-container">
|
||||
@ -384,11 +546,37 @@ export function ModelSelect() {
|
||||
e.preventDefault()
|
||||
const selectedOption = filteredOptions[selectedIndex]
|
||||
if (selectedOption) {
|
||||
// 根据模型类型更新相应的设置
|
||||
switch (modelType) {
|
||||
case 'insight':
|
||||
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)
|
||||
setSearchTerm("")
|
||||
setIsOpen(false)
|
||||
@ -421,11 +609,37 @@ export function ModelSelect() {
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
// 根据模型类型更新相应的设置
|
||||
switch (modelType) {
|
||||
case 'insight':
|
||||
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)
|
||||
setIsOpen(false)
|
||||
}
|
||||
@ -448,11 +662,37 @@ export function ModelSelect() {
|
||||
<DropdownMenu.Item
|
||||
key={option.id}
|
||||
onSelect={() => {
|
||||
// 根据模型类型更新相应的设置
|
||||
switch (modelType) {
|
||||
case 'insight':
|
||||
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)
|
||||
setSearchTerm("")
|
||||
setIsOpen(false)
|
||||
@ -460,9 +700,21 @@ export function ModelSelect() {
|
||||
className={`infio-llm-setting-combobox-option ${isSelected ? 'is-selected' : ''}`}
|
||||
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
|
||||
? index
|
||||
: index + settings.collectedChatModels?.length;
|
||||
: index + getCollectedModels().length;
|
||||
setSelectedIndex(hoverIndex);
|
||||
}}
|
||||
asChild
|
||||
@ -617,9 +869,8 @@ export function ModelSelect() {
|
||||
text-align: left;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background-color: var(--background-primary);
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-radius: 6px;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
font-size: 0.9em;
|
||||
transition: all 0.2s ease;
|
||||
@ -634,7 +885,6 @@ export function ModelSelect() {
|
||||
|
||||
.infio-llm-setting-provider-switch:hover {
|
||||
border-color: var(--interactive-accent);
|
||||
background-color: var(--background-primary-alt);
|
||||
}
|
||||
|
||||
.infio-llm-setting-provider-switch:focus {
|
||||
@ -728,12 +978,9 @@ export function ModelSelect() {
|
||||
.infio-provider-badge {
|
||||
font-size: 10px;
|
||||
padding: 2px 6px;
|
||||
background-color: var(--background-modifier-hover);
|
||||
border-radius: 4px;
|
||||
margin-right: 6px;
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
|
||||
@ -6,10 +6,12 @@ import {
|
||||
useState
|
||||
} from 'react'
|
||||
|
||||
|
||||
import { Mentionable } from '../../../types/mentionable'
|
||||
|
||||
import LexicalContentEditable from './LexicalContentEditable'
|
||||
import { SearchButton } from './SearchButton'
|
||||
import { SearchModeSelect } from './SearchModeSelect'
|
||||
|
||||
export type SearchInputRef = {
|
||||
focus: () => void
|
||||
@ -25,26 +27,28 @@ export type SearchInputProps = {
|
||||
placeholder?: string
|
||||
autoFocus?: boolean
|
||||
disabled?: boolean
|
||||
searchMode?: 'notes' | 'insights' | 'all'
|
||||
onSearchModeChange?: (mode: 'notes' | 'insights' | 'all') => void
|
||||
}
|
||||
|
||||
// 检查编辑器状态是否为空的辅助函数
|
||||
// 检查编辑器状态是否为空
|
||||
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
|
||||
}
|
||||
|
||||
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>(
|
||||
@ -56,6 +60,8 @@ const SearchInputWithActions = forwardRef<SearchInputRef, SearchInputProps>(
|
||||
placeholder = '',
|
||||
autoFocus = false,
|
||||
disabled = false,
|
||||
searchMode = 'all',
|
||||
onSearchModeChange,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
@ -112,6 +118,7 @@ const SearchInputWithActions = forwardRef<SearchInputRef, SearchInputProps>(
|
||||
</div>
|
||||
)}
|
||||
<LexicalContentEditable
|
||||
rootTheme="infio-search-lexical-content-editable-root"
|
||||
initialEditorState={(editor) => {
|
||||
if (initialSerializedEditorState) {
|
||||
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__model-select-container">
|
||||
{/* TODO: add model select */}
|
||||
{onSearchModeChange && (
|
||||
<SearchModeSelect
|
||||
searchMode={searchMode}
|
||||
onSearchModeChange={onSearchModeChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
</div>
|
||||
<div className="infio-chat-user-input-controls__buttons">
|
||||
<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(
|
||||
async () => {
|
||||
// 在嵌入之前处理 markdown,只处理一次
|
||||
// 在嵌入之前处理 markdown
|
||||
const cleanedBatchData = batchChunks.map(chunk => {
|
||||
const cleanContent = removeMarkdown(chunk.content).replace(/\0/g, '')
|
||||
return { chunk, cleanContent }
|
||||
|
||||
@ -8,36 +8,153 @@ interface EmbedResult {
|
||||
vec: number[];
|
||||
tokens: number;
|
||||
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 {
|
||||
method: string;
|
||||
params: any;
|
||||
params: WorkerParams;
|
||||
id: number;
|
||||
worker_id?: string;
|
||||
}
|
||||
|
||||
interface WorkerResponse {
|
||||
id: number;
|
||||
result?: any;
|
||||
result?: unknown;
|
||||
error?: 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 pipeline: any = null;
|
||||
let tokenizer: any = null;
|
||||
let model: ModelInfo | null = null;
|
||||
let pipeline: unknown = null;
|
||||
let tokenizer: unknown = null;
|
||||
let processing_message = 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
|
||||
async function loadTransformers() {
|
||||
async function loadTransformers(): Promise<void> {
|
||||
if (transformersLoaded) return;
|
||||
|
||||
try {
|
||||
console.log('Loading Transformers.js...');
|
||||
|
||||
// 首先初始化端点
|
||||
await initializeEndpoint();
|
||||
|
||||
// 尝试使用旧版本的 Transformers.js,它在 Worker 中更稳定
|
||||
const { pipeline: pipelineFactory, env, AutoTokenizer } = await import('@xenova/transformers');
|
||||
|
||||
@ -53,9 +170,12 @@ async function loadTransformers() {
|
||||
env.useFS = false;
|
||||
env.useBrowserCache = true;
|
||||
|
||||
(globalThis as any).pipelineFactory = pipelineFactory;
|
||||
(globalThis as any).AutoTokenizer = AutoTokenizer;
|
||||
(globalThis as any).env = env;
|
||||
const globalTransformers = globalThis as unknown as { transformers?: GlobalTransformers };
|
||||
globalTransformers.transformers = {
|
||||
pipelineFactory,
|
||||
AutoTokenizer,
|
||||
env
|
||||
};
|
||||
|
||||
transformersLoaded = true;
|
||||
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 {
|
||||
console.log(`Loading model: ${modelKey}, GPU: ${useGpu}`);
|
||||
|
||||
// 确保 Transformers.js 已加载
|
||||
await loadTransformers();
|
||||
|
||||
const pipelineFactory = (globalThis as any).pipelineFactory;
|
||||
const AutoTokenizer = (globalThis as any).AutoTokenizer;
|
||||
const env = (globalThis as any).env;
|
||||
const globalTransformers = globalThis as unknown as { transformers?: GlobalTransformers };
|
||||
const transformers = globalTransformers.transformers;
|
||||
|
||||
if (!transformers) {
|
||||
throw new Error('Transformers.js not loaded');
|
||||
}
|
||||
|
||||
const { pipelineFactory, AutoTokenizer } = transformers;
|
||||
|
||||
// 配置管道选项
|
||||
const pipelineOpts: any = {
|
||||
const pipelineOpts: PipelineOptions = {
|
||||
quantized: true,
|
||||
// 修复进度回调,添加错误处理
|
||||
progress_callback: (progress: any) => {
|
||||
progress_callback: (progress: unknown) => {
|
||||
try {
|
||||
if (progress && typeof progress === 'object') {
|
||||
// console.log('Model loading progress:', progress);
|
||||
@ -96,9 +221,9 @@ async function loadModel(modelKey: string, useGpu: boolean = false) {
|
||||
if (useGpu) {
|
||||
try {
|
||||
// 检查 WebGPU 支持
|
||||
console.log("useGpu", useGpu)
|
||||
console.log("useGpu", useGpu);
|
||||
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') {
|
||||
console.log('[Transformers] Attempting to use GPU');
|
||||
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 {
|
||||
console.log('Unloading model...');
|
||||
|
||||
if (pipeline) {
|
||||
if (pipeline.destroy) {
|
||||
pipeline.destroy();
|
||||
if (pipeline && typeof pipeline === 'object' && 'destroy' in pipeline) {
|
||||
const pipelineWithDestroy = pipeline as { destroy: () => void };
|
||||
pipelineWithDestroy.destroy();
|
||||
}
|
||||
pipeline = null;
|
||||
}
|
||||
|
||||
if (tokenizer) {
|
||||
tokenizer = null;
|
||||
}
|
||||
|
||||
model = null;
|
||||
|
||||
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 {
|
||||
if (!tokenizer) {
|
||||
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 };
|
||||
|
||||
} 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) => ({
|
||||
@ -262,10 +385,11 @@ async function processBatch(batchInputs: EmbedInput[]): Promise<EmbedResult[]> {
|
||||
console.error('Error processing batch:', error);
|
||||
|
||||
// 如果批处理失败,尝试逐个处理
|
||||
return Promise.all(
|
||||
batchInputs.map(async (item) => {
|
||||
const results = await Promise.all(
|
||||
batchInputs.map(async (item): Promise<EmbedResult> => {
|
||||
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);
|
||||
|
||||
return {
|
||||
@ -279,11 +403,13 @@ async function processBatch(batchInputs: EmbedInput[]): Promise<EmbedResult[]> {
|
||||
vec: [],
|
||||
tokens: 0,
|
||||
embed_input: item.embed_input,
|
||||
error: (singleError as Error).message
|
||||
} as any;
|
||||
error: singleError instanceof Error ? singleError.message : 'Unknown error'
|
||||
};
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
|
||||
@ -291,12 +417,13 @@ async function processMessage(data: WorkerMessage): Promise<WorkerResponse> {
|
||||
const { method, params, id, worker_id } = data;
|
||||
|
||||
try {
|
||||
let result: any;
|
||||
let result: unknown;
|
||||
|
||||
switch (method) {
|
||||
case 'load':
|
||||
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;
|
||||
|
||||
case 'unload':
|
||||
@ -318,7 +445,8 @@ async function processMessage(data: WorkerMessage): Promise<WorkerResponse> {
|
||||
}
|
||||
|
||||
processing_message = true;
|
||||
result = await embedBatch(params.inputs);
|
||||
const embedParams = params as EmbedBatchParams;
|
||||
result = await embedBatch(embedParams.inputs);
|
||||
processing_message = false;
|
||||
break;
|
||||
|
||||
@ -336,7 +464,8 @@ async function processMessage(data: WorkerMessage): Promise<WorkerResponse> {
|
||||
}
|
||||
|
||||
processing_message = true;
|
||||
result = await countTokens(params);
|
||||
const tokenParams = params as string;
|
||||
result = await countTokens(tokenParams);
|
||||
processing_message = false;
|
||||
break;
|
||||
|
||||
@ -349,7 +478,7 @@ async function processMessage(data: WorkerMessage): Promise<WorkerResponse> {
|
||||
} catch (error) {
|
||||
console.error('Error processing message:', error);
|
||||
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;
|
||||
}
|
||||
|
||||
const response = await processMessage(event.data);
|
||||
const response = await processMessage(event.data as WorkerMessage);
|
||||
console.log('Worker sending response:', response);
|
||||
self.postMessage(response);
|
||||
} catch (error) {
|
||||
console.error('Unhandled error in worker message handler:', error);
|
||||
self.postMessage({
|
||||
id: event.data?.id || -1,
|
||||
error: `Worker error: ${error.message || 'Unknown error'}`
|
||||
id: (event.data as { id?: number })?.id || -1,
|
||||
error: `Worker error: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@ -282,6 +282,24 @@ export const InfioSettingsSchema = z.object({
|
||||
modelId: z.string(),
|
||||
})).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)
|
||||
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;
|
||||
}
|
||||
|
||||
.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 {
|
||||
margin: 0;
|
||||
line-height: 1.6;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user