feat: add optional MindSearch skill orchestration
This commit is contained in:
@@ -19,6 +19,7 @@ import { createLlmProviderService } from './llm-providers.mjs';
|
||||
import { createAssetGatewayConfigService } from './asset-gateway.mjs';
|
||||
import { createMemoryV2AdminConfigService } from './memory-v2-admin-config.mjs';
|
||||
import { createSkillRuntimeAdminConfigService } from './skill-runtime-admin-config.mjs';
|
||||
import { createMindSearchConfigService } from './mindsearch-config.mjs';
|
||||
import { createWechatScheduleLlmConfigService } from './wechat-schedule-llm-config.mjs';
|
||||
import { createPlazaPostService, formatPostRow } from './plaza-posts.mjs';
|
||||
import { createPlazaInteractionService } from './plaza-interactions.mjs';
|
||||
@@ -101,6 +102,8 @@ export async function createAdminServices(env = {}) {
|
||||
const llmProviderService = createLlmProviderService(pool, { apiTarget, apiSecret });
|
||||
const assetGatewayConfigService = createAssetGatewayConfigService(pool, { llmProviderService });
|
||||
const memoryV2ConfigService = createMemoryV2AdminConfigService(pool);
|
||||
const mindSearchConfigService = createMindSearchConfigService(pool);
|
||||
await mindSearchConfigService.ensureSchema();
|
||||
const skillRuntimeConfigService = createSkillRuntimeAdminConfigService(pool, { h5Root });
|
||||
const wechatScheduleLlmConfigService = createWechatScheduleLlmConfigService(pool);
|
||||
const adminSystemTestService = createAdminSystemTestService({
|
||||
@@ -140,6 +143,7 @@ export async function createAdminServices(env = {}) {
|
||||
llmProviderService,
|
||||
assetGatewayConfigService,
|
||||
memoryV2ConfigService,
|
||||
mindSearchConfigService,
|
||||
skillRuntimeConfigService,
|
||||
wechatScheduleLlmConfigService,
|
||||
adminSystemTestService,
|
||||
|
||||
@@ -45,6 +45,7 @@ export function createAdminApi({
|
||||
llmProviderService,
|
||||
assetGatewayConfigService,
|
||||
memoryV2ConfigService,
|
||||
mindSearchConfigService,
|
||||
skillRuntimeConfigService,
|
||||
adminSystemTestService,
|
||||
plazaPosts,
|
||||
@@ -142,6 +143,21 @@ export function createAdminApi({
|
||||
adminApi.put('/memory-v2/config', requireAdmin, updateMemoryV2Config);
|
||||
adminApi.patch('/memory-v2/config', requireAdmin, updateMemoryV2Config);
|
||||
|
||||
adminApi.get('/mindsearch/config', requireAdmin, async (_req, res) => {
|
||||
if (!mindSearchConfigService?.getAdminConfig) return res.status(503).json({ message: 'MindSearch 配置服务未启用' });
|
||||
return res.json(await mindSearchConfigService.getAdminConfig());
|
||||
});
|
||||
const updateMindSearchConfig = async (req, res) => {
|
||||
if (!mindSearchConfigService?.updateAdminConfig) return res.status(503).json({ message: 'MindSearch 配置服务未启用' });
|
||||
return res.json(await mindSearchConfigService.updateAdminConfig(req.body ?? {}, { updatedBy: req.currentUser.id }));
|
||||
};
|
||||
adminApi.put('/mindsearch/config', requireAdmin, updateMindSearchConfig);
|
||||
adminApi.patch('/mindsearch/config', requireAdmin, updateMindSearchConfig);
|
||||
adminApi.get('/mindsearch/runtime', requireAdmin, async (_req, res) => {
|
||||
if (!mindSearchConfigService?.getRuntimeState) return res.status(503).json({ message: 'MindSearch 配置服务未启用' });
|
||||
return res.json(await mindSearchConfigService.getRuntimeState());
|
||||
});
|
||||
|
||||
adminApi.get('/memory-v2/runtime', requireAdmin, async (_req, res) => {
|
||||
if (!memoryV2ConfigService?.getRuntimeState) {
|
||||
return res.status(503).json({ message: 'Memory V2 配置服务未启用' });
|
||||
|
||||
@@ -90,6 +90,31 @@ test('admin memory-v2 config routes expose config and runtime state', async () =
|
||||
}
|
||||
});
|
||||
|
||||
test('admin MindSearch routes persist only through injected control-plane service', async () => {
|
||||
const updates = [];
|
||||
const router = createAdminApi({
|
||||
jsonBody: express.json(), getToken() { return 'token-admin'; },
|
||||
userAuth: { async getMe() { return { id: 'admin-1', role: 'admin' }; } },
|
||||
llmProviderService: null, memoryV2ConfigService: null,
|
||||
mindSearchConfigService: {
|
||||
async getAdminConfig() { return { config: { enabled: false, mode: 'off', providers: { searxng: false, github: false, reader: false } }, source: 'env' }; },
|
||||
async updateAdminConfig(patch, context) { updates.push({ patch, context }); return { config: { enabled: true, mode: 'shadow', providers: { searxng: true, github: false, reader: false } }, source: 'admin' }; },
|
||||
async getRuntimeState() { return { effective: false, mode: 'off' }; },
|
||||
},
|
||||
plazaPosts: null, plazaOps: null, wechatAdmin: null, subscriptionService: null,
|
||||
});
|
||||
const server = await startTestServer(router);
|
||||
try {
|
||||
const config = await fetch(`${server.baseUrl}/admin-api/mindsearch/config`, { headers: { cookie: 'h5_user_session=token-admin' } });
|
||||
assert.equal(config.status, 200);
|
||||
const update = await fetch(`${server.baseUrl}/admin-api/mindsearch/config`, { method: 'PATCH', headers: { 'content-type': 'application/json', cookie: 'h5_user_session=token-admin' }, body: JSON.stringify({ enabled: true, mode: 'shadow' }) });
|
||||
assert.equal(update.status, 200);
|
||||
assert.deepEqual(updates, [{ patch: { enabled: true, mode: 'shadow' }, context: { updatedBy: 'admin-1' } }]);
|
||||
const runtime = await fetch(`${server.baseUrl}/admin-api/mindsearch/runtime`, { headers: { cookie: 'h5_user_session=token-admin' } });
|
||||
assert.deepEqual(await runtime.json(), { effective: false, mode: 'off' });
|
||||
} finally { await server.close(); }
|
||||
});
|
||||
|
||||
test('admin asset gateway routes preserve an explicit, admin-only control plane', async () => {
|
||||
const calls = [];
|
||||
const router = createAdminApi({
|
||||
|
||||
@@ -78,6 +78,7 @@ const CONSOLES = {
|
||||
llmProviderService: services.llmProviderService,
|
||||
assetGatewayConfigService: services.assetGatewayConfigService,
|
||||
memoryV2ConfigService: services.memoryV2ConfigService,
|
||||
mindSearchConfigService: services.mindSearchConfigService,
|
||||
adminSystemTestService: services.adminSystemTestService,
|
||||
plazaPosts: services.plazaPosts,
|
||||
plazaOps: services.plazaOps,
|
||||
|
||||
+19
-1
@@ -15,6 +15,12 @@ export function resolveSandboxMcpNodeExecPath(overridePath) {
|
||||
return process.execPath;
|
||||
}
|
||||
|
||||
export function resolveMindSearchMcpServerPath(overridePath) {
|
||||
const normalized = String(overridePath ?? '').trim();
|
||||
if (normalized) return normalized;
|
||||
return path.join(path.dirname(fileURLToPath(import.meta.url)), 'tkmind-search-mcp.mjs');
|
||||
}
|
||||
|
||||
export const CAPABILITY_CATALOG = [
|
||||
{
|
||||
key: 'shell',
|
||||
@@ -130,6 +136,13 @@ export const CAPABILITY_CATALOG = [
|
||||
risk: 'medium',
|
||||
category: 'knowledge',
|
||||
},
|
||||
{
|
||||
key: 'search_external',
|
||||
label: 'MindSearch 外部搜索增强',
|
||||
description: '可插拔的外部搜索补充能力;不替代现有网页搜索,默认关闭',
|
||||
risk: 'medium',
|
||||
category: 'knowledge',
|
||||
},
|
||||
{
|
||||
key: 'computer',
|
||||
label: '电脑控制',
|
||||
@@ -190,6 +203,7 @@ export const DEFAULT_USER_CAPABILITIES = Object.fromEntries(
|
||||
apps: false,
|
||||
todo: false,
|
||||
web: true,
|
||||
search_external: false,
|
||||
computer: false,
|
||||
charts: false,
|
||||
aider: false,
|
||||
@@ -323,7 +337,7 @@ function sandboxMcpEnvs(sandboxMcp, mcpTools) {
|
||||
*/
|
||||
export function buildAgentExtensionPolicy(
|
||||
capabilities,
|
||||
{ unrestricted = false, policies = null, sandboxMcp = null, toolMode = 'chat' } = {},
|
||||
{ unrestricted = false, policies = null, sandboxMcp = null, toolMode = 'chat', mindSearchConfig = null } = {},
|
||||
) {
|
||||
if (unrestricted) {
|
||||
return { extensionOverrides: null, enableContextMemory: true, gooseMode: 'auto' };
|
||||
@@ -416,6 +430,10 @@ export function buildAgentExtensionPolicy(
|
||||
if (capabilities.web) {
|
||||
extensions.push(makeExtension('platform', 'web', ['web_search', 'fetch_url']));
|
||||
}
|
||||
const searchEnabled = capabilities.search_external && mindSearchConfig?.enabled && mindSearchConfig.mode !== 'off';
|
||||
if (searchEnabled && (!policies || policies.network_egress !== 'deny')) {
|
||||
extensions.push({ type: 'stdio', name: 'tkmind-search', description: 'MindSearch 外部搜索增强(补充能力)', display_name: 'tkmind-search', bundled: false, cmd: resolveSandboxMcpNodeExecPath(process.env.GOOSED_MCP_NODE_PATH), args: [resolveMindSearchMcpServerPath(process.env.TKMIND_SEARCH_MCP_SERVER_PATH)], envs: { TKMIND_SEARCH_ENABLED: '1', TKMIND_SEARCH_MODE: mindSearchConfig.mode, TKMIND_SEARCH_PROVIDER_SEARXNG_ENABLED: mindSearchConfig.providers?.searxng ? '1' : '0', TKMIND_SEARCH_PROVIDER_GITHUB_ENABLED: mindSearchConfig.providers?.github ? '1' : '0', TKMIND_SEARCH_READER_ENABLED: mindSearchConfig.providers?.reader ? '1' : '0', TKMIND_SEARCH_SEARXNG_URL: mindSearchConfig.settings?.searxngEndpoint ?? '', TKMIND_SEARCH_MAX_RESULTS: String(mindSearchConfig.settings?.maxResults ?? 10), TKMIND_SEARCH_TIMEOUT_MS: String(mindSearchConfig.settings?.timeoutMs ?? 8000), TKMIND_SEARCH_READER_MAX_CHARS: String(mindSearchConfig.settings?.readerMaxChars ?? 12000) }, available_tools: ['tkmind_search', 'tkmind_read'] });
|
||||
}
|
||||
if (capabilities.computer) {
|
||||
extensions.push(makeExtension('builtin', 'computercontroller', []));
|
||||
}
|
||||
|
||||
@@ -101,6 +101,15 @@ export const CHAT_SKILL_DEFINITIONS = [
|
||||
prefillOnly: true,
|
||||
promptKey: 'search',
|
||||
},
|
||||
{
|
||||
id: 'enhanced-search',
|
||||
label: '外部增强搜索',
|
||||
icon: 'web',
|
||||
skillName: 'search-enhanced',
|
||||
requiresSkill: 'search-enhanced',
|
||||
prefillOnly: true,
|
||||
promptKey: 'search-enhanced',
|
||||
},
|
||||
{
|
||||
id: 'service-integration-smoke',
|
||||
label: '联调测试',
|
||||
@@ -175,6 +184,8 @@ export function buildChatSkillPrompt(promptKey, skillName) {
|
||||
return `请使用 ${skillName ?? 'web'} 技能:帮我搜索并查阅相关资料(优先官方文档),并给出中文摘要与来源链接。我的问题是:`;
|
||||
case 'search':
|
||||
return `请使用 ${skillName ?? 'search'} 技能:帮我在工作区中查找代码或文件。我要找的是:`;
|
||||
case 'search-enhanced':
|
||||
return `请使用 ${skillName ?? 'search-enhanced'} 技能:这是可选的外部搜索增强能力。优先调用 tkmind-search 的 tkmind_search / tkmind_read;按 web/news/code/read 选择 Provider,返回标题、摘要、URL、来源和引用。若 MindSearch 不可用,自动回退到现有 web/search 能力,不要让搜索失败阻断回答。我的问题是:`;
|
||||
case 'form-builder':
|
||||
return `请使用 ${skillName ?? 'form-builder'} 技能:请用交互式表单收集以下场景所需的结构化字段(字段不超过 8 个):`;
|
||||
case 'page-data-collect':
|
||||
|
||||
@@ -84,6 +84,12 @@ test('buildAutoChatSkillPrefix enables web for news-like queries', () => {
|
||||
assert.equal(buildAutoChatSkillPrefix('帮我搜索今天热点新闻', ['search']), '');
|
||||
});
|
||||
|
||||
test('manifest enhanced search route is opt-in and uses the MindSearch skill prompt', () => {
|
||||
const routes = [{ skillName: 'search-enhanced', promptKey: 'search-enhanced', keywords: ['最新资料'], priority: 30 }];
|
||||
assert.match(buildAutoChatSkillPrefix('请搜索最新资料:Goose MCP', ['search-enhanced'], { skillRouterV2: true, manifestRoutes: routes }), /tkmind-search/);
|
||||
assert.equal(buildAutoChatSkillPrefix('请搜索最新资料:Goose MCP', [], { skillRouterV2: true, manifestRoutes: routes }), '');
|
||||
});
|
||||
|
||||
test('buildAutoChatSkillPrefix enables publish for page generation requests', () => {
|
||||
const prefix = buildAutoChatSkillPrefix('帮我做一个 Hello 糖的 H5 页面', ['static-page-publish']);
|
||||
assert.match(prefix, /static-page-publish/);
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
const CONFIG_TABLE = 'h5_mindsearch_config';
|
||||
const CONFIG_SCOPE = 'global';
|
||||
|
||||
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 },
|
||||
});
|
||||
|
||||
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));
|
||||
};
|
||||
|
||||
export function normalizeMindSearchConfig(input = {}) {
|
||||
const mode = ['off', 'shadow', 'assist'].includes(input.mode) ? input.mode : 'off';
|
||||
const config = {
|
||||
enabled: bool(input.enabled),
|
||||
mode,
|
||||
providers: {
|
||||
searxng: bool(input.providers?.searxng),
|
||||
github: bool(input.providers?.github),
|
||||
reader: bool(input.providers?.reader),
|
||||
},
|
||||
settings: {
|
||||
searxngEndpoint: String(input.settings?.searxngEndpoint ?? '').trim(),
|
||||
maxResults: Math.max(1, Math.min(20, Number(input.settings?.maxResults ?? 10) || 10)),
|
||||
timeoutMs: Math.max(1000, Math.min(30000, Number(input.settings?.timeoutMs ?? 8000) || 8000)),
|
||||
readerMaxChars: Math.max(1000, Math.min(50000, Number(input.settings?.readerMaxChars ?? 12000) || 12000)),
|
||||
},
|
||||
};
|
||||
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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 } = {}) {
|
||||
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(),
|
||||
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 ?? {}) } });
|
||||
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, effective: config.enabled && config.mode !== 'off' };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { isSafeHttpUrl, normalizeSearchResult } from './search-capability.mjs';
|
||||
|
||||
export async function searchSearxng(query, { limit = 10, endpoint = process.env.TKMIND_SEARCH_SEARXNG_URL, fetchImpl = fetch } = {}) {
|
||||
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' } });
|
||||
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));
|
||||
}
|
||||
|
||||
export async function readSafeUrl(target, { fetchImpl = fetch } = {}) {
|
||||
if (!isSafeHttpUrl(target)) throw new Error('unsafe URL');
|
||||
const response = await fetchImpl(target, { signal: AbortSignal.timeout(8000), headers: { accept: 'text/html,text/plain' } });
|
||||
if (!response.ok) throw new Error(`reader returned ${response.status}`);
|
||||
return { url: target, content: (await response.text()).slice(0, Number(process.env.TKMIND_SEARCH_READER_MAX_CHARS ?? 12000)) };
|
||||
}
|
||||
|
||||
export async function searchGithubCode(query, { limit = 10, token = process.env.GITHUB_TOKEN, endpoint = process.env.TKMIND_SEARCH_GITHUB_URL || 'https://api.github.com/search/code', fetchImpl = fetch } = {}) {
|
||||
const url = new URL(endpoint);
|
||||
if (url.protocol !== 'https:' || url.hostname !== 'api.github.com') throw new Error('GitHub endpoint is not configured safely');
|
||||
url.searchParams.set('q', query);
|
||||
url.searchParams.set('per_page', String(Math.min(20, limit)));
|
||||
const headers = { accept: 'application/vnd.github+json', 'user-agent': 'tkmind-search' };
|
||||
if (token) headers.authorization = `Bearer ${token}`;
|
||||
const response = await fetchImpl(url, { headers, signal: AbortSignal.timeout(8000) });
|
||||
if (!response.ok) throw new Error(`GitHub returned ${response.status}`);
|
||||
const body = await response.json();
|
||||
return (Array.isArray(body.items) ? body.items : []).slice(0, limit).map((item, index) => normalizeSearchResult({ title: `${item.repository?.full_name ?? ''}:${item.path ?? item.name ?? ''}`, url: item.html_url, snippet: item.repository?.description ?? '', source: 'github' }, index));
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { buildCitations, isSafeHttpUrl, validateSearchRequest } from './search-capability.mjs';
|
||||
import { MINDSEARCH_DEFAULT_CONFIG, normalizeMindSearchConfig } from './mindsearch-config.mjs';
|
||||
import { buildAgentExtensionPolicy, DEFAULT_USER_CAPABILITIES } from './capabilities.mjs';
|
||||
import { searchGithubCode, searchSearxng } from './mindsearch-providers.mjs';
|
||||
|
||||
test('MindSearch defaults to disabled and normalizes unsafe modes', () => {
|
||||
assert.equal(MINDSEARCH_DEFAULT_CONFIG.enabled, false);
|
||||
assert.deepEqual(normalizeMindSearchConfig({ enabled: true, mode: 'invalid', providers: { github: true } }), { enabled: false, mode: 'off', providers: { searxng: false, github: true, reader: false }, settings: { searxngEndpoint: '', maxResults: 10, timeoutMs: 8000, readerMaxChars: 12000 } });
|
||||
});
|
||||
|
||||
test('search request validation and citations are deterministic', () => {
|
||||
assert.equal(validateSearchRequest({ query: '' }).code, 'INVALID_REQUEST');
|
||||
const checked = validateSearchRequest({ query: ' Goose ', limit: 999 });
|
||||
assert.deepEqual(checked.value, { query: 'Goose', type: 'web', limit: 20 });
|
||||
assert.deepEqual(buildCitations([{ title: 'A', url: 'https://example.com', source: 'test' }]), [{ id: '[1]', title: 'A', url: 'https://example.com', source: 'test' }]);
|
||||
assert.equal(isSafeHttpUrl('http://127.0.0.1:8081/private'), false);
|
||||
assert.equal(isSafeHttpUrl('https://example.com/a'), true);
|
||||
});
|
||||
|
||||
test('MindSearch never changes legacy web extension and is gated by capability/config', () => {
|
||||
const base = { ...DEFAULT_USER_CAPABILITIES, web: true, search_external: false };
|
||||
let policy = buildAgentExtensionPolicy(base, { mindSearchConfig: { enabled: true, mode: 'assist', providers: {} } });
|
||||
assert.ok(policy.extensionOverrides.some((ext) => ext.name === 'web'));
|
||||
assert.equal(policy.extensionOverrides.some((ext) => ext.name === 'tkmind-search'), false);
|
||||
policy = buildAgentExtensionPolicy({ ...base, search_external: true }, { mindSearchConfig: { enabled: true, mode: 'assist', providers: {} }, policies: { network_egress: 'deny' } });
|
||||
assert.equal(policy.extensionOverrides.some((ext) => ext.name === 'tkmind-search'), false);
|
||||
policy = buildAgentExtensionPolicy({ ...base, search_external: true }, { mindSearchConfig: { enabled: true, mode: 'assist', providers: {} } });
|
||||
assert.ok(policy.extensionOverrides.some((ext) => ext.name === 'tkmind-search'));
|
||||
});
|
||||
|
||||
test('SearXNG adapter normalizes provider results without requiring a live network', async () => {
|
||||
const result = await searchSearxng('goose', { endpoint: 'http://search.local', fetchImpl: async () => ({ ok: true, json: async () => ({ results: [{ title: 'Goose', url: 'https://example.com', content: 'snippet' }] }) }) });
|
||||
assert.deepEqual(result[0], { title: 'Goose', url: 'https://example.com', snippet: 'snippet', source: 'searxng', rank: 1 });
|
||||
});
|
||||
|
||||
test('GitHub code adapter sends bounded queries and normalizes results', async () => {
|
||||
let requested;
|
||||
const result = await searchGithubCode('repo:openai goose', { limit: 50, fetchImpl: async (url, options) => { requested = { url: String(url), options }; return { ok: true, json: async () => ({ items: [{ repository: { full_name: 'openai/goose', description: 'agent' }, path: 'src/goose.rs', html_url: 'https://github.com/openai/goose/blob/main/src/goose.rs' }] }) }; } });
|
||||
assert.match(requested.url, /per_page=20/);
|
||||
assert.equal(requested.options.headers.accept, 'application/vnd.github+json');
|
||||
assert.equal(result[0].source, 'github');
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
export const SEARCH_ERROR_CODES = Object.freeze({ CAPABILITY_DISABLED: 'CAPABILITY_DISABLED', INVALID_REQUEST: 'INVALID_REQUEST', PROVIDER_UNAVAILABLE: 'PROVIDER_UNAVAILABLE', UNSAFE_URL: 'UNSAFE_URL' });
|
||||
|
||||
export function validateSearchRequest(input = {}) {
|
||||
const query = String(input.query ?? '').trim();
|
||||
if (!query || query.length > 500) return { ok: false, code: SEARCH_ERROR_CODES.INVALID_REQUEST, message: 'query must be 1-500 characters' };
|
||||
const limit = Math.max(1, Math.min(20, Number(input.limit ?? 10) || 10));
|
||||
return { ok: true, value: { query, type: String(input.type ?? 'web'), limit } };
|
||||
}
|
||||
|
||||
export function normalizeSearchResult(result = {}, index = 0) {
|
||||
return { title: String(result.title ?? '').trim(), url: String(result.url ?? '').trim(), snippet: String(result.snippet ?? result.content ?? '').trim().slice(0, 2000), source: String(result.source ?? 'unknown'), rank: index + 1 };
|
||||
}
|
||||
|
||||
export function buildCitations(results = []) {
|
||||
return results.filter((item) => item.url).map((item, index) => ({ id: `[${index + 1}]`, title: item.title, url: item.url, source: item.source }));
|
||||
}
|
||||
|
||||
export function isSafeHttpUrl(value) {
|
||||
try { const url = new URL(value); return ['http:', 'https:'].includes(url.protocol) && !['localhost', '127.0.0.1', '::1'].includes(url.hostname); } catch { return false; }
|
||||
}
|
||||
@@ -45,6 +45,7 @@ import { createMindSpaceAuditWriter } from './mindspace-audit.mjs';
|
||||
import { assertMindSpaceRoute, mindspaceFlags } from './mindspace-flags.mjs';
|
||||
import { createMindSpaceService } from './mindspace.mjs';
|
||||
import { ensureMindSpaceConfig } from './mindspace-config.mjs';
|
||||
import { createMindSearchConfigService } from './mindsearch-config.mjs';
|
||||
import { pageInternals, inlinePrivateAssetsInHtml, normalizeWorkspaceRelativePath } from './mindspace-pages.mjs';
|
||||
import {
|
||||
createDownloadConversationPackageArtifactHandler,
|
||||
@@ -408,6 +409,8 @@ async function bootstrapUserAuth() {
|
||||
try {
|
||||
if (!isDatabaseConfigured()) return false;
|
||||
const pool = createDbPool();
|
||||
const mindSearchConfigService = createMindSearchConfigService(pool);
|
||||
await mindSearchConfigService.ensureSchema();
|
||||
authPool = pool;
|
||||
await initSchema(pool);
|
||||
await ensureMindSpaceConfig(pool, {
|
||||
@@ -542,6 +545,7 @@ async function bootstrapUserAuth() {
|
||||
h5Root: __dirname,
|
||||
defaultSignupBalanceCents: Number(process.env.H5_SIGNUP_BALANCE_CENTS ?? 500),
|
||||
subscriptionService,
|
||||
getMindSearchConfig: () => mindSearchConfigService.getEffectiveConfig(),
|
||||
provisionUserDataSpace: async ({ userId, workspaceRoot }) => {
|
||||
const service = createUserDataSpaceService({ workspaceRoot, userId, query: pool });
|
||||
return service.ensureReady();
|
||||
|
||||
@@ -15,6 +15,7 @@ export const PAGE_DATA_COLLECT_SKILL_NAME = 'page-data-collect';
|
||||
export const DEFAULT_USER_SKILLS = {
|
||||
web: true,
|
||||
search: true,
|
||||
'search-enhanced': false,
|
||||
'schedule-assistant': true,
|
||||
'service-integration-smoke': true,
|
||||
'form-builder': true,
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
---
|
||||
name: search-enhanced
|
||||
description: 可插拔外部搜索编排:优先使用 MindSearch,失败时回退现有 web/search 能力
|
||||
---
|
||||
|
||||
# 外部增强搜索
|
||||
|
||||
这是一个可选的搜索编排 Skill,不替代现有 `web` 或工作区 `search` Skill。
|
||||
|
||||
## 使用规则
|
||||
|
||||
1. 只有当前会话策略挂载了 `tkmind-search` 且用户拥有 `search_external` 能力时,才调用 `tkmind_search` 或 `tkmind_read`。
|
||||
2. `web` / `news` 使用 SearXNG,`code` 使用 GitHub Code,`read` 使用 Reader。
|
||||
3. 搜索结果必须保留标题、摘要、URL、Provider 来源和引用编号。
|
||||
4. Provider 超时、限流、未配置或返回错误时,立即回退现有 `web_search` / `fetch_url`;不要阻断回答,也不要声称已使用外部增强搜索。
|
||||
5. 外部结果只作为补充证据,不写入用户长期记忆,不改变会话上下文和原有内容生成策略。
|
||||
|
||||
## 推荐请求形状
|
||||
|
||||
```json
|
||||
{"query":"...","type":"web|news|code","limit":10}
|
||||
```
|
||||
|
||||
读取 URL 前必须确认是公开的 HTTP(S) 地址,禁止访问 localhost、内网地址、云元数据地址和未经用户允许的敏感页面。
|
||||
@@ -0,0 +1,17 @@
|
||||
name: search-enhanced
|
||||
description: 可插拔外部搜索编排:优先使用 MindSearch,失败时回退现有 web/search 能力
|
||||
version: 0.1.0
|
||||
executors:
|
||||
- goose
|
||||
trigger:
|
||||
keywords:
|
||||
- 最新资料
|
||||
- 实时搜索
|
||||
- 外部搜索
|
||||
- 搜索新闻
|
||||
- 搜索代码
|
||||
- search the web
|
||||
- latest information
|
||||
router:
|
||||
priority: 30
|
||||
promptKey: search-enhanced
|
||||
@@ -0,0 +1,47 @@
|
||||
import readline from 'node:readline';
|
||||
import { normalizeMindSearchConfig } from './mindsearch-config.mjs';
|
||||
import { SEARCH_ERROR_CODES, validateSearchRequest } from './search-capability.mjs';
|
||||
import { readSafeUrl, searchGithubCode, searchSearxng } from './mindsearch-providers.mjs';
|
||||
|
||||
const config = normalizeMindSearchConfig({
|
||||
enabled: process.env.TKMIND_SEARCH_ENABLED,
|
||||
mode: process.env.TKMIND_SEARCH_MODE || (/^(1|true|yes|on)$/i.test(process.env.TKMIND_SEARCH_ENABLED ?? '') ? 'assist' : 'off'),
|
||||
providers: {
|
||||
searxng: process.env.TKMIND_SEARCH_PROVIDER_SEARXNG_ENABLED,
|
||||
github: process.env.TKMIND_SEARCH_PROVIDER_GITHUB_ENABLED,
|
||||
reader: process.env.TKMIND_SEARCH_READER_ENABLED,
|
||||
},
|
||||
});
|
||||
|
||||
const tools = [
|
||||
{ name: 'tkmind_search', description: 'Optional external search enhancement; existing web search remains primary.', inputSchema: { type: 'object', properties: { query: { type: 'string' }, type: { type: 'string' }, limit: { type: 'number' } }, required: ['query'] } },
|
||||
{ name: 'tkmind_read', description: 'Optional safe URL reader for search citations.', inputSchema: { type: 'object', properties: { url: { type: 'string' } }, required: ['url'] } },
|
||||
];
|
||||
|
||||
function response(id, result) { process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', id, result })}\n`); }
|
||||
function error(id, code, message) { response(id, { isError: true, content: [{ type: 'text', text: `${code}: ${message}` }], structuredContent: { code, message } }); }
|
||||
function handle(message) {
|
||||
const { id, method, params = {} } = message;
|
||||
if (method === 'initialize') return response(id, { protocolVersion: '2024-11-05', capabilities: { tools: {} }, serverInfo: { name: 'tkmind-search', version: '0.1.0' } });
|
||||
if (method === 'notifications/initialized') return;
|
||||
if (method === 'tools/list') return response(id, { tools });
|
||||
if (method !== 'tools/call') return error(id, 'METHOD_NOT_FOUND', `unsupported method: ${method}`);
|
||||
if (!config.enabled || config.mode === 'off') return error(id, SEARCH_ERROR_CODES.CAPABILITY_DISABLED, 'MindSearch is disabled; existing search capabilities are unchanged.');
|
||||
const name = params.name;
|
||||
if (name === 'tkmind_search') {
|
||||
const checked = validateSearchRequest(params.arguments ?? {});
|
||||
if (!checked.ok) return error(id, checked.code, checked.message);
|
||||
if (checked.value.type === 'code' && config.providers.github) {
|
||||
return searchGithubCode(checked.value.query, { limit: checked.value.limit }).then((results) => response(id, { content: [{ type: 'text', text: JSON.stringify({ results, query: checked.value.query, provider: 'github' }) }], structuredContent: { results, query: checked.value.query, provider: 'github' } })).catch((err) => error(id, SEARCH_ERROR_CODES.PROVIDER_UNAVAILABLE, err.message));
|
||||
}
|
||||
if (config.providers.searxng) {
|
||||
return searchSearxng(checked.value.query, { limit: checked.value.limit }).then((results) => response(id, { content: [{ type: 'text', text: JSON.stringify({ results, query: checked.value.query, provider: 'searxng' }) }], structuredContent: { results, query: checked.value.query, provider: 'searxng' } })).catch((err) => error(id, SEARCH_ERROR_CODES.PROVIDER_UNAVAILABLE, err.message));
|
||||
}
|
||||
return error(id, SEARCH_ERROR_CODES.PROVIDER_UNAVAILABLE, 'No search provider is configured.');
|
||||
}
|
||||
if (name === 'tkmind_read' && config.providers.reader) return readSafeUrl(params.arguments?.url).then((result) => response(id, { content: [{ type: 'text', text: result.content }], structuredContent: result })).catch((err) => error(id, SEARCH_ERROR_CODES.PROVIDER_UNAVAILABLE, err.message));
|
||||
return error(id, SEARCH_ERROR_CODES.PROVIDER_UNAVAILABLE, 'No reader provider is configured.');
|
||||
}
|
||||
|
||||
const rl = readline.createInterface({ input: process.stdin });
|
||||
rl.on('line', (line) => { try { handle(JSON.parse(line)); } catch { error(null, SEARCH_ERROR_CODES.INVALID_REQUEST, 'invalid JSON-RPC request'); } });
|
||||
+4
-1
@@ -141,6 +141,7 @@ export function createUserAuth(pool, options = {}) {
|
||||
const provisionUserDataSpace = typeof options.provisionUserDataSpace === 'function'
|
||||
? options.provisionUserDataSpace
|
||||
: null;
|
||||
const getMindSearchConfig = typeof options.getMindSearchConfig === 'function' ? options.getMindSearchConfig : null;
|
||||
const sessions = new Map();
|
||||
const loginFailures = new Map();
|
||||
|
||||
@@ -1853,10 +1854,11 @@ export function createUserAuth(pool, options = {}) {
|
||||
if (!user) throw new Error('用户不存在');
|
||||
const capabilityState = await resolveUserCapabilities(user);
|
||||
const policyState = await resolveUserPolicies(user);
|
||||
const mindSearchConfig = getMindSearchConfig ? await getMindSearchConfig({ userId, user }) : null;
|
||||
await syncUserSkillsForUser(user);
|
||||
if (capabilityState.unrestricted) {
|
||||
return {
|
||||
...buildAgentExtensionPolicy(capabilityState.capabilities, { unrestricted: true, toolMode }),
|
||||
...buildAgentExtensionPolicy(capabilityState.capabilities, { unrestricted: true, toolMode, mindSearchConfig }),
|
||||
policies: {},
|
||||
unrestricted: true,
|
||||
toolMode,
|
||||
@@ -1900,6 +1902,7 @@ export function createUserAuth(pool, options = {}) {
|
||||
policies: policyState.policies,
|
||||
sandboxMcp,
|
||||
toolMode,
|
||||
mindSearchConfig,
|
||||
}),
|
||||
capabilities: effectiveCapabilities,
|
||||
policies: policyState.policies,
|
||||
|
||||
Reference in New Issue
Block a user