feat: add pluggable search service registry
This commit is contained in:
@@ -207,6 +207,15 @@ export function createAdminApi({
|
||||
if (!mindSearchConfigService?.getRuntimeState) return res.status(503).json({ message: 'MindSearch 配置服务未启用' });
|
||||
return 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/runtime', requireAdmin, async (_req, res) => {
|
||||
if (!memoryV2ConfigService?.getRuntimeState) {
|
||||
|
||||
@@ -165,6 +165,7 @@ test('admin system disclosure policy routes version config through the injected
|
||||
|
||||
test('admin MindSearch routes persist only through injected control-plane service', async () => {
|
||||
const updates = [];
|
||||
const testedServices = [];
|
||||
const router = createAdminApi({
|
||||
jsonBody: express.json(), getToken() { return 'token-admin'; },
|
||||
userAuth: { async getMe() { return { id: 'admin-1', role: 'admin' }; } },
|
||||
@@ -173,6 +174,7 @@ test('admin MindSearch routes persist only through injected control-plane servic
|
||||
async getAdminConfig() { return { config: { enabled: false, mode: 'off', providers: { searxng: false, github: false, reader: false } }, source: 'env' }; },
|
||||
async updateAdminConfig(patch, context) { updates.push({ patch, context }); return { config: { enabled: true, mode: 'shadow', providers: { searxng: true, github: false, reader: false } }, source: 'admin' }; },
|
||||
async getRuntimeState() { return { effective: false, mode: 'off' }; },
|
||||
async testService(serviceId) { testedServices.push(serviceId); return { ok: true, serviceId, latencyMs: 3 }; },
|
||||
},
|
||||
plazaPosts: null, plazaOps: null, wechatAdmin: null, subscriptionService: null,
|
||||
});
|
||||
@@ -185,6 +187,9 @@ test('admin MindSearch routes persist only through injected control-plane servic
|
||||
assert.deepEqual(updates, [{ patch: { enabled: true, mode: 'shadow' }, context: { updatedBy: 'admin-1' } }]);
|
||||
const runtime = await fetch(`${server.baseUrl}/admin-api/mindsearch/runtime`, { headers: { cookie: 'h5_user_session=token-admin' } });
|
||||
assert.deepEqual(await runtime.json(), { effective: false, mode: 'off' });
|
||||
const serviceTest = await fetch(`${server.baseUrl}/admin-api/mindsearch/services/searxng/test`, { method: 'POST', headers: { cookie: 'h5_user_session=token-admin' } });
|
||||
assert.deepEqual(await serviceTest.json(), { ok: true, serviceId: 'searxng', latencyMs: 3 });
|
||||
assert.deepEqual(testedServices, ['searxng']);
|
||||
} finally { await server.close(); }
|
||||
});
|
||||
|
||||
|
||||
+25
-1
@@ -496,7 +496,31 @@ export function buildAgentExtensionPolicy(
|
||||
}
|
||||
const searchEnabled = capabilities.search_external && mindSearchConfig?.enabled && mindSearchConfig.mode !== 'off';
|
||||
if (searchEnabled && (!policies || policies.network_egress !== 'deny')) {
|
||||
extensions.push({ type: 'stdio', name: 'tkmind-search', description: 'MindSearch 外部搜索增强(补充能力)', display_name: 'tkmind-search', bundled: false, cmd: resolveSandboxMcpNodeExecPath(process.env.GOOSED_MCP_NODE_PATH), args: [resolveMindSearchMcpServerPath(process.env.TKMIND_SEARCH_MCP_SERVER_PATH)], envs: { TKMIND_SEARCH_ENABLED: '1', TKMIND_SEARCH_MODE: mindSearchConfig.mode, TKMIND_SEARCH_PROVIDER_SEARXNG_ENABLED: mindSearchConfig.providers?.searxng ? '1' : '0', TKMIND_SEARCH_PROVIDER_GITHUB_ENABLED: mindSearchConfig.providers?.github ? '1' : '0', TKMIND_SEARCH_READER_ENABLED: mindSearchConfig.providers?.reader ? '1' : '0', TKMIND_SEARCH_SEARXNG_URL: mindSearchConfig.settings?.searxngEndpoint ?? '', TKMIND_SEARCH_MAX_RESULTS: String(mindSearchConfig.settings?.maxResults ?? 10), TKMIND_SEARCH_TIMEOUT_MS: String(mindSearchConfig.settings?.timeoutMs ?? 8000), TKMIND_SEARCH_READER_MAX_CHARS: String(mindSearchConfig.settings?.readerMaxChars ?? 12000) }, available_tools: ['tkmind_search', 'tkmind_read'] });
|
||||
const hasResearchService = mindSearchConfig.services?.some((service) =>
|
||||
service.enabled && service.id === mindSearchConfig.routes?.research && service.adapter === 'research-http');
|
||||
extensions.push({
|
||||
type: 'stdio',
|
||||
name: 'tkmind-search',
|
||||
description: 'MindSearch 外部搜索增强(补充能力)',
|
||||
display_name: 'tkmind-search',
|
||||
bundled: false,
|
||||
cmd: resolveSandboxMcpNodeExecPath(process.env.GOOSED_MCP_NODE_PATH),
|
||||
args: [resolveMindSearchMcpServerPath(process.env.TKMIND_SEARCH_MCP_SERVER_PATH)],
|
||||
envs: {
|
||||
TKMIND_SEARCH_ENABLED: '1',
|
||||
TKMIND_SEARCH_MODE: mindSearchConfig.mode,
|
||||
TKMIND_SEARCH_PROVIDER_SEARXNG_ENABLED: mindSearchConfig.providers?.searxng ? '1' : '0',
|
||||
TKMIND_SEARCH_PROVIDER_GITHUB_ENABLED: mindSearchConfig.providers?.github ? '1' : '0',
|
||||
TKMIND_SEARCH_READER_ENABLED: mindSearchConfig.providers?.reader ? '1' : '0',
|
||||
TKMIND_SEARCH_SEARXNG_URL: mindSearchConfig.settings?.searxngEndpoint ?? '',
|
||||
TKMIND_SEARCH_MAX_RESULTS: String(mindSearchConfig.settings?.maxResults ?? 10),
|
||||
TKMIND_SEARCH_TIMEOUT_MS: String(mindSearchConfig.settings?.timeoutMs ?? 8000),
|
||||
TKMIND_SEARCH_READER_MAX_CHARS: String(mindSearchConfig.settings?.readerMaxChars ?? 12000),
|
||||
TKMIND_SEARCH_SERVICES_JSON: JSON.stringify(mindSearchConfig.services ?? []),
|
||||
TKMIND_SEARCH_ROUTES_JSON: JSON.stringify(mindSearchConfig.routes ?? {}),
|
||||
},
|
||||
available_tools: ['tkmind_search', 'tkmind_read', ...(hasResearchService ? ['tkmind_research'] : [])],
|
||||
});
|
||||
}
|
||||
if (capabilities.excel_analysis) {
|
||||
const excelWorkspaceRoot = resolveSandboxMcpLocalRoot(sandboxMcp);
|
||||
|
||||
+288
-10
@@ -1,11 +1,56 @@
|
||||
const CONFIG_TABLE = 'h5_mindsearch_config';
|
||||
const CONFIG_SCOPE = 'global';
|
||||
|
||||
const SERVICE_KINDS = new Set(['provider', 'reader', 'orchestrator']);
|
||||
const SERVICE_ADAPTERS = new Set(['searxng', 'github', 'reader', 'research-http']);
|
||||
const ROUTE_KEYS = ['web', 'news', 'code', 'read', 'research'];
|
||||
|
||||
const BUILTIN_SERVICES = Object.freeze([
|
||||
{
|
||||
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,
|
||||
},
|
||||
]);
|
||||
|
||||
export const MINDSEARCH_DEFAULT_CONFIG = Object.freeze({
|
||||
enabled: false,
|
||||
mode: 'off',
|
||||
providers: { searxng: false, github: false, reader: false },
|
||||
settings: { searxngEndpoint: '', maxResults: 10, timeoutMs: 8000, readerMaxChars: 12000 },
|
||||
services: BUILTIN_SERVICES,
|
||||
routes: { web: 'searxng', news: 'searxng', code: 'github', read: 'reader', research: '' },
|
||||
});
|
||||
|
||||
const clone = (value) => JSON.parse(JSON.stringify(value));
|
||||
@@ -15,22 +60,182 @@ const bool = (value, fallback = false) => {
|
||||
return /^(1|true|yes|on)$/i.test(String(value));
|
||||
};
|
||||
|
||||
const boundedNumber = (value, fallback, min, max) =>
|
||||
Math.max(min, Math.min(max, Number(value ?? fallback) || fallback));
|
||||
|
||||
export function isValidSearchServiceEndpoint(value, { allowEmpty = true } = {}) {
|
||||
const endpoint = String(value ?? '').trim();
|
||||
if (!endpoint) return allowEmpty;
|
||||
try {
|
||||
const url = new URL(endpoint);
|
||||
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) return false;
|
||||
return !['0.0.0.0', '::', '[::]', '169.254.169.254'].includes(url.hostname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeCapabilities(value, fallback = []) {
|
||||
const capabilities = Array.isArray(value) ? value : fallback;
|
||||
return [...new Set(capabilities
|
||||
.map((item) => String(item ?? '').trim())
|
||||
.filter((item) => /^[a-z][a-z0-9_.-]{1,63}$/.test(item)))]
|
||||
.slice(0, 20);
|
||||
}
|
||||
|
||||
function normalizeService(input = {}, fallback = {}) {
|
||||
const id = String(input.id ?? fallback.id ?? '').trim().toLowerCase();
|
||||
if (!/^[a-z0-9][a-z0-9._-]{0,63}$/.test(id)) return null;
|
||||
const kind = SERVICE_KINDS.has(input.kind) ? input.kind : (fallback.kind ?? 'provider');
|
||||
const adapter = SERVICE_ADAPTERS.has(input.adapter) ? input.adapter : (fallback.adapter ?? 'searxng');
|
||||
const endpoint = String(input.endpoint ?? fallback.endpoint ?? '').trim();
|
||||
if (!isValidSearchServiceEndpoint(endpoint)) return null;
|
||||
const healthPathValue = String(input.healthPath ?? fallback.healthPath ?? '').trim();
|
||||
const healthPath = healthPathValue && /^\/[^\s]{0,255}$/.test(healthPathValue) ? healthPathValue : '';
|
||||
return {
|
||||
id,
|
||||
name: String(input.name ?? fallback.name ?? id).trim().slice(0, 80) || id,
|
||||
kind,
|
||||
adapter,
|
||||
capabilities: normalizeCapabilities(input.capabilities, fallback.capabilities),
|
||||
endpoint,
|
||||
healthPath,
|
||||
enabled: bool(input.enabled, bool(fallback.enabled)),
|
||||
timeoutMs: boundedNumber(input.timeoutMs, fallback.timeoutMs ?? 8000, 1000, 120000),
|
||||
priority: boundedNumber(input.priority, fallback.priority ?? 100, 1, 1000),
|
||||
};
|
||||
}
|
||||
|
||||
function builtinsFromLegacy(input = {}) {
|
||||
const providers = input.providers ?? {};
|
||||
const settings = input.settings ?? {};
|
||||
return BUILTIN_SERVICES.map((service) => normalizeService({
|
||||
...service,
|
||||
enabled: bool(providers[service.adapter], service.enabled),
|
||||
endpoint: service.adapter === 'searxng'
|
||||
? String(settings.searxngEndpoint ?? service.endpoint)
|
||||
: service.endpoint,
|
||||
timeoutMs: settings.timeoutMs ?? service.timeoutMs,
|
||||
}, service));
|
||||
}
|
||||
|
||||
function normalizeServices(input = {}) {
|
||||
const source = Array.isArray(input.services) ? input.services : builtinsFromLegacy(input);
|
||||
const seen = new Set();
|
||||
const services = [];
|
||||
for (const item of source) {
|
||||
const fallback = BUILTIN_SERVICES.find((candidate) => candidate.id === item?.id) ?? {};
|
||||
const service = normalizeService(item, fallback);
|
||||
if (!service || seen.has(service.id)) continue;
|
||||
seen.add(service.id);
|
||||
services.push(service);
|
||||
}
|
||||
for (const builtin of builtinsFromLegacy(input)) {
|
||||
if (!seen.has(builtin.id)) services.push(builtin);
|
||||
}
|
||||
return services.slice(0, 30);
|
||||
}
|
||||
|
||||
function normalizeRoutes(value, services) {
|
||||
const ids = new Set(services.map((service) => service.id));
|
||||
const defaults = MINDSEARCH_DEFAULT_CONFIG.routes;
|
||||
return Object.fromEntries(ROUTE_KEYS.map((key) => {
|
||||
const requested = String(value?.[key] ?? defaults[key] ?? '').trim();
|
||||
return [key, !requested || ids.has(requested) ? requested : ''];
|
||||
}));
|
||||
}
|
||||
|
||||
export function resolveMindSearchService(config, route) {
|
||||
const normalized = normalizeMindSearchConfig(config);
|
||||
const serviceId = normalized.routes?.[route] ?? '';
|
||||
return normalized.services.find((service) => service.id === serviceId && service.enabled) ?? null;
|
||||
}
|
||||
|
||||
export async function probeMindSearchService(service, { fetchImpl = fetch } = {}) {
|
||||
const normalized = normalizeService(service);
|
||||
if (!normalized) throw new Error('Invalid search service configuration');
|
||||
if (!normalized.endpoint) {
|
||||
return {
|
||||
ok: normalized.adapter === 'reader',
|
||||
serviceId: normalized.id,
|
||||
adapter: normalized.adapter,
|
||||
status: null,
|
||||
latencyMs: 0,
|
||||
message: normalized.adapter === 'reader' ? 'Built-in reader is available' : 'Service endpoint is not configured',
|
||||
};
|
||||
}
|
||||
const startedAt = Date.now();
|
||||
let url;
|
||||
if (normalized.adapter === 'searxng') {
|
||||
url = new URL(normalized.endpoint);
|
||||
url.searchParams.set('q', 'OpenAI');
|
||||
url.searchParams.set('format', 'json');
|
||||
} else if (normalized.healthPath) {
|
||||
url = new URL(normalized.healthPath, normalized.endpoint);
|
||||
} else {
|
||||
return {
|
||||
ok: true,
|
||||
serviceId: normalized.id,
|
||||
adapter: normalized.adapter,
|
||||
status: null,
|
||||
latencyMs: 0,
|
||||
message: 'Configuration is valid; no health path is configured',
|
||||
};
|
||||
}
|
||||
try {
|
||||
const response = await fetchImpl(url, {
|
||||
signal: AbortSignal.timeout(normalized.timeoutMs),
|
||||
headers: { accept: 'application/json,text/plain' },
|
||||
});
|
||||
let resultCount = null;
|
||||
if (normalized.adapter === 'searxng' && response.ok) {
|
||||
const body = await response.json();
|
||||
resultCount = Array.isArray(body.results) ? body.results.length : 0;
|
||||
}
|
||||
return {
|
||||
ok: response.ok && (resultCount == null || resultCount > 0),
|
||||
serviceId: normalized.id,
|
||||
adapter: normalized.adapter,
|
||||
status: response.status,
|
||||
latencyMs: Date.now() - startedAt,
|
||||
resultCount,
|
||||
message: response.ok ? 'Health check completed' : `Service returned HTTP ${response.status}`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
serviceId: normalized.id,
|
||||
adapter: normalized.adapter,
|
||||
status: null,
|
||||
latencyMs: Date.now() - startedAt,
|
||||
message: error instanceof Error ? error.message : 'Service health check failed',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeMindSearchConfig(input = {}) {
|
||||
const mode = ['off', 'shadow', 'assist'].includes(input.mode) ? input.mode : 'off';
|
||||
const services = normalizeServices(input);
|
||||
const serviceByAdapter = (adapter) => services.find((service) => service.adapter === adapter);
|
||||
const searxng = serviceByAdapter('searxng');
|
||||
const github = serviceByAdapter('github');
|
||||
const reader = serviceByAdapter('reader');
|
||||
const config = {
|
||||
enabled: bool(input.enabled),
|
||||
mode,
|
||||
providers: {
|
||||
searxng: bool(input.providers?.searxng),
|
||||
github: bool(input.providers?.github),
|
||||
reader: bool(input.providers?.reader),
|
||||
searxng: bool(searxng?.enabled),
|
||||
github: bool(github?.enabled),
|
||||
reader: bool(reader?.enabled),
|
||||
},
|
||||
settings: {
|
||||
searxngEndpoint: String(input.settings?.searxngEndpoint ?? '').trim(),
|
||||
maxResults: Math.max(1, Math.min(20, Number(input.settings?.maxResults ?? 10) || 10)),
|
||||
timeoutMs: Math.max(1000, Math.min(30000, Number(input.settings?.timeoutMs ?? 8000) || 8000)),
|
||||
readerMaxChars: Math.max(1000, Math.min(50000, Number(input.settings?.readerMaxChars ?? 12000) || 12000)),
|
||||
searxngEndpoint: String(searxng?.endpoint ?? input.settings?.searxngEndpoint ?? '').trim(),
|
||||
maxResults: boundedNumber(input.settings?.maxResults, 10, 1, 20),
|
||||
timeoutMs: boundedNumber(input.settings?.timeoutMs, 8000, 1000, 30000),
|
||||
readerMaxChars: boundedNumber(input.settings?.readerMaxChars, 12000, 1000, 50000),
|
||||
},
|
||||
services,
|
||||
routes: normalizeRoutes(input.routes, services),
|
||||
};
|
||||
if (!config.enabled || config.mode === 'off') {
|
||||
config.enabled = false;
|
||||
@@ -49,6 +254,57 @@ function envConfig(env = process.env) {
|
||||
github: env.TKMIND_SEARCH_PROVIDER_GITHUB_ENABLED,
|
||||
reader: env.TKMIND_SEARCH_READER_ENABLED,
|
||||
},
|
||||
settings: {
|
||||
searxngEndpoint: env.TKMIND_SEARCH_SEARXNG_URL,
|
||||
maxResults: env.TKMIND_SEARCH_MAX_RESULTS,
|
||||
timeoutMs: env.TKMIND_SEARCH_TIMEOUT_MS,
|
||||
readerMaxChars: env.TKMIND_SEARCH_READER_MAX_CHARS,
|
||||
},
|
||||
services: parseJsonArray(env.TKMIND_SEARCH_SERVICES_JSON),
|
||||
routes: parseJsonObject(env.TKMIND_SEARCH_ROUTES_JSON),
|
||||
});
|
||||
}
|
||||
|
||||
function parseJsonArray(value) {
|
||||
if (!value) return undefined;
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return Array.isArray(parsed) ? parsed : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function parseJsonObject(value) {
|
||||
if (!value) return undefined;
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function applyLegacyPatchToServices(current, patch) {
|
||||
if (Array.isArray(patch?.services)) return patch.services;
|
||||
const providerPatch = patch?.providers ?? {};
|
||||
const settingPatch = patch?.settings ?? {};
|
||||
return current.services.map((service) => {
|
||||
if (service.adapter === 'searxng') {
|
||||
return {
|
||||
...service,
|
||||
...(providerPatch.searxng == null ? {} : { enabled: providerPatch.searxng }),
|
||||
...(settingPatch.searxngEndpoint == null ? {} : { endpoint: settingPatch.searxngEndpoint }),
|
||||
...(settingPatch.timeoutMs == null ? {} : { timeoutMs: settingPatch.timeoutMs }),
|
||||
};
|
||||
}
|
||||
if (service.adapter === 'github' && providerPatch.github != null) {
|
||||
return { ...service, enabled: providerPatch.github };
|
||||
}
|
||||
if (service.adapter === 'reader' && providerPatch.reader != null) {
|
||||
return { ...service, enabled: providerPatch.reader };
|
||||
}
|
||||
return service;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -61,7 +317,7 @@ export async function ensureMindSearchConfigSchema(pool) {
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`);
|
||||
}
|
||||
|
||||
export function createMindSearchConfigService(pool, { env = process.env } = {}) {
|
||||
export function createMindSearchConfigService(pool, { env = process.env, fetchImpl = fetch } = {}) {
|
||||
let ready = null;
|
||||
const ensure = async () => {
|
||||
if (!pool) return;
|
||||
@@ -82,9 +338,22 @@ export function createMindSearchConfigService(pool, { env = process.env } = {})
|
||||
ensureSchema: ensure,
|
||||
getEffectiveConfig: async () => (await load()).config,
|
||||
getAdminConfig: async () => load(),
|
||||
testService: async (serviceId) => {
|
||||
const current = await load();
|
||||
const service = current.config.services.find((item) => item.id === String(serviceId ?? '').trim());
|
||||
if (!service) throw Object.assign(new Error('Search service not found'), { code: 'SEARCH_SERVICE_NOT_FOUND' });
|
||||
return probeMindSearchService(service, { fetchImpl });
|
||||
},
|
||||
updateAdminConfig: async (patch, { updatedBy = null } = {}) => {
|
||||
const current = await load();
|
||||
const next = normalizeMindSearchConfig({ ...current.config, ...patch, providers: { ...current.config.providers, ...(patch?.providers ?? {}) }, settings: { ...current.config.settings, ...(patch?.settings ?? {}) } });
|
||||
const next = normalizeMindSearchConfig({
|
||||
...current.config,
|
||||
...patch,
|
||||
providers: { ...current.config.providers, ...(patch?.providers ?? {}) },
|
||||
settings: { ...current.config.settings, ...(patch?.settings ?? {}) },
|
||||
services: applyLegacyPatchToServices(current.config, patch),
|
||||
routes: { ...current.config.routes, ...(patch?.routes ?? {}) },
|
||||
});
|
||||
if (!pool) return { config: next, source: 'env' };
|
||||
await ensure();
|
||||
const now = Date.now();
|
||||
@@ -93,7 +362,16 @@ export function createMindSearchConfigService(pool, { env = process.env } = {})
|
||||
},
|
||||
getRuntimeState: async () => {
|
||||
const config = await (async () => (await load()).config)();
|
||||
return { enabled: config.enabled, mode: config.mode, providers: config.providers, effective: config.enabled && config.mode !== 'off' };
|
||||
return {
|
||||
enabled: config.enabled,
|
||||
mode: config.mode,
|
||||
providers: config.providers,
|
||||
services: config.services.map(({ id, name, kind, adapter, capabilities, enabled, timeoutMs, priority }) => ({
|
||||
id, name, kind, adapter, capabilities, enabled, timeoutMs, priority,
|
||||
})),
|
||||
routes: config.routes,
|
||||
effective: config.enabled && config.mode !== 'off',
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { isSafeHttpUrl, normalizeSearchResult } from './search-capability.mjs';
|
||||
import { isValidSearchServiceEndpoint } from './mindsearch-config.mjs';
|
||||
|
||||
export async function searchSearxng(query, { limit = 10, endpoint = process.env.TKMIND_SEARCH_SEARXNG_URL, fetchImpl = fetch } = {}) {
|
||||
if (!endpoint) throw new Error('SearXNG endpoint is not configured');
|
||||
@@ -29,3 +30,48 @@ export async function searchGithubCode(query, { limit = 10, token = process.env.
|
||||
const body = await response.json();
|
||||
return (Array.isArray(body.items) ? body.items : []).slice(0, limit).map((item, index) => normalizeSearchResult({ title: `${item.repository?.full_name ?? ''}:${item.path ?? item.name ?? ''}`, url: item.html_url, snippet: item.repository?.description ?? '', source: 'github' }, index));
|
||||
}
|
||||
|
||||
function serviceUrl(endpoint, path) {
|
||||
if (!isValidSearchServiceEndpoint(endpoint, { allowEmpty: false })) throw new Error('Search service endpoint is not configured safely');
|
||||
const base = endpoint.endsWith('/') ? endpoint : `${endpoint}/`;
|
||||
return new URL(path.replace(/^\//, ''), base);
|
||||
}
|
||||
|
||||
async function postServiceJson(endpoint, path, payload, { timeoutMs = 30000, fetchImpl = fetch } = {}) {
|
||||
const response = await fetchImpl(serviceUrl(endpoint, path), {
|
||||
method: 'POST',
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
headers: { accept: 'application/json', 'content-type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!response.ok) throw new Error(`Search service returned ${response.status}`);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function searchResearchService(query, {
|
||||
endpoint,
|
||||
type = 'web',
|
||||
limit = 10,
|
||||
timeoutMs = 30000,
|
||||
fetchImpl = fetch,
|
||||
} = {}) {
|
||||
const body = await postServiceJson(endpoint, '/v1/search', { query, type, limit }, { timeoutMs, fetchImpl });
|
||||
return (Array.isArray(body.results) ? body.results : [])
|
||||
.slice(0, limit)
|
||||
.map((item, index) => normalizeSearchResult({ ...item, source: item.source || 'research-service' }, index));
|
||||
}
|
||||
|
||||
export async function startResearchTask(question, {
|
||||
endpoint,
|
||||
depth = 'standard',
|
||||
timeoutMs = 30000,
|
||||
fetchImpl = fetch,
|
||||
} = {}) {
|
||||
const body = await postServiceJson(endpoint, '/v1/research', { question, depth }, { timeoutMs, fetchImpl });
|
||||
if (!body || typeof body !== 'object' || !body.task_id) throw new Error('Research service returned an invalid task');
|
||||
return {
|
||||
taskId: String(body.task_id),
|
||||
status: String(body.status ?? 'running'),
|
||||
service: String(body.service ?? 'research-service'),
|
||||
};
|
||||
}
|
||||
|
||||
+81
-3
@@ -1,13 +1,74 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { buildCitations, isSafeHttpUrl, validateSearchRequest } from './search-capability.mjs';
|
||||
import { MINDSEARCH_DEFAULT_CONFIG, normalizeMindSearchConfig } from './mindsearch-config.mjs';
|
||||
import {
|
||||
MINDSEARCH_DEFAULT_CONFIG,
|
||||
normalizeMindSearchConfig,
|
||||
probeMindSearchService,
|
||||
resolveMindSearchService,
|
||||
} from './mindsearch-config.mjs';
|
||||
import { buildAgentExtensionPolicy, DEFAULT_USER_CAPABILITIES } from './capabilities.mjs';
|
||||
import { searchGithubCode, searchSearxng } from './mindsearch-providers.mjs';
|
||||
import {
|
||||
searchGithubCode,
|
||||
searchResearchService,
|
||||
searchSearxng,
|
||||
startResearchTask,
|
||||
} from './mindsearch-providers.mjs';
|
||||
|
||||
test('MindSearch defaults to disabled and normalizes unsafe modes', () => {
|
||||
assert.equal(MINDSEARCH_DEFAULT_CONFIG.enabled, false);
|
||||
assert.deepEqual(normalizeMindSearchConfig({ enabled: true, mode: 'invalid', providers: { github: true } }), { enabled: false, mode: 'off', providers: { searxng: false, github: true, reader: false }, settings: { searxngEndpoint: '', maxResults: 10, timeoutMs: 8000, readerMaxChars: 12000 } });
|
||||
const config = normalizeMindSearchConfig({ enabled: true, mode: 'invalid', providers: { github: true } });
|
||||
assert.equal(config.enabled, false);
|
||||
assert.equal(config.mode, 'off');
|
||||
assert.deepEqual(config.providers, { searxng: false, github: true, reader: false });
|
||||
assert.equal(config.services.find((service) => service.id === 'github').enabled, true);
|
||||
assert.deepEqual(config.routes, { web: 'searxng', news: 'searxng', code: 'github', read: 'reader', research: '' });
|
||||
});
|
||||
|
||||
test('MindSearch service registry accepts research orchestrators and rejects unsafe endpoints', () => {
|
||||
const config = normalizeMindSearchConfig({
|
||||
enabled: true,
|
||||
mode: 'assist',
|
||||
services: [
|
||||
{
|
||||
id: 'research-main',
|
||||
name: 'Research',
|
||||
kind: 'orchestrator',
|
||||
adapter: 'research-http',
|
||||
endpoint: 'http://127.0.0.1:20100',
|
||||
healthPath: '/health',
|
||||
capabilities: ['search.web', 'research.execute', 'research.execute', 'INVALID CAPABILITY'],
|
||||
enabled: true,
|
||||
timeoutMs: 500000,
|
||||
},
|
||||
{ id: 'metadata', adapter: 'research-http', endpoint: 'http://169.254.169.254/latest', enabled: true },
|
||||
],
|
||||
routes: { research: 'research-main', web: 'research-main' },
|
||||
});
|
||||
assert.equal(config.services.some((service) => service.id === 'metadata'), false);
|
||||
assert.deepEqual(config.services.find((service) => service.id === 'research-main').capabilities, ['search.web', 'research.execute']);
|
||||
assert.equal(config.services.find((service) => service.id === 'research-main').timeoutMs, 120000);
|
||||
assert.equal(resolveMindSearchService(config, 'research').id, 'research-main');
|
||||
});
|
||||
|
||||
test('MindSearch service probe supports loopback SearXNG without exposing arbitrary response data', async () => {
|
||||
let requested;
|
||||
const result = await probeMindSearchService({
|
||||
id: 'searxng-local',
|
||||
name: 'Local',
|
||||
kind: 'provider',
|
||||
adapter: 'searxng',
|
||||
endpoint: 'http://127.0.0.1:8080/search',
|
||||
enabled: true,
|
||||
}, {
|
||||
fetchImpl: async (url) => {
|
||||
requested = String(url);
|
||||
return { ok: true, status: 200, json: async () => ({ results: [{ title: 'A' }] }) };
|
||||
},
|
||||
});
|
||||
assert.match(requested, /q=OpenAI/);
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.resultCount, 1);
|
||||
});
|
||||
|
||||
test('search request validation and citations are deterministic', () => {
|
||||
@@ -42,3 +103,20 @@ test('GitHub code adapter sends bounded queries and normalizes results', async (
|
||||
assert.equal(requested.options.headers.accept, 'application/vnd.github+json');
|
||||
assert.equal(result[0].source, 'github');
|
||||
});
|
||||
|
||||
test('Research service adapter uses the stable HTTP contract', async () => {
|
||||
const requests = [];
|
||||
const fetchImpl = async (url, options) => {
|
||||
requests.push({ url: String(url), options });
|
||||
if (String(url).endsWith('/v1/research')) {
|
||||
return { ok: true, json: async () => ({ task_id: 'task-1', status: 'running' }) };
|
||||
}
|
||||
return { ok: true, json: async () => ({ results: [{ title: 'Report', url: 'https://example.com/report', snippet: 'evidence' }] }) };
|
||||
};
|
||||
const results = await searchResearchService('AI Agent', { endpoint: 'http://research.local', limit: 5, fetchImpl });
|
||||
const task = await startResearchTask('Analyze AI Agent', { endpoint: 'http://research.local', depth: 'deep', fetchImpl });
|
||||
assert.equal(results[0].source, 'research-service');
|
||||
assert.deepEqual(task, { taskId: 'task-1', status: 'running', service: 'research-service' });
|
||||
assert.equal(JSON.parse(requests[0].options.body).query, 'AI Agent');
|
||||
assert.deepEqual(JSON.parse(requests[1].options.body), { question: 'Analyze AI Agent', depth: 'deep' });
|
||||
});
|
||||
|
||||
+52
-7
@@ -1,7 +1,18 @@
|
||||
import readline from 'node:readline';
|
||||
import { normalizeMindSearchConfig } from './mindsearch-config.mjs';
|
||||
import { normalizeMindSearchConfig, resolveMindSearchService } from './mindsearch-config.mjs';
|
||||
import { SEARCH_ERROR_CODES, validateSearchRequest } from './search-capability.mjs';
|
||||
import { readSafeUrl, searchGithubCode, searchSearxng } from './mindsearch-providers.mjs';
|
||||
import {
|
||||
readSafeUrl,
|
||||
searchGithubCode,
|
||||
searchResearchService,
|
||||
searchSearxng,
|
||||
startResearchTask,
|
||||
} from './mindsearch-providers.mjs';
|
||||
|
||||
function parseJson(value, fallback) {
|
||||
if (!value) return fallback;
|
||||
try { return JSON.parse(value); } catch { return fallback; }
|
||||
}
|
||||
|
||||
const config = normalizeMindSearchConfig({
|
||||
enabled: process.env.TKMIND_SEARCH_ENABLED,
|
||||
@@ -11,12 +22,31 @@ const config = normalizeMindSearchConfig({
|
||||
github: process.env.TKMIND_SEARCH_PROVIDER_GITHUB_ENABLED,
|
||||
reader: process.env.TKMIND_SEARCH_READER_ENABLED,
|
||||
},
|
||||
settings: {
|
||||
searxngEndpoint: process.env.TKMIND_SEARCH_SEARXNG_URL,
|
||||
maxResults: process.env.TKMIND_SEARCH_MAX_RESULTS,
|
||||
timeoutMs: process.env.TKMIND_SEARCH_TIMEOUT_MS,
|
||||
readerMaxChars: process.env.TKMIND_SEARCH_READER_MAX_CHARS,
|
||||
},
|
||||
services: parseJson(process.env.TKMIND_SEARCH_SERVICES_JSON, undefined),
|
||||
routes: parseJson(process.env.TKMIND_SEARCH_ROUTES_JSON, undefined),
|
||||
});
|
||||
|
||||
const tools = [
|
||||
{ name: 'tkmind_search', description: 'Optional external search enhancement; existing web search remains primary.', inputSchema: { type: 'object', properties: { query: { type: 'string' }, type: { type: 'string' }, limit: { type: 'number' } }, required: ['query'] } },
|
||||
{ name: 'tkmind_read', description: 'Optional safe URL reader for search citations.', inputSchema: { type: 'object', properties: { url: { type: 'string' } }, required: ['url'] } },
|
||||
];
|
||||
if (resolveMindSearchService(config, 'research')) {
|
||||
tools.push({
|
||||
name: 'tkmind_research',
|
||||
description: 'Start an asynchronous deep-research task and return its task identifier.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: { question: { type: 'string' }, depth: { type: 'string', enum: ['quick', 'standard', 'deep'] } },
|
||||
required: ['question'],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function response(id, result) { process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', id, result })}\n`); }
|
||||
function error(id, code, message) { response(id, { isError: true, content: [{ type: 'text', text: `${code}: ${message}` }], structuredContent: { code, message } }); }
|
||||
@@ -31,15 +61,30 @@ function handle(message) {
|
||||
if (name === 'tkmind_search') {
|
||||
const checked = validateSearchRequest(params.arguments ?? {});
|
||||
if (!checked.ok) return error(id, checked.code, checked.message);
|
||||
if (checked.value.type === 'code' && config.providers.github) {
|
||||
return searchGithubCode(checked.value.query, { limit: checked.value.limit }).then((results) => response(id, { content: [{ type: 'text', text: JSON.stringify({ results, query: checked.value.query, provider: 'github' }) }], structuredContent: { results, query: checked.value.query, provider: 'github' } })).catch((err) => error(id, SEARCH_ERROR_CODES.PROVIDER_UNAVAILABLE, err.message));
|
||||
const route = checked.value.type === 'code' ? 'code' : checked.value.type === 'news' ? 'news' : 'web';
|
||||
const service = resolveMindSearchService(config, route);
|
||||
if (service?.adapter === 'github') {
|
||||
return searchGithubCode(checked.value.query, { limit: checked.value.limit, endpoint: service.endpoint || undefined }).then((results) => response(id, { content: [{ type: 'text', text: JSON.stringify({ results, query: checked.value.query, provider: service.id }) }], structuredContent: { results, query: checked.value.query, provider: service.id } })).catch((err) => error(id, SEARCH_ERROR_CODES.PROVIDER_UNAVAILABLE, err.message));
|
||||
}
|
||||
if (config.providers.searxng) {
|
||||
return searchSearxng(checked.value.query, { limit: checked.value.limit }).then((results) => response(id, { content: [{ type: 'text', text: JSON.stringify({ results, query: checked.value.query, provider: 'searxng' }) }], structuredContent: { results, query: checked.value.query, provider: 'searxng' } })).catch((err) => error(id, SEARCH_ERROR_CODES.PROVIDER_UNAVAILABLE, err.message));
|
||||
if (service?.adapter === 'searxng') {
|
||||
return searchSearxng(checked.value.query, { limit: checked.value.limit, endpoint: service.endpoint }).then((results) => response(id, { content: [{ type: 'text', text: JSON.stringify({ results, query: checked.value.query, provider: service.id }) }], structuredContent: { results, query: checked.value.query, provider: service.id } })).catch((err) => error(id, SEARCH_ERROR_CODES.PROVIDER_UNAVAILABLE, err.message));
|
||||
}
|
||||
if (service?.adapter === 'research-http') {
|
||||
return searchResearchService(checked.value.query, { endpoint: service.endpoint, type: checked.value.type, limit: checked.value.limit, timeoutMs: service.timeoutMs }).then((results) => response(id, { content: [{ type: 'text', text: JSON.stringify({ results, query: checked.value.query, provider: service.id }) }], structuredContent: { results, query: checked.value.query, provider: service.id } })).catch((err) => error(id, SEARCH_ERROR_CODES.PROVIDER_UNAVAILABLE, err.message));
|
||||
}
|
||||
return error(id, SEARCH_ERROR_CODES.PROVIDER_UNAVAILABLE, 'No search provider is configured.');
|
||||
}
|
||||
if (name === 'tkmind_read' && config.providers.reader) return readSafeUrl(params.arguments?.url).then((result) => response(id, { content: [{ type: 'text', text: result.content }], structuredContent: result })).catch((err) => error(id, SEARCH_ERROR_CODES.PROVIDER_UNAVAILABLE, err.message));
|
||||
if (name === 'tkmind_read' && resolveMindSearchService(config, 'read')?.adapter === 'reader') return readSafeUrl(params.arguments?.url).then((result) => response(id, { content: [{ type: 'text', text: result.content }], structuredContent: result })).catch((err) => error(id, SEARCH_ERROR_CODES.PROVIDER_UNAVAILABLE, err.message));
|
||||
if (name === 'tkmind_research') {
|
||||
const question = String(params.arguments?.question ?? '').trim();
|
||||
if (!question || question.length > 2000) return error(id, SEARCH_ERROR_CODES.INVALID_REQUEST, 'question must be 1-2000 characters');
|
||||
const depth = ['quick', 'standard', 'deep'].includes(params.arguments?.depth) ? params.arguments.depth : 'standard';
|
||||
const service = resolveMindSearchService(config, 'research');
|
||||
if (service?.adapter !== 'research-http') return error(id, SEARCH_ERROR_CODES.PROVIDER_UNAVAILABLE, 'No research service is configured.');
|
||||
return startResearchTask(question, { endpoint: service.endpoint, depth, timeoutMs: service.timeoutMs })
|
||||
.then((result) => response(id, { content: [{ type: 'text', text: JSON.stringify(result) }], structuredContent: result }))
|
||||
.catch((err) => error(id, SEARCH_ERROR_CODES.PROVIDER_UNAVAILABLE, err.message));
|
||||
}
|
||||
return error(id, SEARCH_ERROR_CODES.PROVIDER_UNAVAILABLE, 'No reader provider is configured.');
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user