feat: manage pluggable search services
This commit is contained in:
@@ -1,14 +1,40 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getMindSearchConfig, updateMindSearchConfig } from '../../api/client';
|
||||
import type { MindSearchConfig } from '../../types';
|
||||
import { getMindSearchConfig, testMindSearchService, updateMindSearchConfig } from '../../api/client';
|
||||
import type { 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 },
|
||||
];
|
||||
|
||||
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: '' },
|
||||
};
|
||||
|
||||
const routeLabels: Record<keyof MindSearchRoutes, string> = {
|
||||
web: '普通网页搜索',
|
||||
news: '新闻搜索',
|
||||
code: '代码搜索',
|
||||
read: '网页读取',
|
||||
research: '深度研究',
|
||||
};
|
||||
|
||||
function mergeConfig(config: MindSearchConfig): MindSearchConfig {
|
||||
return {
|
||||
...initial,
|
||||
...config,
|
||||
settings: { ...initial.settings, ...config.settings },
|
||||
services: config.services?.length ? config.services : builtInServices,
|
||||
routes: { ...initial.routes, ...config.routes },
|
||||
};
|
||||
}
|
||||
|
||||
export function MindSearchPage() {
|
||||
const [config, setConfig] = useState(initial);
|
||||
const [meta, setMeta] = useState<{ source?: string; updatedAt?: number | null; updatedBy?: string | null }>({});
|
||||
@@ -17,7 +43,7 @@ export function MindSearchPage() {
|
||||
useEffect(() => {
|
||||
getMindSearchConfig()
|
||||
.then((result) => {
|
||||
setConfig({ ...initial, ...result.config, settings: { ...initial.settings, ...result.config.settings } });
|
||||
setConfig(mergeConfig(result.config));
|
||||
setMeta(result);
|
||||
setMessage('');
|
||||
})
|
||||
@@ -28,7 +54,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 +62,149 @@ 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,
|
||||
}],
|
||||
}));
|
||||
};
|
||||
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 : '服务测试失败');
|
||||
}
|
||||
};
|
||||
|
||||
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) => (
|
||||
<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>
|
||||
</div>
|
||||
<div className="search-service-actions">
|
||||
<button className="send-btn" type="button" onClick={() => runServiceTest(service.id)}>测试已保存配置</button>
|
||||
{!['searxng', 'github', 'reader'].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>
|
||||
|
||||
Reference in New Issue
Block a user