feat: add optional MindSearch skill orchestration

This commit is contained in:
john
2026-07-15 13:46:17 +08:00
parent 87b03f7848
commit 5f1c4dcaca
17 changed files with 373 additions and 2 deletions
+4
View File
@@ -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,
+16
View File
@@ -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 配置服务未启用' });
+25
View File
@@ -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({
+1
View File
@@ -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
View File
@@ -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', []));
}
+11
View File
@@ -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':
+6
View File
@@ -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/);
+99
View File
@@ -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' };
},
};
}
+31
View File
@@ -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));
}
+44
View File
@@ -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');
});
+20
View File
@@ -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; }
}
+4
View File
@@ -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();
+1
View File
@@ -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,
+24
View File
@@ -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、内网地址、云元数据地址和未经用户允许的敏感页面。
+17
View File
@@ -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
+47
View File
@@ -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
View File
@@ -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,