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',
};
},
+63
View File
@@ -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
View File
@@ -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
View File
@@ -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);
},
+72 -2
View File
@@ -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,7 +135,10 @@ This model-generated analysis is intentionally long enough to pass report valida
const engine = createDeepSearchEngine({
store,
idFactory: () => 'task-llm',
llm: {
llmResolver: async ({ providerKeyId, model }) => {
assert.equal(providerKeyId, 'provider-key-1');
assert.equal(model, 'research-model');
return {
async plan() {
return {
research_plan: [{
@@ -146,6 +151,7 @@ This model-generated analysis is intentionally long enough to pass report valida
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')));
});
+11 -6
View File
@@ -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`
+12
View File
@@ -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,
};
}
+5
View File
@@ -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 {
+8
View File
@@ -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
View File
@@ -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 {
+2
View File
@@ -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 }))