feat: manage pluggable search services
This commit is contained in:
@@ -401,6 +401,15 @@ export function createAdminApp(services) {
|
|||||||
if (!mindSearchConfigService?.getRuntimeState) return res.status(503).json({ message: 'MindSearch 配置未启用' });
|
if (!mindSearchConfigService?.getRuntimeState) return res.status(503).json({ message: 'MindSearch 配置未启用' });
|
||||||
res.json(await mindSearchConfigService.getRuntimeState());
|
res.json(await mindSearchConfigService.getRuntimeState());
|
||||||
});
|
});
|
||||||
|
adminApi.post('/mindsearch/services/:serviceId/test', requireAdmin, async (req, res) => {
|
||||||
|
if (!mindSearchConfigService?.testService) return res.status(503).json({ message: 'MindSearch 服务测试未启用' });
|
||||||
|
try {
|
||||||
|
return res.json(await mindSearchConfigService.testService(req.params.serviceId));
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.code === 'SEARCH_SERVICE_NOT_FOUND') return res.status(404).json({ message: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
adminApi.get('/memory-v2/status', requireAdmin, async (_req, res) => {
|
adminApi.get('/memory-v2/status', requireAdmin, async (_req, res) => {
|
||||||
const portalBaseUrl = `http://127.0.0.1:${process.env.H5_PORT ?? 8081}`;
|
const portalBaseUrl = `http://127.0.0.1:${process.env.H5_PORT ?? 8081}`;
|
||||||
|
|||||||
@@ -1,14 +1,40 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { getMindSearchConfig, updateMindSearchConfig } from '../../api/client';
|
import { getMindSearchConfig, testMindSearchService, updateMindSearchConfig } from '../../api/client';
|
||||||
import type { MindSearchConfig } from '../../types';
|
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 = {
|
const initial: MindSearchConfig = {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
mode: 'off',
|
mode: 'off',
|
||||||
providers: { searxng: false, github: false, reader: false },
|
providers: { searxng: false, github: false, reader: false },
|
||||||
settings: { searxngEndpoint: '', maxResults: 10, timeoutMs: 8000, readerMaxChars: 12000 },
|
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() {
|
export function MindSearchPage() {
|
||||||
const [config, setConfig] = useState(initial);
|
const [config, setConfig] = useState(initial);
|
||||||
const [meta, setMeta] = useState<{ source?: string; updatedAt?: number | null; updatedBy?: string | null }>({});
|
const [meta, setMeta] = useState<{ source?: string; updatedAt?: number | null; updatedBy?: string | null }>({});
|
||||||
@@ -17,7 +43,7 @@ export function MindSearchPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getMindSearchConfig()
|
getMindSearchConfig()
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
setConfig({ ...initial, ...result.config, settings: { ...initial.settings, ...result.config.settings } });
|
setConfig(mergeConfig(result.config));
|
||||||
setMeta(result);
|
setMeta(result);
|
||||||
setMessage('');
|
setMessage('');
|
||||||
})
|
})
|
||||||
@@ -28,7 +54,7 @@ export function MindSearchPage() {
|
|||||||
setMessage('保存中…');
|
setMessage('保存中…');
|
||||||
try {
|
try {
|
||||||
const result = await updateMindSearchConfig(config);
|
const result = await updateMindSearchConfig(config);
|
||||||
setConfig({ ...initial, ...result.config, settings: { ...initial.settings, ...result.config.settings } });
|
setConfig(mergeConfig(result.config));
|
||||||
setMeta(result);
|
setMeta(result);
|
||||||
setMessage('已保存到数据库;新会话生效,旧会话不受影响');
|
setMessage('已保存到数据库;新会话生效,旧会话不受影响');
|
||||||
} catch (error) {
|
} 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) =>
|
const updateSetting = (key: keyof MindSearchConfig['settings'], value: string | number) =>
|
||||||
setConfig((current) => ({ ...current, settings: { ...current.settings, [key]: value } }));
|
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 (
|
return (
|
||||||
<section className="admin-page">
|
<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">
|
<div className="admin-card">
|
||||||
<h2>总开关与模式</h2>
|
<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>
|
<div className="admin-form">
|
||||||
<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>
|
<label className="search-service-toggle">
|
||||||
<h2>Provider</h2>
|
<input
|
||||||
<fieldset><legend>可用 Provider</legend>
|
type="checkbox"
|
||||||
<label><input type="checkbox" checked={config.providers.searxng} onChange={() => toggleProvider('searxng')} /> SearXNG Web/News</label>
|
checked={config.enabled}
|
||||||
<label><input type="checkbox" checked={config.providers.github} onChange={() => toggleProvider('github')} /> GitHub Code(Token 通过环境变量配置,不在页面回显)</label>
|
onChange={(event) => setConfig({
|
||||||
<label><input type="checkbox" checked={config.providers.reader} onChange={() => toggleProvider('reader')} /> Reader(阻止 localhost/内网地址)</label>
|
...config,
|
||||||
</fieldset>
|
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>
|
<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>
|
<div className="admin-form">
|
||||||
<label>最大结果数 <input type="number" min={1} max={20} value={config.settings.maxResults} onChange={(event) => updateSetting('maxResults', Number(event.target.value))} /></label>
|
<label><span>最大结果数</span><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><span>默认 Provider 超时(毫秒)</span><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>
|
<label><span>Reader 最大字符数</span><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>
|
||||||
|
<button className="send-btn" type="button" onClick={save}>保存配置</button>
|
||||||
{message && <p role="status">{message}</p>}
|
{message && <p role="status">{message}</p>}
|
||||||
<p>配置来源:{meta.source === 'admin' ? '数据库' : '环境变量默认值'};最后更新:{meta.updatedAt ? new Date(meta.updatedAt).toLocaleString() : '尚未保存'}{meta.updatedBy ? `;操作人:${meta.updatedBy}` : ''}</p>
|
<p>配置来源:{meta.source === 'admin' ? '数据库' : '环境变量默认值'};最后更新:{meta.updatedAt ? new Date(meta.updatedAt).toLocaleString() : '尚未保存'}{meta.updatedBy ? `;操作人:${meta.updatedBy}` : ''}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ import type {
|
|||||||
WechatMessage,
|
WechatMessage,
|
||||||
WechatWebNotification,
|
WechatWebNotification,
|
||||||
MindSearchConfig,
|
MindSearchConfig,
|
||||||
|
MindSearchServiceTestResult,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
@@ -669,6 +670,10 @@ export async function updateMindSearchConfig(config: Partial<MindSearchConfig>):
|
|||||||
return portalFetch('/admin-api/mindsearch/config', { method: 'PATCH', body: JSON.stringify(config) });
|
return portalFetch('/admin-api/mindsearch/config', { method: 'PATCH', body: JSON.stringify(config) });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function testMindSearchService(serviceId: string): Promise<MindSearchServiceTestResult> {
|
||||||
|
return portalFetch(`/admin-api/mindsearch/services/${encodeURIComponent(serviceId)}/test`, { method: 'POST' });
|
||||||
|
}
|
||||||
|
|
||||||
// ── Policies ──────────────────────────────────────────
|
// ── Policies ──────────────────────────────────────────
|
||||||
|
|
||||||
export async function listPolicyCatalog(): Promise<PolicyDefinition[]> {
|
export async function listPolicyCatalog(): Promise<PolicyDefinition[]> {
|
||||||
|
|||||||
@@ -577,6 +577,44 @@ body,
|
|||||||
justify-self: stretch;
|
justify-self: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.search-service-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(min(420px, 100%), 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
margin: 14px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-service-card {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 14px;
|
||||||
|
border: 1px solid var(--color-border-input);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-service-card legend {
|
||||||
|
padding: 0 6px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-service-toggle {
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
color: var(--color-text-primary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-service-toggle input {
|
||||||
|
width: auto;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-service-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.form-span-all {
|
.form-span-all {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -625,4 +625,37 @@ export type MindSearchConfig = {
|
|||||||
mode: 'off' | 'shadow' | 'assist';
|
mode: 'off' | 'shadow' | 'assist';
|
||||||
providers: { searxng: boolean; github: boolean; reader: boolean };
|
providers: { searxng: boolean; github: boolean; reader: boolean };
|
||||||
settings: { searxngEndpoint: string; maxResults: number; timeoutMs: number; readerMaxChars: number };
|
settings: { searxngEndpoint: string; maxResults: number; timeoutMs: number; readerMaxChars: number };
|
||||||
|
services: MindSearchService[];
|
||||||
|
routes: MindSearchRoutes;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MindSearchService = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
kind: 'provider' | 'reader' | 'orchestrator';
|
||||||
|
adapter: 'searxng' | 'github' | 'reader' | 'research-http';
|
||||||
|
capabilities: string[];
|
||||||
|
endpoint: string;
|
||||||
|
healthPath: string;
|
||||||
|
enabled: boolean;
|
||||||
|
timeoutMs: number;
|
||||||
|
priority: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MindSearchRoutes = {
|
||||||
|
web: string;
|
||||||
|
news: string;
|
||||||
|
code: string;
|
||||||
|
read: string;
|
||||||
|
research: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MindSearchServiceTestResult = {
|
||||||
|
ok: boolean;
|
||||||
|
serviceId: string;
|
||||||
|
adapter: MindSearchService['adapter'];
|
||||||
|
status: number | null;
|
||||||
|
latencyMs: number;
|
||||||
|
resultCount?: number | null;
|
||||||
|
message: string;
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user