feat: add pluggable search service registry

This commit is contained in:
john
2026-07-23 21:38:36 +08:00
parent 48e9f68211
commit 4e36b98c9e
7 changed files with 506 additions and 21 deletions
+52 -7
View File
@@ -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.');
}