@@ -1,23 +1,75 @@
import { useEffect , useState } from 'react' ;
import { getMindSearchConfig , updateMindSearchConfig } from '../../api/client' ;
import type { MindSearchConfig } from '../../types' ;
import {
getMindSearchConfig ,
listLlmProviderKeys ,
testLlmProviderKey ,
testMindSearchService ,
updateMindSearchConfig ,
} from '../../api/client' ;
import type {
LlmProviderKeyRow ,
MindSearchConfig ,
MindSearchRoutes ,
MindSearchService ,
} from '../../types' ;
const builtInServices : MindSearchService [ ] = [
{ id : 'searxng' , name : 'SearXNG' , kind : 'provider' , adapter : 'searxng' , capabilities : [ 'search.web' , 'search.news' ] , endpoint : '' , healthPath : '/config' , enabled : false , timeoutMs : 8000 , priority : 100 } ,
{ id : 'github' , name : 'GitHub Code' , kind : 'provider' , adapter : 'github' , capabilities : [ 'search.code' ] , endpoint : 'https://api.github.com/search/code' , healthPath : '' , enabled : false , timeoutMs : 8000 , priority : 90 } ,
{ id : 'reader' , name : 'Safe Reader' , kind : 'reader' , adapter : 'reader' , capabilities : [ 'search.read' ] , endpoint : '' , healthPath : '' , enabled : false , timeoutMs : 8000 , priority : 80 } ,
{ id : 'deep-search' , name : 'TKMind Deep Search' , kind : 'orchestrator' , adapter : 'research-http' , capabilities : [ 'search.web' , 'research.plan' , 'research.execute' ] , endpoint : 'http://127.0.0.1:20100' , healthPath : '/health' , enabled : false , timeoutMs : 120000 , priority : 100 , llmProviderKeyId : '' , llmModel : '' } ,
] ;
const initial : MindSearchConfig = {
enabled : false ,
mode : 'off' ,
providers : { searxng : false , github : false , reader : false } ,
settings : { searxngEndpoint : '' , maxResults : 10 , timeoutMs : 8000 , readerMaxChars : 12000 } ,
services : builtInServices ,
routes : { web : 'searxng' , news : 'searxng' , code : 'github' , read : 'reader' , research : 'deep-search' } ,
} ;
const routeLabels : Record < keyof MindSearchRoutes , string > = {
web : '普通网页搜索' ,
news : '新闻搜索' ,
code : '代码搜索' ,
read : '网页读取' ,
research : '深度研究' ,
} ;
function mergeConfig ( config : MindSearchConfig ) : MindSearchConfig {
const configuredServices = config . services ? ? [ ] ;
const configuredServiceIds = new Set ( configuredServices . map ( ( service ) = > service . id ) ) ;
const services = configuredServices . length
? [
. . . configuredServices ,
. . . builtInServices . filter ( ( service ) = > ! configuredServiceIds . has ( service . id ) ) ,
]
: builtInServices ;
return {
. . . initial ,
. . . config ,
settings : { . . . initial . settings , . . . config . settings } ,
services ,
routes : { . . . initial . routes , . . . config . routes } ,
} ;
}
export function MindSearchPage() {
const [ config , setConfig ] = useState ( initial ) ;
const [ llmKeys , setLlmKeys ] = useState < LlmProviderKeyRow [ ] > ( [ ] ) ;
const [ meta , setMeta ] = useState < { source? : string ; updatedAt? : number | null ; updatedBy? : string | null } > ( { } ) ;
const [ message , setMessage ] = useState ( '加载配置中…' ) ;
useEffect ( ( ) = > {
getMindSearchConfig ( )
. then ( ( result ) = > {
setConfig ( { . . . initial , . . . result . config , settings : { . . . initial . settings , . . . result . config . settings } } ) ;
Promise . all ( [
getMindSearchConfig ( ) ,
listLlmProviderKeys ( ) . catch ( ( ) = > [ ] as LlmProviderKeyRow [ ] ) ,
] )
. then ( ( [ result , keys ] ) = > {
setConfig ( mergeConfig ( result . config ) ) ;
setLlmKeys ( keys . filter ( ( key ) = > key . status === 'active' ) ) ;
setMeta ( result ) ;
setMessage ( '' ) ;
} )
@@ -28,7 +80,7 @@ export function MindSearchPage() {
setMessage ( '保存中…' ) ;
try {
const result = await updateMindSearchConfig ( config ) ;
setConfig ( { . . . initial , . . . result . config , settings : { . . . initial . settings , . . . result . config . settings } } ) ;
setConfig ( mergeConfig ( result . config ) ) ;
setMeta ( result ) ;
setMessage ( '已保存到数据库;新会话生效,旧会话不受影响' ) ;
} catch ( error ) {
@@ -36,30 +88,207 @@ export function MindSearchPage() {
}
} ;
const toggleProvider = ( key : keyof MindSearchConfig [ 'providers' ] ) = >
setConfig ( ( current ) = > ( { . . . current , providers : { . . . current . providers , [ key ] : ! current . providers [ key ] } } ) ) ;
const updateSetting = ( key : keyof MindSearchConfig [ 'settings' ] , value : string | number ) = >
setConfig ( ( current ) = > ( { . . . current , settings : { . . . current . settings , [ key ] : value } } ) ) ;
const updateService = ( id : string , patch : Partial < MindSearchService > ) = >
setConfig ( ( current ) = > ( {
. . . current ,
services : current.services.map ( ( service ) = > service . id === id ? { . . . service , . . . patch } : service ) ,
} ) ) ;
const addService = ( ) = > {
const existingIds = new Set ( config . services . map ( ( service ) = > service . id ) ) ;
let suffix = config . services . length + 1 ;
while ( existingIds . has ( ` research- ${ suffix } ` ) ) suffix += 1 ;
const id = ` research- ${ suffix } ` ;
setConfig ( ( current ) = > ( {
. . . current ,
services : [ . . . current . services , {
id ,
name : 'Research Engine' ,
kind : 'orchestrator' ,
adapter : 'research-http' ,
capabilities : [ 'search.web' , 'research.plan' , 'research.execute' ] ,
endpoint : 'http://127.0.0.1:20100' ,
healthPath : '/health' ,
enabled : false ,
timeoutMs : 30000 ,
priority : 100 ,
llmProviderKeyId : '' ,
llmModel : '' ,
} ] ,
} ) ) ;
} ;
const removeService = ( id : string ) = >
setConfig ( ( current ) = > ( {
. . . current ,
services : current.services.filter ( ( service ) = > service . id !== id ) ,
routes : Object.fromEntries (
Object . entries ( current . routes ) . map ( ( [ key , value ] ) = > [ key , value === id ? '' : value ] ) ,
) as unknown as MindSearchRoutes ,
} ) ) ;
const runServiceTest = async ( id : string ) = > {
setMessage ( ` 测试 ${ id } 中… ` ) ;
try {
const result = await testMindSearchService ( id ) ;
setMessage ( ` ${ result . ok ? '通过' : '失败' } : ${ id } ; ${ result . message } ; ${ result . latencyMs } ms ${ result . resultCount == null ? '' : ` ; ${ result . resultCount } 条结果 ` } ` ) ;
} catch ( error ) {
setMessage ( error instanceof Error ? error . message : '服务测试失败' ) ;
}
} ;
const runLlmTest = async ( service : MindSearchService ) = > {
if ( ! service . llmProviderKeyId || ! service . llmModel ) {
setMessage ( '请先选择 Deep Search 的 LLM 配置和模型' ) ;
return ;
}
setMessage ( ` 测试 ${ service . llmModel } 中… ` ) ;
try {
const result = await testLlmProviderKey ( service . llmProviderKeyId , service . llmModel ) ;
setMessage ( result . ok
? ` LLM 通过: ${ result . model ? ? service . llmModel } ${ result . latencyMs == null ? '' : ` ; ${ result . latencyMs } ms ` } `
: ` LLM 失败: ${ result . message ? ? '模型连接失败' } ` ) ;
} catch ( error ) {
setMessage ( error instanceof Error ? error . message : 'LLM 测试失败' ) ;
}
} ;
return (
< section className = "admin-page" >
< div className = "admin-page-header" > < div > < h1 > MindSearch < / h1 > < p > 可 插 拔 外 部 搜 索 增 强 。 关 闭 时 完 全 保 持 原 有 搜 索 、 记 忆 和 上 下 文 链 路 。 < / p > < / div > < / div >
< div className = "admin-page-header" >
< div >
< h1 > MindSearch < / h1 >
< p > 可 插 拔 搜 索 与 研 究 服 务 。 关 闭 时 保 持 原 有 搜 索 、 记 忆 和 上 下 文 链 路 。 < / p >
< / div >
< / div >
< div className = "admin-card" >
< h2 > 总 开 关 与 模 式 < / h2 >
< label > < input type = "checkbox" checked = { config . enabled } onChange = { ( event ) = > setConfig ( { . . . config , enabled : event.target.checked , mode : event.target.checked ? ( config . mode === 'off' ? 'shadow' : config . mode ) : 'off' } ) } / > 启 用 MindSearch < / label >
< label > 运 行 模 式 < select value = { config . mode } onChange = { ( event ) = > setConfig ( { . . . config , mode : event.target.value as MindSearchConfig [ 'mode' ] } ) } > < option value = "off" > 关 闭 < / option > < option value = "shadow" > Shadow ( 只 记 录 , 不 注 入 ) < / option > < option value = "assist" > Assist ( 补 充 工 具 ) < / option > < / select > < / label >
< h2 > Provider < / h2 >
< fieldset > < legend > 可 用 Provider < / legend >
< label > < input type = "checkbox" checked = { config . providers . searxng } onChange = { ( ) = > toggleProvider ( 'searxng' ) } / > SearXNG Web / News < / label >
< label > < input type = "checkbox" checked = { config . providers . github } onChange = { ( ) = > toggleProvider ( 'github' ) } / > GitHub Code ( Token 通 过 环 境 变 量 配 置 , 不 在 页 面 回 显 ) < / label >
< label > < input type = "checkbox" checked = { config . providers . reader } onChange = { ( ) = > toggleProvider ( 'reader' ) } / > Reader ( 阻 止 localhost / 内 网 地 址 ) < / label >
< / fieldset >
< div className = "admin-form" >
< label className = "search-service-toggle" >
< input
type = "checkbox"
checked = { config . enabled }
onChange = { ( event ) = > setConfig ( {
. . . config ,
enabled : event.target.checked ,
mode : event.target.checked ? ( config . mode === 'off' ? 'shadow' : config . mode ) : 'off' ,
} ) }
/ >
< span > 启 用 MindSearch < / span >
< / label >
< label >
< span > 运 行 模 式 < / span >
< select value = { config . mode } onChange = { ( event ) = > setConfig ( { . . . config , mode : event.target.value as MindSearchConfig [ 'mode' ] } ) } >
< option value = "off" > 关 闭 < / option >
< option value = "shadow" > Shadow ( 只 记 录 , 不 注 入 ) < / option >
< option value = "assist" > Assist ( 补 充 工 具 ) < / option >
< / select >
< / label >
< / div >
< h2 > 服 务 注 册 表 < / h2 >
< p > 服 务 独 立 部 署 ; 后 台 只 管 理 连 接 、 路 由 和 健 康 检 查 , 不 直 接 操 作 Docker 或 生 产 进 程 。 测 试 使 用 已 保 存 配 置 。 < / p >
< div className = "search-service-grid" >
{ config . services . map ( ( service ) = > {
const selectedLlmKey = llmKeys . find ( ( key ) = > key . id === service . llmProviderKeyId ) ;
return (
< fieldset key = { service . id } className = "search-service-card" >
< legend > { service . name } ( { service . id } ) < / legend >
< label className = "search-service-toggle" > < input type = "checkbox" checked = { service . enabled } onChange = { ( event ) = > updateService ( service . id , { enabled : event.target.checked } ) } / > < span > 启 用 服 务 < / span > < / label >
< div className = "admin-form" >
< label > < span > 名 称 < / span > < input value = { service . name } onChange = { ( event ) = > updateService ( service . id , { name : event.target.value } ) } / > < / label >
< label >
< span > 类 型 < / span >
< select value = { service . kind } onChange = { ( event ) = > updateService ( service . id , { kind : event.target.value as MindSearchService [ 'kind' ] } ) } >
< option value = "provider" > Provider < / option >
< option value = "reader" > Reader < / option >
< option value = "orchestrator" > Orchestrator < / option >
< / select >
< / label >
< label >
< span > 适 配 器 < / span >
< select value = { service . adapter } onChange = { ( event ) = > updateService ( service . id , { adapter : event.target.value as MindSearchService [ 'adapter' ] } ) } >
< option value = "searxng" > SearXNG < / option >
< option value = "github" > GitHub < / option >
< option value = "reader" > Reader < / option >
< option value = "research-http" > Research HTTP < / option >
< / select >
< / label >
< label > < span > 健 康 检 查 路 径 < / span > < input value = { service . healthPath } placeholder = "/health" onChange = { ( event ) = > updateService ( service . id , { healthPath : event.target.value } ) } / > < / label >
< label className = "form-span-all" > < span > Endpoint < / span > < input value = { service . endpoint } placeholder = "http://127.0.0.1:20100" onChange = { ( event ) = > updateService ( service . id , { endpoint : event.target.value } ) } / > < / label >
< label className = "form-span-all" > < span > Capabilities < / span > < input value = { service . capabilities . join ( ', ' ) } onChange = { ( event ) = > updateService ( service . id , { capabilities : event.target.value.split ( ',' ) . map ( ( item ) = > item . trim ( ) ) . filter ( Boolean ) } ) } / > < / label >
< label > < span > 超 时 ( 毫 秒 ) < / span > < input type = "number" min = { 1000 } max = { 120000 } value = { service . timeoutMs } onChange = { ( event ) = > updateService ( service . id , { timeoutMs : Number ( event . target . value ) } ) } / > < / label >
< label > < span > 优 先 级 < / span > < input type = "number" min = { 1 } max = { 1000 } value = { service . priority } onChange = { ( event ) = > updateService ( service . id , { priority : Number ( event . target . value ) } ) } / > < / label >
{ service . adapter === 'research-http' && < >
< label className = "form-span-all" >
< span > 规 划 与 报 告 LLM ( 可 选 ) < / span >
< select
value = { service . llmProviderKeyId ? ? '' }
onChange = { ( event ) = > {
const key = llmKeys . find ( ( item ) = > item . id === event . target . value ) ;
updateService ( service . id , {
llmProviderKeyId : event.target.value ,
llmModel : key?.defaultModel ? ? '' ,
} ) ;
} }
>
< option value = "" > 不 使 用 LLM ( 确 定 性 降 级 ) < / option >
{ service . llmProviderKeyId && ! selectedLlmKey
&& < option value = { service . llmProviderKeyId } > 原 LLM 配 置 已 停 用 或 删 除 < / option > }
{ llmKeys . map ( ( key ) = > (
< option key = { key . id } value = { key . id } > { key . name } · { key . providerLabel } < / option >
) ) }
< / select >
< / label >
< label className = "form-span-all" >
< span > 模 型 < / span >
< select
value = { service . llmModel ? ? '' }
disabled = { ! selectedLlmKey }
onChange = { ( event ) = > updateService ( service . id , { llmModel : event.target.value } ) }
>
{ ! selectedLlmKey && < option value = "" > 请 先 选 择 LLM 配 置 < / option > }
{ selectedLlmKey && service . llmModel && ! selectedLlmKey . models . includes ( service . llmModel )
&& < option value = { service . llmModel } > 原 模 型 已 不 可 用 : { service . llmModel } < / option > }
{ selectedLlmKey ? . models . map ( ( model ) = > < option key = { model } value = { model } > { model } < / option > ) }
< / select >
< / label >
< p className = "search-llm-hint form-span-all" > 密 钥 复 用 “ 统 一 模 型 中 心 ” , 不 会 保 存 到 MindSearch 配 置 或 下 发 给 Deep Search 服 务 。 < / p >
< / > }
< / div >
< div className = "search-service-actions" >
< button className = "send-btn" type = "button" onClick = { ( ) = > runServiceTest ( service . id ) } > 测 试 已 保 存 配 置 < / button >
{ service . adapter === 'research-http' && service . llmProviderKeyId && service . llmModel
&& < button type = "button" onClick = { ( ) = > runLlmTest ( service ) } > 测 试 LLM < / button > }
{ ! [ 'searxng' , 'github' , 'reader' , 'deep-search' ] . includes ( service . id ) && < button type = "button" onClick = { ( ) = > removeService ( service . id ) } > 移 除 < / button > }
< / div >
< / fieldset >
) ;
} ) }
< / div >
< button className = "send-btn" type = "button" onClick = { addService } > 添 加 Research Service < / button >
< h2 > 能 力 路 由 < / h2 >
< div className = "admin-form" >
{ ( Object . keys ( routeLabels ) as Array < keyof MindSearchRoutes > ) . map ( ( route ) = > (
< label key = { route } >
< span > { routeLabels [ route ] } < / span >
< select
value = { config . routes [ route ] }
onChange = { ( event ) = > setConfig ( ( current ) = > ( { . . . current , routes : { . . . current . routes , [ route ] : event . target . value } } ) ) }
>
< option value = "" > 未 配 置 < / option >
{ config . services . map ( ( service ) = > < option key = { service . id } value = { service . id } > { service . name } ( { service . id } ) < / option > ) }
< / select >
< / label >
) ) }
< / div >
< h2 > 运 行 参 数 < / h2 >
< label > SearXNG 地 址 < input value = { config . settings . searxngEndpoint } placeholder = "http://127.0.0.1:8080/search" onChange = { ( event ) = > updateSetting ( 'searxngEndpoint' , event . target . value ) } / > < / label >
< label > 最 大 结 果 数 < input type = "number" min = { 1 } max = { 20 } value = { config . settings . maxResults } onChange = { ( event ) = > updateSetting ( 'maxResults' , Number ( event . target . value ) ) } / > < / label >
< label > Provider 超 时 ( 毫 秒 ) < input type = "number" min = { 1000 } max = { 30000 } value = { config . settings . timeoutMs } onChange = { ( event ) = > updateSetting ( 'timeoutMs' , Number ( event . target . value ) ) } / > < / label >
< label > Reader 最 大 字 符 数 < input type = "number" min = { 1000 } max = { 50000 } value = { config . settings . readerMaxChars } onChange = { ( event ) = > updateSetting ( 'readerMaxChars' , Number ( event . target . value ) ) } / > < / label >
< button type = "button" onClick = { save } > 保 存 配 置 < / button >
< div className = "admin-form" >
< label > < span > 最 大 结 果 数 < / span > < input type = "number" min = { 1 } max = { 20 } value = { config . settings . maxResults } onChange = { ( event ) = > updateSetting ( 'maxResults' , Number ( event . target . value ) ) } / > < / label >
< label > < span > 默 认 Provider 超 时 ( 毫 秒 ) < / span > < input type = "number" min = { 1000 } max = { 30000 } value = { config . settings . timeoutMs } onChange = { ( event ) = > updateSetting ( 'timeoutMs' , Number ( event . target . value ) ) } / > < / label >
< label > < span > Reader 最 大 字 符 数 < / span > < input type = "number" min = { 1000 } max = { 50000 } value = { config . settings . readerMaxChars } onChange = { ( event ) = > updateSetting ( 'readerMaxChars' , Number ( event . target . value ) ) } / > < / label >
< / div >
< button className = "send-btn" type = "button" onClick = { save } > 保 存 配 置 < / button >
{ message && < p role = "status" > { message } < / p > }
< p > 配 置 来 源 : { meta . source === 'admin' ? '数据库' : '环境变量默认值' } ; 最 后 更 新 : { meta . updatedAt ? new Date ( meta . updatedAt ) . toLocaleString ( ) : '尚未保存' } { meta . updatedBy ? ` ;操作人: ${ meta . updatedBy } ` : '' } < / p >
< / div >