diff --git a/admin-bootstrap.mjs b/admin-bootstrap.mjs index feeb875..92e7739 100644 --- a/admin-bootstrap.mjs +++ b/admin-bootstrap.mjs @@ -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, diff --git a/admin-routes.mjs b/admin-routes.mjs index 853ae64..b77430f 100644 --- a/admin-routes.mjs +++ b/admin-routes.mjs @@ -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 配置服务未启用' }); diff --git a/admin-routes.test.mjs b/admin-routes.test.mjs index d742f43..72154e0 100644 --- a/admin-routes.test.mjs +++ b/admin-routes.test.mjs @@ -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({ diff --git a/admin-server.mjs b/admin-server.mjs index cf5aacf..899769e 100644 --- a/admin-server.mjs +++ b/admin-server.mjs @@ -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, diff --git a/capabilities.mjs b/capabilities.mjs index d400b2e..15dd348 100644 --- a/capabilities.mjs +++ b/capabilities.mjs @@ -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', [])); } diff --git a/chat-skills.mjs b/chat-skills.mjs index 4fa3bd3..eed4cfb 100644 --- a/chat-skills.mjs +++ b/chat-skills.mjs @@ -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': diff --git a/chat-skills.test.mjs b/chat-skills.test.mjs index 8dfe9ca..5f4cb60 100644 --- a/chat-skills.test.mjs +++ b/chat-skills.test.mjs @@ -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/); diff --git a/mindsearch-config.mjs b/mindsearch-config.mjs new file mode 100644 index 0000000..3585043 --- /dev/null +++ b/mindsearch-config.mjs @@ -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' }; + }, + }; +} diff --git a/mindsearch-providers.mjs b/mindsearch-providers.mjs new file mode 100644 index 0000000..3ef66b3 --- /dev/null +++ b/mindsearch-providers.mjs @@ -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)); +} diff --git a/mindsearch.test.mjs b/mindsearch.test.mjs new file mode 100644 index 0000000..18dcae1 --- /dev/null +++ b/mindsearch.test.mjs @@ -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'); +}); diff --git a/search-capability.mjs b/search-capability.mjs new file mode 100644 index 0000000..1f5988c --- /dev/null +++ b/search-capability.mjs @@ -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; } +} diff --git a/server.mjs b/server.mjs index 8a0d12d..fe90c47 100644 --- a/server.mjs +++ b/server.mjs @@ -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(); diff --git a/skills-registry.mjs b/skills-registry.mjs index 156c3b1..a6d3d2a 100644 --- a/skills-registry.mjs +++ b/skills-registry.mjs @@ -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, diff --git a/skills/search-enhanced/SKILL.md b/skills/search-enhanced/SKILL.md new file mode 100644 index 0000000..52e1eb3 --- /dev/null +++ b/skills/search-enhanced/SKILL.md @@ -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、内网地址、云元数据地址和未经用户允许的敏感页面。 diff --git a/skills/search-enhanced/skill.yaml b/skills/search-enhanced/skill.yaml new file mode 100644 index 0000000..65538c9 --- /dev/null +++ b/skills/search-enhanced/skill.yaml @@ -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 diff --git a/tkmind-search-mcp.mjs b/tkmind-search-mcp.mjs new file mode 100644 index 0000000..4c01b16 --- /dev/null +++ b/tkmind-search-mcp.mjs @@ -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'); } }); diff --git a/user-auth.mjs b/user-auth.mjs index 8874de8..b3d5430 100644 --- a/user-auth.mjs +++ b/user-auth.mjs @@ -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,