feat: configure deep search llm models
This commit is contained in:
+113
-9
@@ -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',
|
||||
};
|
||||
},
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
function secureEqual(left, right) {
|
||||
const a = Buffer.from(String(left ?? ''));
|
||||
const b = Buffer.from(String(right ?? ''));
|
||||
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
||||
}
|
||||
|
||||
function normalizeMessages(value) {
|
||||
if (!Array.isArray(value) || !value.length || value.length > 20) return null;
|
||||
const messages = value.map((message) => ({
|
||||
role: ['system', 'user', 'assistant'].includes(message?.role) ? message.role : '',
|
||||
content: String(message?.content ?? '').trim(),
|
||||
}));
|
||||
if (messages.some((message) => !message.role || !message.content || message.content.length > 120_000)) {
|
||||
return null;
|
||||
}
|
||||
if (messages.reduce((total, message) => total + message.content.length, 0) > 150_000) return null;
|
||||
return messages;
|
||||
}
|
||||
|
||||
export async function executeDeepSearchLlmGateway({
|
||||
llmProviderService,
|
||||
expectedSecret,
|
||||
providedSecret,
|
||||
input = {},
|
||||
} = {}) {
|
||||
if (!expectedSecret || !secureEqual(expectedSecret, providedSecret)) {
|
||||
return { status: 401, body: { ok: false, message: 'Deep Search LLM gateway credential is invalid' } };
|
||||
}
|
||||
if (!llmProviderService?.createChatCompletion) {
|
||||
return { status: 503, body: { ok: false, message: 'LLM provider service is unavailable' } };
|
||||
}
|
||||
const providerKeyId = String(input.providerKeyId ?? '').trim();
|
||||
const model = String(input.model ?? '').trim();
|
||||
const messages = normalizeMessages(input.messages);
|
||||
if (!/^[a-zA-Z0-9._:-]{1,128}$/.test(providerKeyId) || !model || model.length > 200 || !messages) {
|
||||
return { status: 400, body: { ok: false, message: 'Invalid Deep Search LLM request' } };
|
||||
}
|
||||
const result = await llmProviderService.createChatCompletion({
|
||||
providerKeyId,
|
||||
model,
|
||||
messages,
|
||||
temperature: Math.max(0, Math.min(1, Number(input.temperature ?? 0.2) || 0.2)),
|
||||
});
|
||||
if (!result?.ok) {
|
||||
return {
|
||||
status: Number(result?.status) >= 400 ? Number(result.status) : 502,
|
||||
body: { ok: false, message: result?.message || 'Deep Search LLM request failed' },
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
ok: true,
|
||||
reply: result.reply,
|
||||
providerKeyId: result.providerKeyId,
|
||||
providerId: result.providerId,
|
||||
model: result.model,
|
||||
usage: result.usage ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
+11
-2
@@ -1,7 +1,10 @@
|
||||
import http from 'node:http';
|
||||
import path from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { createDeepSearchEngine } from './deep-search-engine.mjs';
|
||||
import {
|
||||
createDeepSearchEngine,
|
||||
createPortalGatewayResearchLlmResolver,
|
||||
} from './deep-search-engine.mjs';
|
||||
import { createDeepSearchStore } from './deep-search-store.mjs';
|
||||
|
||||
function sendJson(res, status, body) {
|
||||
@@ -83,6 +86,8 @@ export function createDeepSearchHttpServer({
|
||||
const task = engine.start({
|
||||
...input,
|
||||
userId: requestUserId || input.userId || null,
|
||||
llmProviderKeyId: input.llmProviderKeyId || input.llm_provider_key_id || '',
|
||||
llmModel: input.llmModel || input.llm_model || '',
|
||||
});
|
||||
return sendJson(res, 202, {
|
||||
task_id: task.taskId,
|
||||
@@ -163,7 +168,11 @@ export function startDeepSearchServer({
|
||||
logger = console,
|
||||
} = {}) {
|
||||
const store = createDeepSearchStore({ databasePath });
|
||||
const engine = createDeepSearchEngine({ store });
|
||||
const engine = createDeepSearchEngine({
|
||||
store,
|
||||
llm: null,
|
||||
llmResolver: createPortalGatewayResearchLlmResolver(),
|
||||
});
|
||||
const server = createDeepSearchHttpServer({ engine, logger });
|
||||
server.listen(port, host, () => {
|
||||
logger.info?.(`[deep-search] listening on http://${host}:${port}`);
|
||||
|
||||
+31
-4
@@ -29,6 +29,8 @@ function mapTask(row) {
|
||||
userId: row.user_id || null,
|
||||
question: row.question,
|
||||
depth: row.depth,
|
||||
llmProviderKeyId: row.llm_provider_key_id || '',
|
||||
llmModel: row.llm_model || '',
|
||||
status: row.status,
|
||||
progress: row.progress,
|
||||
phase: row.phase,
|
||||
@@ -72,6 +74,8 @@ export function createDeepSearchStore({
|
||||
user_id TEXT,
|
||||
question TEXT NOT NULL,
|
||||
depth TEXT NOT NULL,
|
||||
llm_provider_key_id TEXT NOT NULL DEFAULT '',
|
||||
llm_model TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL,
|
||||
progress INTEGER NOT NULL DEFAULT 0,
|
||||
phase TEXT NOT NULL DEFAULT 'queued',
|
||||
@@ -114,11 +118,18 @@ export function createDeepSearchStore({
|
||||
CREATE INDEX IF NOT EXISTS idx_research_sources_task ON research_sources(task_id, score DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_research_events_task ON research_events(task_id, seq);
|
||||
`);
|
||||
const taskColumns = new Set(db.prepare('PRAGMA table_info(research_tasks)').all().map((row) => row.name));
|
||||
if (!taskColumns.has('llm_provider_key_id')) {
|
||||
db.exec("ALTER TABLE research_tasks ADD COLUMN llm_provider_key_id TEXT NOT NULL DEFAULT ''");
|
||||
}
|
||||
if (!taskColumns.has('llm_model')) {
|
||||
db.exec("ALTER TABLE research_tasks ADD COLUMN llm_model TEXT NOT NULL DEFAULT ''");
|
||||
}
|
||||
|
||||
const createTaskStmt = db.prepare(`
|
||||
INSERT INTO research_tasks
|
||||
(id, user_id, question, depth, status, progress, phase, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, 'queued', 0, 'queued', ?, ?)
|
||||
(id, user_id, question, depth, llm_provider_key_id, llm_model, status, progress, phase, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 'queued', 0, 'queued', ?, ?)
|
||||
`);
|
||||
const getTaskStmt = db.prepare('SELECT * FROM research_tasks WHERE id = ?');
|
||||
const listTasksStmt = db.prepare('SELECT * FROM research_tasks ORDER BY created_at DESC LIMIT ?');
|
||||
@@ -165,9 +176,25 @@ export function createDeepSearchStore({
|
||||
}
|
||||
|
||||
return {
|
||||
createTask({ id, userId = null, question, depth }) {
|
||||
createTask({
|
||||
id,
|
||||
userId = null,
|
||||
question,
|
||||
depth,
|
||||
llmProviderKeyId = '',
|
||||
llmModel = '',
|
||||
}) {
|
||||
const timestamp = now();
|
||||
createTaskStmt.run(id, userId, question, depth, timestamp, timestamp);
|
||||
createTaskStmt.run(
|
||||
id,
|
||||
userId,
|
||||
question,
|
||||
depth,
|
||||
llmProviderKeyId,
|
||||
llmModel,
|
||||
timestamp,
|
||||
timestamp,
|
||||
);
|
||||
appendEventStmt.run(id, 'queued', JSON.stringify({ depth }), timestamp);
|
||||
return getTask(id);
|
||||
},
|
||||
|
||||
+84
-14
@@ -5,10 +5,12 @@ import {
|
||||
buildFallbackResearchPlan,
|
||||
canonicalizeUrl,
|
||||
createDeepSearchEngine,
|
||||
createPortalGatewayResearchLlmResolver,
|
||||
extractEvidence,
|
||||
htmlToPlainText,
|
||||
readResearchSource,
|
||||
} from './deep-search-engine.mjs';
|
||||
import { executeDeepSearchLlmGateway } from './deep-search-llm-gateway.mjs';
|
||||
import { createDeepSearchHttpServer } from './deep-search-server.mjs';
|
||||
import { createDeepSearchStore } from './deep-search-store.mjs';
|
||||
|
||||
@@ -133,19 +135,23 @@ This model-generated analysis is intentionally long enough to pass report valida
|
||||
const engine = createDeepSearchEngine({
|
||||
store,
|
||||
idFactory: () => 'task-llm',
|
||||
llm: {
|
||||
async plan() {
|
||||
return {
|
||||
research_plan: [{
|
||||
goal: 'Primary evidence',
|
||||
queries: ['official evidence', 'independent evidence'],
|
||||
sources: ['web'],
|
||||
}],
|
||||
};
|
||||
},
|
||||
async synthesize() {
|
||||
return llmReport;
|
||||
},
|
||||
llmResolver: async ({ providerKeyId, model }) => {
|
||||
assert.equal(providerKeyId, 'provider-key-1');
|
||||
assert.equal(model, 'research-model');
|
||||
return {
|
||||
async plan() {
|
||||
return {
|
||||
research_plan: [{
|
||||
goal: 'Primary evidence',
|
||||
queries: ['official evidence', 'independent evidence'],
|
||||
sources: ['web'],
|
||||
}],
|
||||
};
|
||||
},
|
||||
async synthesize() {
|
||||
return llmReport;
|
||||
},
|
||||
};
|
||||
},
|
||||
memorySink: async (payload) => {
|
||||
memoryPayload = payload;
|
||||
@@ -172,18 +178,75 @@ This model-generated analysis is intentionally long enough to pass report valida
|
||||
question: 'Evaluate the tested claim',
|
||||
depth: 'quick',
|
||||
userId: 'memory-user',
|
||||
llmProviderKeyId: 'provider-key-1',
|
||||
llmModel: 'research-model',
|
||||
});
|
||||
const task = await waitFor(() => {
|
||||
const current = engine.getTask('task-llm');
|
||||
return current.status === 'completed' ? current : null;
|
||||
});
|
||||
assert.equal(task.report, llmReport);
|
||||
assert.equal(task.llmProviderKeyId, 'provider-key-1');
|
||||
assert.equal(task.llmModel, 'research-model');
|
||||
assert.ok(task.events.some((event) => event.type === 'llm_selected'));
|
||||
assert.equal(memoryPayload.userId, 'memory-user');
|
||||
assert.equal(memoryPayload.taskId, 'task-llm');
|
||||
assert.ok(task.events.some((event) => event.type === 'memory_saved'));
|
||||
store.close();
|
||||
});
|
||||
|
||||
test('Deep Search portal LLM gateway keeps provider secrets inside the portal', async () => {
|
||||
let request = null;
|
||||
const unauthorized = await executeDeepSearchLlmGateway({
|
||||
llmProviderService: {},
|
||||
expectedSecret: 'expected',
|
||||
providedSecret: 'wrong',
|
||||
input: {},
|
||||
});
|
||||
assert.equal(unauthorized.status, 401);
|
||||
const authorized = await executeDeepSearchLlmGateway({
|
||||
expectedSecret: 'expected',
|
||||
providedSecret: 'expected',
|
||||
input: {
|
||||
providerKeyId: 'key-1',
|
||||
model: 'model-1',
|
||||
messages: [{ role: 'user', content: 'Plan research' }],
|
||||
},
|
||||
llmProviderService: {
|
||||
async createChatCompletion(input) {
|
||||
request = input;
|
||||
return {
|
||||
ok: true,
|
||||
providerKeyId: 'key-1',
|
||||
providerId: 'custom',
|
||||
model: 'model-1',
|
||||
reply: '{"research_plan":[]}',
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.equal(authorized.status, 200);
|
||||
assert.equal(request.providerKeyId, 'key-1');
|
||||
assert.equal(request.model, 'model-1');
|
||||
|
||||
const resolver = createPortalGatewayResearchLlmResolver({
|
||||
endpoint: 'http://portal.local/api/internal/deep-search/llm',
|
||||
secret: 'expected',
|
||||
fetchImpl: async (_url, options) => {
|
||||
const body = JSON.parse(options.body);
|
||||
assert.equal(options.headers.authorization, 'Bearer expected');
|
||||
assert.equal(body.providerKeyId, 'key-1');
|
||||
return new Response(JSON.stringify({ ok: true, reply: '{"research_plan":[]}' }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
},
|
||||
});
|
||||
assert.equal(resolver.configured, true);
|
||||
const resolved = await resolver({ providerKeyId: 'key-1', model: 'model-1' });
|
||||
assert.deepEqual(await resolved.plan('Question', 'quick'), { research_plan: [] });
|
||||
});
|
||||
|
||||
test('Deep Search supports cancellation while a provider is running', async () => {
|
||||
const store = createDeepSearchStore({ databasePath: ':memory:' });
|
||||
const engine = createDeepSearchEngine({
|
||||
@@ -239,7 +302,12 @@ test('Deep Search HTTP service exposes health, async start, status, search, and
|
||||
const started = await fetch(`${base}/v1/research`, {
|
||||
method: 'POST',
|
||||
headers: { ...headers, 'x-user-id': 'user-a' },
|
||||
body: JSON.stringify({ question: 'Evaluate Deep Search', depth: 'quick' }),
|
||||
body: JSON.stringify({
|
||||
question: 'Evaluate Deep Search',
|
||||
depth: 'quick',
|
||||
llm_provider_key_id: 'key-http',
|
||||
llm_model: 'model-http',
|
||||
}),
|
||||
}).then((response) => response.json());
|
||||
assert.equal(started.task_id, 'task-http');
|
||||
assert.equal((await fetch(`${base}/v1/research/${started.task_id}`, {
|
||||
@@ -253,5 +321,7 @@ test('Deep Search HTTP service exposes health, async start, status, search, and
|
||||
return task.status === 'completed' ? task : null;
|
||||
});
|
||||
assert.equal(completed.progress, 100);
|
||||
assert.equal(completed.llmProviderKeyId, 'key-http');
|
||||
assert.equal(completed.llmModel, 'model-http');
|
||||
assert.ok(completed.sources.every((source) => !Object.hasOwn(source, 'content')));
|
||||
});
|
||||
|
||||
@@ -15,7 +15,7 @@ The standalone runtime uses the built-in `node:sqlite` module and therefore requ
|
||||
7. Generate a Markdown report with numbered citations.
|
||||
8. Persist task state, sources, events, report, and research memory in SQLite.
|
||||
|
||||
The planner and report writer use an OpenAI-compatible model when configured. If the model is unavailable, deterministic planning and citation-safe extractive reporting keep the service operational.
|
||||
The planner and report writer use an optional model selected from TKMind's Unified Model Center. MindSearch stores only the Provider Key ID and model name. Deep Search calls a secret-protected Portal gateway, and the provider API key never leaves the Portal process. If the selected model is unavailable, deterministic planning and citation-safe extractive reporting keep the service operational.
|
||||
|
||||
`createDeepSearchEngine` also accepts an explicit `memorySink` callback. It is disabled by default; an in-process TKMind integration can inject `memoryV2.write` without giving the standalone service an unrestricted outbound memory endpoint. Memory sink failures are fail-open and recorded as task events.
|
||||
|
||||
@@ -24,6 +24,7 @@ The planner and report writer use an OpenAI-compatible model when configured. If
|
||||
```bash
|
||||
TKMIND_DEEP_SEARCH_SEARXNG_URL=http://127.0.0.1:8080/search \
|
||||
TKMIND_DEEP_SEARCH_DB=.deep-search/research.sqlite \
|
||||
TKMIND_DEEP_SEARCH_SECRET=local-shared-secret \
|
||||
npm run dev:deep-search
|
||||
```
|
||||
|
||||
@@ -70,14 +71,14 @@ Start response:
|
||||
| `TKMIND_DEEP_SEARCH_DB` | `.deep-search/research.sqlite` | SQLite database |
|
||||
| `TKMIND_DEEP_SEARCH_SEARXNG_URL` | `http://127.0.0.1:8080/search` | Upstream search endpoint |
|
||||
| `TKMIND_DEEP_SEARCH_SECRET` | empty | Optional `X-Secret-Key` required by non-health routes |
|
||||
| `TKMIND_DEEP_SEARCH_LLM_URL` | empty | OpenAI-compatible chat-completions endpoint |
|
||||
| `TKMIND_DEEP_SEARCH_LLM_API_KEY` | empty | Optional model API key |
|
||||
| `TKMIND_DEEP_SEARCH_LLM_MODEL` | empty | Planner and report model |
|
||||
| `TKMIND_DEEP_SEARCH_LLM_GATEWAY_URL` | `http://127.0.0.1:8081/api/internal/deep-search/llm` | Portal LLM gateway |
|
||||
| `TKMIND_DEEP_SEARCH_LLM_GATEWAY_SECRET` | `TKMIND_DEEP_SEARCH_SECRET` | Portal gateway bearer secret |
|
||||
|
||||
## Isolation and safety
|
||||
|
||||
- The default bind address is loopback only.
|
||||
- An optional service secret protects all non-health endpoints.
|
||||
- The same secret can authenticate the local service to the Portal LLM gateway.
|
||||
- MCP forwards the current TKMind user ID as `X-User-Id`.
|
||||
- Task status, event, and cancellation routes enforce user ownership when a user ID is present.
|
||||
- Source reading rejects credentials, loopback, link-local, private, multicast, and private-DNS destinations.
|
||||
@@ -94,11 +95,15 @@ The built-in disabled service is:
|
||||
"adapter": "research-http",
|
||||
"endpoint": "http://127.0.0.1:20100",
|
||||
"healthPath": "/health",
|
||||
"enabled": false
|
||||
"enabled": false,
|
||||
"llmProviderKeyId": "",
|
||||
"llmModel": ""
|
||||
}
|
||||
```
|
||||
|
||||
After local verification, enable the service and MindSearch in `memindadm`, then select `deep-search` for the `research` route. New Goose sessions receive:
|
||||
In `memindadm` → `MindSearch` → `TKMind Deep Search`, select an optional active Provider configuration and one of its models. The list comes directly from Unified Model Center. The `测试 LLM` action checks that exact Provider/model combination before saving the MindSearch configuration. Selecting “不使用 LLM(确定性降级)” leaves both fields empty.
|
||||
|
||||
After local verification, enable the service and MindSearch, then select `deep-search` for the `research` route. New Goose sessions receive:
|
||||
|
||||
- `tkmind_research`
|
||||
- `tkmind_research_status`
|
||||
|
||||
@@ -53,6 +53,8 @@ const BUILTIN_SERVICES = Object.freeze([
|
||||
enabled: false,
|
||||
timeoutMs: 120000,
|
||||
priority: 100,
|
||||
llmProviderKeyId: '',
|
||||
llmModel: '',
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -104,6 +106,14 @@ function normalizeService(input = {}, fallback = {}) {
|
||||
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,
|
||||
@@ -115,6 +125,8 @@ function normalizeService(input = {}, fallback = {}) {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -90,6 +90,8 @@ export async function startResearchTask(question, {
|
||||
endpoint,
|
||||
depth = 'standard',
|
||||
userId = null,
|
||||
llmProviderKeyId = '',
|
||||
llmModel = '',
|
||||
timeoutMs = 30000,
|
||||
fetchImpl = fetch,
|
||||
secret = process.env.TKMIND_DEEP_SEARCH_SECRET,
|
||||
@@ -98,6 +100,9 @@ export async function startResearchTask(question, {
|
||||
question,
|
||||
depth,
|
||||
...(userId ? { userId } : {}),
|
||||
...(llmProviderKeyId && llmModel
|
||||
? { llm_provider_key_id: llmProviderKeyId, llm_model: llmModel }
|
||||
: {}),
|
||||
}, { timeoutMs, fetchImpl, userId, secret });
|
||||
if (!body || typeof body !== 'object' || !body.task_id) throw new Error('Research service returned an invalid task');
|
||||
return {
|
||||
|
||||
@@ -43,6 +43,8 @@ test('MindSearch service registry accepts research orchestrators and rejects uns
|
||||
capabilities: ['search.web', 'research.execute', 'research.execute', 'INVALID CAPABILITY'],
|
||||
enabled: true,
|
||||
timeoutMs: 500000,
|
||||
llmProviderKeyId: 'provider-key-1',
|
||||
llmModel: 'research-model',
|
||||
},
|
||||
{ id: 'metadata', adapter: 'research-http', endpoint: 'http://169.254.169.254/latest', enabled: true },
|
||||
],
|
||||
@@ -51,6 +53,8 @@ test('MindSearch service registry accepts research orchestrators and rejects uns
|
||||
assert.equal(config.services.some((service) => service.id === 'metadata'), false);
|
||||
assert.deepEqual(config.services.find((service) => service.id === 'research-main').capabilities, ['search.web', 'research.execute']);
|
||||
assert.equal(config.services.find((service) => service.id === 'research-main').timeoutMs, 120000);
|
||||
assert.equal(config.services.find((service) => service.id === 'research-main').llmProviderKeyId, 'provider-key-1');
|
||||
assert.equal(config.services.find((service) => service.id === 'research-main').llmModel, 'research-model');
|
||||
assert.equal(resolveMindSearchService(config, 'research').id, 'research-main');
|
||||
});
|
||||
|
||||
@@ -146,6 +150,8 @@ test('Research service adapter uses the stable HTTP contract', async () => {
|
||||
endpoint: 'http://research.local',
|
||||
depth: 'deep',
|
||||
userId: 'user-1',
|
||||
llmProviderKeyId: 'provider-key-1',
|
||||
llmModel: 'research-model',
|
||||
secret: 'secret-1',
|
||||
fetchImpl,
|
||||
});
|
||||
@@ -160,6 +166,8 @@ test('Research service adapter uses the stable HTTP contract', async () => {
|
||||
question: 'Analyze AI Agent',
|
||||
depth: 'deep',
|
||||
userId: 'user-1',
|
||||
llm_provider_key_id: 'provider-key-1',
|
||||
llm_model: 'research-model',
|
||||
});
|
||||
assert.equal(requests[1].options.headers['x-user-id'], 'user-1');
|
||||
assert.equal(requests[1].options.headers['x-secret-key'], 'secret-1');
|
||||
|
||||
+20
@@ -198,6 +198,7 @@ import { createScheduleService } from './schedule-service.mjs';
|
||||
import { createFeedbackService } from './user-feedback.mjs';
|
||||
import { startScheduleReminderWorker } from './schedule-reminder-worker.mjs';
|
||||
import { createLlmProviderService, RELAY_BOOTSTRAP } from './llm-providers.mjs';
|
||||
import { executeDeepSearchLlmGateway } from './deep-search-llm-gateway.mjs';
|
||||
import { createDirectChatService, isDirectChatSessionId, isPortalDirectChatSnapshot, sendDirectChatSessionEvents, shouldExpirePortalDirectChatSnapshot } from './direct-chat-service.mjs';
|
||||
import {
|
||||
filterUserVisibleConversation,
|
||||
@@ -254,6 +255,7 @@ const API_TARGETS = parseApiTargets();
|
||||
const API_TARGET = API_TARGETS[0] ?? 'https://127.0.0.1:18006';
|
||||
const API_SECRET = process.env.TKMIND_SERVER__SECRET_KEY ?? 'local-dev-secret';
|
||||
const INTERNAL_AGENT_SECRET = process.env.MINDSPACE_INTERNAL_AGENT_SECRET ?? API_SECRET;
|
||||
const DEEP_SEARCH_INTERNAL_SECRET = process.env.TKMIND_DEEP_SEARCH_SECRET ?? INTERNAL_AGENT_SECRET;
|
||||
const mindSpaceServerRuntime = resolveMindSpaceServerRuntimeOptions(__dirname, process.env);
|
||||
const ACCESS_PASSWORD = process.env.H5_ACCESS_PASSWORD;
|
||||
const WECHAT_MP_CONFIG = loadWechatMpConfig();
|
||||
@@ -2741,6 +2743,24 @@ function requireInternalAgentSecret(req, res) {
|
||||
return false;
|
||||
}
|
||||
|
||||
api.post('/internal/deep-search/llm', async (req, res) => {
|
||||
try {
|
||||
const result = await executeDeepSearchLlmGateway({
|
||||
llmProviderService,
|
||||
expectedSecret: DEEP_SEARCH_INTERNAL_SECRET,
|
||||
providedSecret: bearerToken(req) ?? req.get('x-secret-key'),
|
||||
input: req.body ?? {},
|
||||
});
|
||||
return res.status(result.status).json(result.body);
|
||||
} catch (error) {
|
||||
console.warn('[deep-search] LLM gateway failed:', error);
|
||||
return res.status(502).json({
|
||||
ok: false,
|
||||
message: error instanceof Error ? error.message : 'Deep Search LLM gateway failed',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
api.post('/mindspace/v1/agent/jobs', async (req, res) => {
|
||||
if (!mindSpaceAgentJobs || !ensureMindSpaceEnabled(res, req, { agent: true })) return;
|
||||
try {
|
||||
|
||||
@@ -107,6 +107,8 @@ function handle(message) {
|
||||
endpoint: service.endpoint,
|
||||
depth,
|
||||
userId: process.env.TKMIND_SEARCH_USER_ID || null,
|
||||
llmProviderKeyId: service.llmProviderKeyId || '',
|
||||
llmModel: service.llmModel || '',
|
||||
timeoutMs: service.timeoutMs,
|
||||
})
|
||||
.then((result) => response(id, { content: [{ type: 'text', text: JSON.stringify(result) }], structuredContent: result }))
|
||||
|
||||
Reference in New Issue
Block a user