409 lines
15 KiB
JavaScript
409 lines
15 KiB
JavaScript
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',
|
|
};
|
|
},
|
|
};
|
|
}
|