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:
duanfuxiang 2025-07-07 16:56:12 +08:00
parent 3db334c6e8
commit c89186a40d
11 changed files with 1393 additions and 593 deletions

View File

@ -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[]>([])

View File

@ -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>
{/* 结果统计 */}
{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">
{hasLoaded && !isLoading && (
<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 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>

View File

@ -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 () => {
@ -426,7 +461,7 @@ const SearchView = () => {
// 移除图片显示,避免布局问题
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) {
return <code className="obsidian-inline-code">{children}</code>
}
@ -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">
<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,6 +638,79 @@ const SearchView = () => {
</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>
)}
@ -581,107 +726,26 @@ const SearchView = () => {
placeholder="语义搜索(按回车键搜索)..."
autoFocus={true}
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>
{/* 结果统计 */}
{/* 索引统计 */}
{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>
)}
{/* 搜索进度 */}
{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 && (
<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,21 +956,131 @@ 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>
</div>
)}
<div className="obsidian-no-results">
<p></p>
</div>
)}
</div>
{/* 样式 */}
<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;
}

View File

@ -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],

View File

@ -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)
setModelIds(models)
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 }];
}
setSettings({
...settings,
collectedChatModels: newCollectedModels,
});
// 根据模型类型更新相应的设置
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,62 +368,128 @@ export function ModelSelect() {
<DropdownMenu.Portal>
<DropdownMenu.Content className="infio-popover infio-llm-setting-combobox-dropdown">
{/* collected models */}
{settings.collectedChatModels?.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) => (
<DropdownMenu.Item
key={`${collectedModel.provider}-${collectedModel.modelId}`}
onSelect={() => {
setSettings({
...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)
);
{(() => {
const getCollectedModels = () => {
switch (modelType) {
case 'insight':
return settings.collectedInsightModels || []
case 'apply':
return settings.collectedApplyModels || []
case 'embedding':
return settings.collectedEmbeddingModels || []
default:
return settings.collectedChatModels || []
}
}
setSettings({
...settings,
collectedChatModels: newCollectedModels,
});
}} />
</div>
</li>
</DropdownMenu.Item>
))}
</ul>
<div className="infio-model-separator"></div>
</div>
)}
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">
{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)
}}
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-provider-container">
@ -384,11 +546,37 @@ export function ModelSelect() {
e.preventDefault()
const selectedOption = filteredOptions[selectedIndex]
if (selectedOption) {
setSettings({
...settings,
chatModelProvider: modelProvider,
chatModelId: selectedOption.id,
})
// 根据模型类型更新相应的设置
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()
setSettings({
...settings,
chatModelProvider: modelProvider,
chatModelId: searchTerm,
})
// 根据模型类型更新相应的设置
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={() => {
setSettings({
...settings,
chatModelProvider: modelProvider,
chatModelId: option.id,
})
// 根据模型类型更新相应的设置
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
@ -475,7 +727,7 @@ export function ModelSelect() {
{searchTerm ? (
<HighlightedText segments={option.html} />
) : (
<span title={option.id}>{option.id}</span>
<span title={option.id}>{option.id}</span>
)}
</div>
<div
@ -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>

View File

@ -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()} />

View 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>
</>
)
}

View File

@ -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 }

View File

@ -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();
}
pipeline = null;
}
if (tokenizer) {
tokenizer = null;
if (pipeline && typeof pipeline === 'object' && 'destroy' in pipeline) {
const pipelineWithDestroy = pipeline as { destroy: () => void };
pipelineWithDestroy.destroy();
}
pipeline = null;
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'}`
});
}
});

View File

@ -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),

View File

@ -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;