feat: implement local deep search engine

This commit is contained in:
john
2026-07-23 22:27:07 +08:00
parent 4e36b98c9e
commit be1e4c18c0
12 changed files with 1717 additions and 16 deletions
+78 -6
View File
@@ -1,11 +1,21 @@
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 } = {}) {
export async function searchSearxng(query, {
limit = 10,
endpoint = process.env.TKMIND_SEARCH_SEARXNG_URL,
fetchImpl = fetch,
timeoutMs = Number(process.env.TKMIND_SEARCH_TIMEOUT_MS ?? 8000),
signal,
} = {}) {
if (!endpoint) throw new Error('SearXNG endpoint is not configured');
try { if (!['http:', 'https:'].includes(new URL(endpoint).protocol)) throw new Error('unsupported protocol'); } catch { throw new Error('SearXNG endpoint is not configured safely'); }
const url = new URL(endpoint); url.searchParams.set('q', query); url.searchParams.set('format', 'json');
const response = await fetchImpl(url, { signal: AbortSignal.timeout(Number(process.env.TKMIND_SEARCH_TIMEOUT_MS ?? 8000)), headers: { accept: 'application/json' } });
const timeout = AbortSignal.timeout(timeoutMs);
const response = await fetchImpl(url, {
signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
headers: { accept: 'application/json' },
});
if (!response.ok) throw new Error(`SearXNG returned ${response.status}`);
const body = await response.json();
return (Array.isArray(body.results) ? body.results : []).slice(0, limit).map((item, index) => normalizeSearchResult({ ...item, source: 'searxng' }, index));
@@ -37,11 +47,21 @@ function serviceUrl(endpoint, path) {
return new URL(path.replace(/^\//, ''), base);
}
async function postServiceJson(endpoint, path, payload, { timeoutMs = 30000, fetchImpl = fetch } = {}) {
async function postServiceJson(endpoint, path, payload, {
timeoutMs = 30000,
fetchImpl = fetch,
userId = null,
secret = process.env.TKMIND_DEEP_SEARCH_SECRET,
} = {}) {
const response = await fetchImpl(serviceUrl(endpoint, path), {
method: 'POST',
signal: AbortSignal.timeout(timeoutMs),
headers: { accept: 'application/json', 'content-type': 'application/json' },
headers: {
accept: 'application/json',
'content-type': 'application/json',
...(userId ? { 'x-user-id': String(userId) } : {}),
...(secret ? { 'x-secret-key': String(secret) } : {}),
},
body: JSON.stringify(payload),
});
if (!response.ok) throw new Error(`Search service returned ${response.status}`);
@@ -54,8 +74,13 @@ export async function searchResearchService(query, {
limit = 10,
timeoutMs = 30000,
fetchImpl = fetch,
secret = process.env.TKMIND_DEEP_SEARCH_SECRET,
} = {}) {
const body = await postServiceJson(endpoint, '/v1/search', { query, type, limit }, { timeoutMs, fetchImpl });
const body = await postServiceJson(endpoint, '/v1/search', { query, type, limit }, {
timeoutMs,
fetchImpl,
secret,
});
return (Array.isArray(body.results) ? body.results : [])
.slice(0, limit)
.map((item, index) => normalizeSearchResult({ ...item, source: item.source || 'research-service' }, index));
@@ -64,10 +89,16 @@ export async function searchResearchService(query, {
export async function startResearchTask(question, {
endpoint,
depth = 'standard',
userId = null,
timeoutMs = 30000,
fetchImpl = fetch,
secret = process.env.TKMIND_DEEP_SEARCH_SECRET,
} = {}) {
const body = await postServiceJson(endpoint, '/v1/research', { question, depth }, { timeoutMs, fetchImpl });
const body = await postServiceJson(endpoint, '/v1/research', {
question,
depth,
...(userId ? { userId } : {}),
}, { timeoutMs, fetchImpl, userId, secret });
if (!body || typeof body !== 'object' || !body.task_id) throw new Error('Research service returned an invalid task');
return {
taskId: String(body.task_id),
@@ -75,3 +106,44 @@ export async function startResearchTask(question, {
service: String(body.service ?? 'research-service'),
};
}
export async function getResearchTask(taskId, {
endpoint,
userId = null,
timeoutMs = 30000,
fetchImpl = fetch,
secret = process.env.TKMIND_DEEP_SEARCH_SECRET,
} = {}) {
const response = await fetchImpl(serviceUrl(endpoint, `/v1/research/${encodeURIComponent(taskId)}`), {
signal: AbortSignal.timeout(timeoutMs),
headers: {
accept: 'application/json',
...(userId ? { 'x-user-id': String(userId) } : {}),
...(secret ? { 'x-secret-key': String(secret) } : {}),
},
});
if (response.status === 404) throw new Error('Research task was not found');
if (!response.ok) throw new Error(`Search service returned ${response.status}`);
return response.json();
}
export async function cancelResearchTask(taskId, {
endpoint,
userId = null,
timeoutMs = 30000,
fetchImpl = fetch,
secret = process.env.TKMIND_DEEP_SEARCH_SECRET,
} = {}) {
const response = await fetchImpl(serviceUrl(endpoint, `/v1/research/${encodeURIComponent(taskId)}`), {
method: 'DELETE',
signal: AbortSignal.timeout(timeoutMs),
headers: {
accept: 'application/json',
...(userId ? { 'x-user-id': String(userId) } : {}),
...(secret ? { 'x-secret-key': String(secret) } : {}),
},
});
if (response.status === 404) throw new Error('Research task was not found');
if (!response.ok) throw new Error(`Search service returned ${response.status}`);
return response.json();
}