feat: configure deep search llm models

This commit is contained in:
john
2026-07-23 22:46:10 +08:00
parent be1e4c18c0
commit 784a10a592
11 changed files with 360 additions and 35 deletions
+113 -9
View File
@@ -291,6 +291,69 @@ export function createOpenAiCompatibleResearchLlm({
};
}
export function createPortalGatewayResearchLlmResolver({
endpoint = process.env.TKMIND_DEEP_SEARCH_LLM_GATEWAY_URL
|| 'http://127.0.0.1:8081/api/internal/deep-search/llm',
secret = process.env.TKMIND_DEEP_SEARCH_LLM_GATEWAY_SECRET
|| process.env.TKMIND_DEEP_SEARCH_SECRET,
fetchImpl = fetch,
timeoutMs = 90_000,
} = {}) {
const resolver = async ({ providerKeyId, model }) => {
if (!providerKeyId || !model) throw new Error('Deep Search LLM provider and model are required');
if (!secret) throw new Error('Deep Search LLM gateway secret is not configured');
async function complete(messages, { json = false } = {}) {
const response = await fetchImpl(endpoint, {
method: 'POST',
signal: AbortSignal.timeout(timeoutMs),
headers: {
accept: 'application/json',
authorization: `Bearer ${secret}`,
'content-type': 'application/json',
},
body: JSON.stringify({
providerKeyId,
model,
messages,
temperature: 0.2,
json,
}),
});
const body = await response.json().catch(() => ({}));
if (!response.ok || !body.ok || !body.reply) {
throw new Error(body.message || `Deep Search LLM gateway returned ${response.status}`);
}
return String(body.reply).trim();
}
return {
async plan(question, depth) {
const content = await complete([
{
role: 'system',
content: 'You are a research planner. Return JSON {"research_plan":[{"goal":"","queries":[""],"sources":["web"]}]}. Create distinct, verifiable goals and search queries.',
},
{ role: 'user', content: `Depth: ${depth}\nQuestion: ${question}` },
], { json: true });
return JSON.parse(content);
},
async synthesize({ question, plan, evidence }) {
return complete([
{
role: 'system',
content: 'Write a rigorous Markdown research report using only the supplied evidence. Cite every factual claim with [n]. Include executive summary, findings by research goal, uncertainties, and sources. Never invent citations.',
},
{
role: 'user',
content: JSON.stringify({ question, plan, evidence }, null, 2).slice(0, 120_000),
},
]);
},
};
};
resolver.configured = Boolean(endpoint && secret);
return resolver;
}
function sourceAuthority(url) {
try {
const hostname = new URL(url).hostname;
@@ -446,6 +509,7 @@ export function createDeepSearchEngine({
searchProvider = createSearxngResearchProvider(),
reader = readResearchSource,
llm = createOpenAiCompatibleResearchLlm(),
llmResolver = null,
memorySink = null,
idFactory = () => randomUUID(),
now = () => Date.now(),
@@ -460,10 +524,10 @@ export function createDeepSearchEngine({
emitter.emit(taskId, { type, payload, createdAt: now() });
}
async function planResearch(question, depth) {
if (llm?.plan) {
async function planResearch(question, depth, activeLlm) {
if (activeLlm?.plan) {
try {
return normalizePlan(await llm.plan(question, depth), question, depth);
return normalizePlan(await activeLlm.plan(question, depth), question, depth);
} catch {
// The deterministic planner keeps Deep Search available when the LLM is unavailable.
}
@@ -477,7 +541,23 @@ export function createDeepSearchEngine({
try {
store.updateTask(taskId, { status: 'researching', phase: 'planning', progress: 5 });
publish(taskId, 'phase', { phase: 'planning', progress: 5 });
const plan = await planResearch(task.question, task.depth);
let activeLlm = llm;
if (task.llmProviderKeyId && typeof llmResolver === 'function') {
try {
activeLlm = await llmResolver({
providerKeyId: task.llmProviderKeyId,
model: task.llmModel,
});
publish(taskId, 'llm_selected', {
providerKeyId: task.llmProviderKeyId,
model: task.llmModel,
});
} catch (error) {
activeLlm = null;
publish(taskId, 'llm_error', { message: String(error?.message ?? error) });
}
}
const plan = await planResearch(task.question, task.depth, activeLlm);
throwIfAborted(signal);
store.updateTask(taskId, { plan, phase: 'searching', progress: 12 });
publish(taskId, 'plan', { plan });
@@ -561,13 +641,17 @@ export function createDeepSearchEngine({
store.updateTask(taskId, { phase: 'synthesizing', progress: 78 });
publish(taskId, 'phase', { phase: 'synthesizing', progress: 78, evidence: evidence.length });
let report = '';
if (llm?.synthesize && finalSources.length) {
if (activeLlm?.synthesize && finalSources.length) {
try {
const numberedEvidence = evidence.map((item) => ({
...item,
citation: finalSources.findIndex((source) => source.url === item.url) + 1,
}));
const candidate = await llm.synthesize({ question: task.question, plan, evidence: numberedEvidence });
const candidate = await activeLlm.synthesize({
question: task.question,
plan,
evidence: numberedEvidence,
});
if (validLlmReport(candidate, finalSources.length)) report = candidate;
} catch {
// Fall through to the citation-safe deterministic report.
@@ -637,14 +721,33 @@ export function createDeepSearchEngine({
}
return {
start({ question, depth = 'standard', userId = null } = {}) {
start({
question,
depth = 'standard',
userId = null,
llmProviderKeyId = '',
llmModel = '',
} = {}) {
const normalizedQuestion = String(question ?? '').trim();
if (!normalizedQuestion || normalizedQuestion.length > 2000) {
throw new Error('question must be 1-2000 characters');
}
const normalizedDepth = Object.hasOwn(DEPTH_PROFILES, depth) ? depth : 'standard';
const normalizedProviderKeyId = /^[a-zA-Z0-9._:-]{1,128}$/.test(String(llmProviderKeyId))
? String(llmProviderKeyId)
: '';
const normalizedModel = normalizedProviderKeyId
? String(llmModel ?? '').trim().slice(0, 200)
: '';
const taskId = idFactory();
store.createTask({ id: taskId, userId, question: normalizedQuestion, depth: normalizedDepth });
store.createTask({
id: taskId,
userId,
question: normalizedQuestion,
depth: normalizedDepth,
llmProviderKeyId: normalizedProviderKeyId,
llmModel: normalizedModel,
});
const controller = new AbortController();
running.set(taskId, controller);
queueMicrotask(() => executeTask(taskId, controller.signal));
@@ -690,7 +793,8 @@ export function createDeepSearchEngine({
service: 'tkmind-deep-search',
running: running.size,
tasks: store.getStats(),
llmEnabled: Boolean(llm),
llmEnabled: Boolean(llm || llmResolver?.configured),
llmMode: llmResolver?.configured ? 'portal-gateway' : (llm ? 'direct' : 'deterministic'),
provider: searchProvider.name ?? 'custom',
};
},