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, }, { 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: '', }, ]); 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: 'deep-search' }, }); const clone = (value) => JSON.parse(JSON.stringify(value)); const bool = (value, fallback = false) => { if (typeof value === 'boolean') return value; if (value == null || value === '') return fallback; 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 : ''; const llmProviderKeyIdValue = String(input.llmProviderKeyId ?? fallback.llmProviderKeyId ?? '').trim(); const llmProviderKeyId = /^[a-zA-Z0-9._:-]{1,128}$/.test(llmProviderKeyIdValue) ? llmProviderKeyIdValue : ''; const llmModelValue = String(input.llmModel ?? fallback.llmModel ?? '').trim(); const llmModel = llmProviderKeyId && !/[\u0000-\u001f\u007f]/.test(llmModelValue) ? llmModelValue.slice(0, 200) : ''; 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), llmProviderKeyId, llmModel, }; } 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: service.adapter === 'research-http' ? service.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', ...(normalized.adapter === 'research-http' && process.env.TKMIND_DEEP_SEARCH_SECRET ? { 'x-secret-key': process.env.TKMIND_DEEP_SEARCH_SECRET } : {}), }, }); 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(searxng?.enabled), github: bool(github?.enabled), reader: bool(reader?.enabled), }, settings: { 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; config.mode = 'off'; } return config; } function envConfig(env = process.env) { const enabled = bool(env.TKMIND_SEARCH_ENABLED); return normalizeMindSearchConfig({ enabled, mode: env.TKMIND_SEARCH_MODE || (enabled ? 'assist' : 'off'), providers: { searxng: env.TKMIND_SEARCH_PROVIDER_SEARXNG_ENABLED, 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; }); } export async function ensureMindSearchConfigSchema(pool) { await pool.query(`CREATE TABLE IF NOT EXISTS ${CONFIG_TABLE} ( config_scope VARCHAR(32) PRIMARY KEY, config_json JSON NOT NULL, updated_by CHAR(36) NULL, updated_at BIGINT NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`); } export function createMindSearchConfigService(pool, { env = process.env, fetchImpl = fetch } = {}) { let ready = null; const ensure = async () => { if (!pool) return; ready ??= ensureMindSearchConfigSchema(pool); await ready; }; const load = async () => { const fallback = envConfig(env); if (!pool) return { config: fallback, source: 'env' }; await ensure(); const [rows] = await pool.query(`SELECT config_json, updated_by, updated_at FROM ${CONFIG_TABLE} WHERE config_scope = ? LIMIT 1`, [CONFIG_SCOPE]); if (!rows?.[0]) return { config: fallback, source: 'env' }; let stored = rows[0].config_json; if (typeof stored === 'string') { try { stored = JSON.parse(stored); } catch { stored = {}; } } return { config: normalizeMindSearchConfig(stored), source: 'admin', updatedBy: rows[0].updated_by ?? null, updatedAt: Number(rows[0].updated_at ?? 0) || null }; }; return { 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 ?? {}) }, services: applyLegacyPatchToServices(current.config, patch), routes: { ...current.config.routes, ...(patch?.routes ?? {}) }, }); if (!pool) return { config: next, source: 'env' }; await ensure(); const now = Date.now(); await pool.query(`INSERT INTO ${CONFIG_TABLE} (config_scope, config_json, updated_by, updated_at) VALUES (?, ?, ?, ?) ON DUPLICATE KEY UPDATE config_json = VALUES(config_json), updated_by = VALUES(updated_by), updated_at = VALUES(updated_at)`, [CONFIG_SCOPE, JSON.stringify(next), updatedBy, now]); return { config: next, source: 'admin', updatedBy, updatedAt: now }; }, getRuntimeState: async () => { const config = await (async () => (await load()).config)(); 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', }; }, }; }