From b9599da1c8aaf0ed62665171e26881d1012ef670 Mon Sep 17 00:00:00 2001 From: digua Date: Wed, 17 Jun 2026 21:28:46 +0800 Subject: [PATCH] feat(semantic-index): expose semantic search via routes, agent tool and pipeline Add shared /_web/ai/semantic-index HTTP routes and wire the service into CLI web and Electron internal API. Introduce the semantic_search_current_chat agent tool (AGENT-only, not MCP) with desensitized output and a narrowed service interface, forward the semantic-search context through the CLI and Desktop tool adapters, and integrate on-demand semantic search into the agent pipeline with guidance and a max-result-token evidence budget. Co-authored-by: Cursor --- apps/cli/src/ai/agent-stream-runner.ts | 32 ++++- apps/cli/src/ai/tool-adapter.ts | 12 +- apps/cli/src/http/index.ts | 23 ++- apps/cli/src/http/routes/web/index.ts | 30 +++- apps/desktop/main/ai/agent-stream-runner.ts | 23 ++- apps/desktop/main/ai/logger.ts | 4 - apps/desktop/main/ai/tools/index.ts | 11 +- .../main/ai/tools/shared-tool-adapter.ts | 8 +- apps/desktop/main/ai/tools/types.ts | 3 + apps/desktop/main/internal-api.ts | 36 ++++- packages/http-routes/src/context.ts | 4 + packages/http-routes/src/register.ts | 2 + .../src/routes/shared-routes.test.ts | 1 + .../src/routes/web/ai-semantic-index.test.ts | 133 +++++++++++++++++ .../src/routes/web/ai-semantic-index.ts | 73 ++++++++++ .../http-routes/src/routes/web/cache.test.ts | 1 + packages/node-runtime/src/ai/agent/index.ts | 1 + .../src/ai/agent/semantic-search-guidance.ts | 24 ++++ .../adapters/database-manager-adapter.test.ts | 1 + .../semantic-search-current-chat.test.ts | 136 ++++++++++++++++++ .../semantic-search-current-chat.ts | 92 ++++++++++++ packages/tools/src/index.ts | 9 +- packages/tools/src/registry.test.ts | 18 +++ packages/tools/src/registry.ts | 13 +- packages/tools/src/types.ts | 71 +++++++++ 25 files changed, 741 insertions(+), 20 deletions(-) create mode 100644 packages/http-routes/src/routes/web/ai-semantic-index.test.ts create mode 100644 packages/http-routes/src/routes/web/ai-semantic-index.ts create mode 100644 packages/node-runtime/src/ai/agent/semantic-search-guidance.ts create mode 100644 packages/tools/src/definitions/semantic-search-current-chat.test.ts create mode 100644 packages/tools/src/definitions/semantic-search-current-chat.ts create mode 100644 packages/tools/src/registry.test.ts diff --git a/apps/cli/src/ai/agent-stream-runner.ts b/apps/cli/src/ai/agent-stream-runner.ts index 7f2e3c72..2b7195b4 100644 --- a/apps/cli/src/ai/agent-stream-runner.ts +++ b/apps/cli/src/ai/agent-stream-runner.ts @@ -3,7 +3,7 @@ * for the shared HTTP route context. */ -import type { DatabaseManager, AIChatManager, AgentStreamChunk } from '@openchatlab/node-runtime' +import type { DatabaseManager, AIChatManager, AgentStreamChunk, SemanticIndexService } from '@openchatlab/node-runtime' import { CHART_CAPABILITY_CORE_TOOLS, SkillManager, @@ -19,7 +19,8 @@ import { getChatOverview, normalizeBuiltinToolNames } from '@openchatlab/core' import type { ChartAutoMode } from '@openchatlab/shared-types' import type { DataSnapshot } from '@openchatlab/node-runtime' import type { AgentStreamRequest } from '@openchatlab/http-routes' -import { AGENT_TOOL_REGISTRY } from '@openchatlab/tools' +import { AGENT_TOOL_REGISTRY, SEMANTIC_SEARCH_TOOL_NAME } from '@openchatlab/tools' +import { buildSemanticSearchGuidance } from '@openchatlab/node-runtime' import { adaptToolsForAgent } from './tool-adapter' import { getDefaultAssistantConfig, buildPiModel } from './llm-config' import { loadAssistantConfig } from './assistant-loader' @@ -63,7 +64,8 @@ export function getAllowedToolSet( export function createCliRunAgentStream( dbManager: DatabaseManager, - aiChatManager: AIChatManager + aiChatManager: AIChatManager, + semanticIndexService?: SemanticIndexService ): (params: AgentStreamRequest, onEvent: (chunk: AgentStreamChunk) => void, abortSignal: AbortSignal) => Promise { return async (params, onEvent, abortSignal) => { const { @@ -121,10 +123,32 @@ export function createCliRunAgentStream( ) const availableToolDefs = getAvailableToolDefs(isChartCapability, allowedToolSet) + // 语义检索按需暴露:仅当前会话可检索时保留工具,否则从工具集中过滤掉以减少 schema token 与无效调用。 + const canSemanticSearch = !!db && !!semanticIndexService && semanticIndexService.canSearch(sessionId) + const filteredToolDefs = canSemanticSearch + ? availableToolDefs + : availableToolDefs.filter((tool) => tool.name !== SEMANTIC_SEARCH_TOOL_NAME) + const agentTools = db - ? adaptToolsForAgent(availableToolDefs, () => ({ db, sessionId, locale }), { maxToolResultTokens }) + ? adaptToolsForAgent( + filteredToolDefs, + () => ({ + db, + sessionId, + locale, + semanticIndexService, + preprocessConfig: params.preprocessConfig, + ownerPlatformId: ownerInfo?.platformId, + }), + { maxToolResultTokens } + ) : [] + // 工具可用时向 system prompt 加简短引导,提示何时调用语义检索。 + if (canSemanticSearch) { + assistantSystemPrompt = [assistantSystemPrompt, buildSemanticSearchGuidance(locale)].filter(Boolean).join('\n\n') + } + const skillMgr = new SkillManager(aiDataDir) skillMgr.init() const toolNames = agentTools.map((t: { name: string }) => t.name) diff --git a/apps/cli/src/ai/tool-adapter.ts b/apps/cli/src/ai/tool-adapter.ts index 17636268..0bca2da7 100644 --- a/apps/cli/src/ai/tool-adapter.ts +++ b/apps/cli/src/ai/tool-adapter.ts @@ -5,7 +5,7 @@ * 消息类工具返回 rawMessages 时自动执行预处理管道(清洗、去噪、脱敏、截断、格式化)。 */ -import type { ToolDefinition, ToolExecutionContext } from '@openchatlab/tools' +import type { ToolDefinition, ToolExecutionContext, SemanticSearchToolService } from '@openchatlab/tools' import { CoreDataProvider } from '@openchatlab/tools' import type { DatabaseAdapter } from '@openchatlab/core' import { @@ -35,6 +35,12 @@ export interface ServerToolContext { db: DatabaseAdapter sessionId: string locale?: string + /** 语义检索窄接口(仅当前会话可检索时由 runner 注入) */ + semanticIndexService?: SemanticSearchToolService + /** 预处理配置(脱敏/匿名化/清洗) */ + preprocessConfig?: Record + /** 当前用户平台 id(昵称匿名化 owner 识别) */ + ownerPlatformId?: string } function convertJsonSchemaToParameters(schema: ToolDefinition['inputSchema']) { @@ -76,6 +82,10 @@ export function adaptToolsForAgent( dataProvider: new CoreDataProvider(ctx.db), sessionId: ctx.sessionId, locale: ctx.locale, + semanticIndexService: ctx.semanticIndexService, + preprocessConfig: ctx.preprocessConfig, + ownerPlatformId: ctx.ownerPlatformId, + maxToolResultTokens: tokenBudget, segmentText: (texts, locale, options) => batchSegmentWithFrequency(texts, locale as any, options as any), } try { diff --git a/apps/cli/src/http/index.ts b/apps/cli/src/http/index.ts index 6acc76a1..834077bd 100644 --- a/apps/cli/src/http/index.ts +++ b/apps/cli/src/http/index.ts @@ -20,8 +20,10 @@ import { applyPendingNodeDataDirMigrationIfNeeded, hasPendingElectronDataWarning, verifyCliDataPath, + createSemanticIndexService, + createDatabaseManagerAdapter, } from '@openchatlab/node-runtime' -import type { ConfigStorage } from '@openchatlab/node-runtime' +import type { ConfigStorage, SemanticIndexService } from '@openchatlab/node-runtime' import { createServer } from './server' import { setAuthToken, setRequireAuth } from '@openchatlab/http-routes' import { registerWebRoutes } from './routes/web' @@ -177,9 +179,26 @@ export async function startHttpServer(options?: HttpServerOptions): Promise<{ limits: { fileSize: 1024 * 1024 * 1024 }, // 1GB }) + // 语义索引 service:每进程一个共享实例,注入 runAgentStream 与 Web 路由,生命周期与 server 一致。 + // 构建失败(如 sqlite-vec 原生扩展缺失)不应拖垮整个 server,下游会优雅跳过。 + let semanticIndexService: SemanticIndexService | undefined + try { + semanticIndexService = createSemanticIndexService({ + pathProvider, + sessionAdapter: createDatabaseManagerAdapter(dbManager), + nativeBinding, + }) + semanticIndexService.recover() + server.addHook('onClose', async () => semanticIndexService?.close()) + } catch (err) { + console.warn('[semantic-index] service unavailable:', err instanceof Error ? err.message : String(err)) + semanticIndexService = undefined + } + registerWebRoutes(server, dbManager, { pathProvider, nativeBinding, + semanticIndexService, aiContext: { aiDataDir, aiChatManager, @@ -188,7 +207,7 @@ export async function startHttpServer(options?: HttpServerOptions): Promise<{ llmConfigStore, customProviderStore: new CustomProviderStore(createFileConfigStorage(aiDataDir)), customModelStore: new CustomModelStore(createFileConfigStorage(aiDataDir)), - runAgentStream: createCliRunAgentStream(dbManager, aiChatManager), + runAgentStream: createCliRunAgentStream(dbManager, aiChatManager, semanticIndexService), }, }) diff --git a/apps/cli/src/http/routes/web/index.ts b/apps/cli/src/http/routes/web/index.ts index f65a933b..c751bbd9 100644 --- a/apps/cli/src/http/routes/web/index.ts +++ b/apps/cli/src/http/routes/web/index.ts @@ -36,6 +36,8 @@ import { createNodeDataDirSwitch, getDefaultNodeUserDataDir, getPendingNodeDataDirMigration, + createSemanticIndexService, + type SemanticIndexService, } from '@openchatlab/node-runtime' import { registerSharedRoutes } from '@openchatlab/http-routes' import type { HttpRouteContext } from '@openchatlab/http-routes' @@ -59,7 +61,13 @@ export interface AiContextOptions { export function registerWebRoutes( server: FastifyInstance, dbManager: DatabaseManager, - options?: { pathProvider?: PathProvider; nativeBinding?: string; aiContext?: AiContextOptions } + options?: { + pathProvider?: PathProvider + nativeBinding?: string + aiContext?: AiContextOptions + /** 由 server 入口注入的共享语义索引实例;传入时由调用方管理生命周期 */ + semanticIndexService?: SemanticIndexService + } ): void { const adapter = createDatabaseManagerAdapter(dbManager) @@ -72,6 +80,7 @@ export function registerWebRoutes( getSystemDir: () => path.join(os.homedir(), '.chatlab'), getUserDataDir: () => path.join(os.homedir(), '.chatlab', 'data'), getDatabaseDir: () => path.join(os.homedir(), '.chatlab', 'data', 'databases'), + getVectorDir: () => path.join(os.homedir(), '.chatlab', 'data', 'vector'), getAiDataDir: () => path.join(os.homedir(), '.chatlab', 'ai'), getSettingsDir: () => path.join(os.homedir(), '.chatlab', 'settings'), getCacheDir: () => path.join(os.homedir(), '.chatlab', 'cache'), @@ -93,6 +102,24 @@ export function registerWebRoutes( const defaultUserDataDir = getDefaultNodeUserDataDir() const isCustom = path.resolve(resolvedPathProvider.getUserDataDir()) !== path.resolve(defaultUserDataDir) + // 语义索引 service:优先使用 server 入口注入的共享实例(与 runAgentStream 同一个)。 + // 未注入时(如测试/旧调用方)就地创建并自管生命周期;构建失败不应拖垮 server,路由会优雅跳过。 + let semanticIndexService = options?.semanticIndexService + if (!semanticIndexService) { + try { + semanticIndexService = createSemanticIndexService({ + pathProvider: resolvedPathProvider, + sessionAdapter: adapter, + nativeBinding: options?.nativeBinding, + }) + semanticIndexService.recover() + server.addHook('onClose', async () => semanticIndexService?.close()) + } catch (err) { + console.warn('[semantic-index] service unavailable:', err instanceof Error ? err.message : String(err)) + semanticIndexService = undefined + } + } + registerSharedRoutes( server, { @@ -101,6 +128,7 @@ export function registerWebRoutes( pathProvider: resolvedPathProvider, getVersion, nativeBinding: options?.nativeBinding, + semanticIndexService, openDirectory: openDirectoryPath, showInFolder: showPathInFolder, downloadsDir: resolvedPathProvider.getDownloadsDir(), diff --git a/apps/desktop/main/ai/agent-stream-runner.ts b/apps/desktop/main/ai/agent-stream-runner.ts index 91489472..fde7653b 100644 --- a/apps/desktop/main/ai/agent-stream-runner.ts +++ b/apps/desktop/main/ai/agent-stream-runner.ts @@ -6,7 +6,7 @@ * but outputs events via callback instead of IPC. */ -import type { AgentStreamChunk as SharedAgentStreamChunk } from '@openchatlab/node-runtime' +import type { AgentStreamChunk as SharedAgentStreamChunk, SemanticIndexService } from '@openchatlab/node-runtime' import type { AgentStreamRequest } from '@openchatlab/http-routes' import type { ChartAutoMode } from '@openchatlab/shared-types' import { @@ -20,6 +20,7 @@ import { getChartCapabilitySkill, initTokenizer, resolveChartRuntimeForRequest, + buildSemanticSearchGuidance, } from '@openchatlab/node-runtime' import type { CompressionConfig, CompressionLlmAdapter, AgentRuntimeStatus } from '@openchatlab/node-runtime' import { Agent, type AgentStreamChunk, type SkillContext } from './agent' @@ -61,7 +62,9 @@ const compressionLogger = { error: (cat: string, msg: string, extra?: Record) => aiLogger.error(cat, msg, extra), } -export function createElectronRunAgentStream(): ( +export function createElectronRunAgentStream( + semanticIndexService?: SemanticIndexService +): ( params: AgentStreamRequest, onEvent: (chunk: SharedAgentStreamChunk) => void, abortSignal: AbortSignal @@ -220,6 +223,21 @@ export function createElectronRunAgentStream(): ( } } + // 语义检索按需暴露:工具可用时向 system prompt 加简短引导(实际检索由 LLM 调用工具触发)。 + const canSemanticSearch = !!semanticIndexService && semanticIndexService.canSearch(sessionId) + if (canSemanticSearch) { + const baseSystemPrompt = assistantConfig?.systemPrompt ?? '' + assistantConfig = { + ...(assistantConfig ?? { + id: resolvedAssistantId, + name: resolvedAssistantId, + systemPrompt: '', + presetQuestions: [], + }), + systemPrompt: [baseSystemPrompt, buildSemanticSearchGuidance(locale)].filter(Boolean).join('\n\n'), + } + } + const maxToolResultPercent = compressionConfig?.maxToolResultPercent ?? 50 const modelDef = findModelDefinition(activeAIConfig.provider, activeAIConfig.model || '') const resolvedContextWindow = modelDef?.contextWindow || DEFAULT_CONTEXT_WINDOW @@ -241,6 +259,7 @@ export function createElectronRunAgentStream(): ( ownerInfo, mentionedMembers: mentionedMembers as ToolContext['mentionedMembers'], preprocessConfig: params.preprocessConfig as ToolContext['preprocessConfig'], + semanticIndexService, maxToolResultTokens, dataSnapshot, abortSignal, diff --git a/apps/desktop/main/ai/logger.ts b/apps/desktop/main/ai/logger.ts index 11eb8d0c..a3d6a495 100644 --- a/apps/desktop/main/ai/logger.ts +++ b/apps/desktop/main/ai/logger.ts @@ -45,7 +45,3 @@ export function logLLM(message: string, data?: unknown) { export function logSearch(message: string, data?: unknown) { aiLogger.info('Search', message, data) } - -export function logRAG(message: string, data?: unknown) { - aiLogger.info('RAG', message, data) -} diff --git a/apps/desktop/main/ai/tools/index.ts b/apps/desktop/main/ai/tools/index.ts index 74774e25..986d82a1 100644 --- a/apps/desktop/main/ai/tools/index.ts +++ b/apps/desktop/main/ai/tools/index.ts @@ -19,6 +19,11 @@ import { getSkillConfigWithBuiltinChart, wrapWithChartSchemaGate, } from '@openchatlab/node-runtime' +import { semanticSearchCurrentChatTool } from '@openchatlab/tools' +import { adaptSharedTool } from './shared-tool-adapter' + +// 语义检索工具按需暴露:仅当前会话可检索时追加,不进 TOOL_REGISTRY(避免被当作始终加载的 core 工具) +const semanticSearchEntry = adaptSharedTool(semanticSearchCurrentChatTool, { category: 'core' }) const CORE_TOOL_NAMES = new Set(TOOL_REGISTRY.filter((e) => e.category === 'core').map((e) => e.name)) @@ -119,9 +124,13 @@ export function getAllTools(context: ToolContext, allowedTools?: string[]): Agen (e) => e.category === 'analysis' && isAnalysisToolAllowed(e.name, allowedTools) ).map((e) => e.factory(context)) + const semanticTools = context.semanticIndexService?.canSearch(context.sessionId) + ? [semanticSearchEntry.factory(context)] + : [] + const chartSchemaGateState = createChartSchemaGateState() - return [...coreTools, ...analysisTools] + return [...coreTools, ...analysisTools, ...semanticTools] .map(translateTool) .map((t) => wrapWithChartSchemaGate(t, chartSchemaGateState)) .map((t) => wrapWithPreprocessing(t, context)) diff --git a/apps/desktop/main/ai/tools/shared-tool-adapter.ts b/apps/desktop/main/ai/tools/shared-tool-adapter.ts index 28caf4cd..d8b4f88c 100644 --- a/apps/desktop/main/ai/tools/shared-tool-adapter.ts +++ b/apps/desktop/main/ai/tools/shared-tool-adapter.ts @@ -27,6 +27,10 @@ function buildExecutionContext(ctx: ToolContext): ToolExecutionContext { searchContextBefore: ctx.searchContextBefore, searchContextAfter: ctx.searchContextAfter, maxMessagesLimit: ctx.maxMessagesLimit, + maxToolResultTokens: ctx.maxToolResultTokens, + semanticIndexService: ctx.semanticIndexService, + preprocessConfig: ctx.preprocessConfig as Record | undefined, + ownerPlatformId: ctx.ownerInfo?.platformId, segmentText: (texts, locale, options) => batchSegmentWithFrequency(texts, locale as any, options as any), translateTemplate: (key: string) => { const translated = i18nT(key) @@ -50,7 +54,9 @@ export function adaptSharedTool(tool: ToolDefinition, options: AdaptOptions): To return { name: tool.name, label: tool.name, - description: `ai.tools.${tool.name}.desc`, + // 保留英文原始描述作为 fallback:translateTool 会在 i18n key 命中时覆盖为译文, + // 缺 key 时回退到此英文描述,避免把裸 i18n key 当作工具描述传给 LLM。 + description: tool.description, parameters: schema as any, async execute(_toolCallId: string, params: unknown): Promise> { const toolParams = (params && typeof params === 'object' ? params : {}) as Record diff --git a/apps/desktop/main/ai/tools/types.ts b/apps/desktop/main/ai/tools/types.ts index 339b9941..d8dd3204 100644 --- a/apps/desktop/main/ai/tools/types.ts +++ b/apps/desktop/main/ai/tools/types.ts @@ -5,6 +5,7 @@ import type { AgentTool } from '@openchatlab/node-runtime' import type { PreprocessConfig } from '@openchatlab/node-runtime' import type { DataSnapshot } from '@openchatlab/node-runtime' +import type { SemanticSearchToolService } from '@openchatlab/tools' export type ToolCategory = 'core' | 'analysis' @@ -62,6 +63,8 @@ export interface ToolContext { locale?: string /** 聊天记录预处理配置(全局) */ preprocessConfig?: PreprocessConfig + /** 语义检索窄接口(仅当前会话可检索时由 runner 注入) */ + semanticIndexService?: SemanticSearchToolService abortSignal?: AbortSignal /** 搜索结果上下文:向前取多少条(默认 3) */ searchContextBefore?: number diff --git a/apps/desktop/main/internal-api.ts b/apps/desktop/main/internal-api.ts index 47be414e..1801bf28 100644 --- a/apps/desktop/main/internal-api.ts +++ b/apps/desktop/main/internal-api.ts @@ -23,8 +23,9 @@ import { MergeSessionCache, raiseChatDbCompatibilityGate, streamingImport, + createSemanticIndexService, } from '@openchatlab/node-runtime' -import type { StreamImportDeps } from '@openchatlab/node-runtime' +import type { StreamImportDeps, SemanticIndexService } from '@openchatlab/node-runtime' import multipart from '@fastify/multipart' import type { ConfigStorage } from '@openchatlab/node-runtime' import { @@ -53,6 +54,7 @@ let server: FastifyInstance | null = null let endpoint: InternalEndpoint | null = null let dbManager: DatabaseManager | null = null let mergeCache: MergeSessionCache | null = null +let semanticIndexService: SemanticIndexService | null = null const JSON_BODY_LIMIT = 50 * 1024 * 1024 // 50 MB @@ -81,6 +83,7 @@ export async function startInternalServer(pathProvider: PathProvider): Promise { const deps: StreamImportDeps = { openDatabase(sessionId: string) { @@ -145,6 +158,7 @@ export async function startInternalServer(pathProvider: PathProvider): Promise shell.openPath(dirPath).then(() => {}), showInFolder: (filePath) => { shell.showItemInFolder(filePath) @@ -153,7 +167,7 @@ export async function startInternalServer(pathProvider: PathProvider): Promise { if (!server) return try { @@ -280,10 +306,16 @@ export async function stopInternalServer(): Promise { } catch { /* best-effort */ } + try { + semanticIndexService?.close() + } catch { + /* best-effort */ + } server = null endpoint = null dbManager = null mergeCache = null + semanticIndexService = null console.log('[InternalAPI] Server stopped') } } diff --git a/packages/http-routes/src/context.ts b/packages/http-routes/src/context.ts index 25f6ad88..6950558a 100644 --- a/packages/http-routes/src/context.ts +++ b/packages/http-routes/src/context.ts @@ -22,6 +22,7 @@ import type { CustomModelStore, MergeSessionCache, AgentStreamChunk, + SemanticIndexService, } from '@openchatlab/node-runtime' export interface HttpRouteContext { @@ -53,6 +54,9 @@ export interface HttpRouteContext { customProviderStore?: CustomProviderStore customModelStore?: CustomModelStore + /** 语义索引共享 service — 可选,路由在缺失时优雅跳过 */ + semanticIndexService?: SemanticIndexService + /** Cache/storage — platform-specific (optional) */ openDirectory?: (dirPath: string) => Promise showInFolder?: (filePath: string) => Promise diff --git a/packages/http-routes/src/register.ts b/packages/http-routes/src/register.ts index 5020f5d3..ad2c187e 100644 --- a/packages/http-routes/src/register.ts +++ b/packages/http-routes/src/register.ts @@ -26,6 +26,7 @@ import { registerAiAgentStreamRoutes } from './routes/web/ai-agent-stream' import { registerAiToolRoutes } from './routes/web/ai-tools' import { registerAiChatRoutes } from './routes/web/ai-chats' import { registerAiSummaryRoutes } from './routes/web/ai-summaries' +import { registerSemanticIndexRoutes } from './routes/web/ai-semantic-index' import { registerMergeRoutes } from './routes/web/merge' import { registerCacheRoutes } from './routes/web/cache' @@ -82,6 +83,7 @@ export function registerSharedRoutes( registerAiToolRoutes(server, resolvedCtx) registerAiChatRoutes(server, resolvedCtx) registerAiSummaryRoutes(server, resolvedCtx) + registerSemanticIndexRoutes(server, resolvedCtx) // Merge routes (graceful skip when mergeSessionCache is absent) registerMergeRoutes(server, resolvedCtx) diff --git a/packages/http-routes/src/routes/shared-routes.test.ts b/packages/http-routes/src/routes/shared-routes.test.ts index fe843727..ababafed 100644 --- a/packages/http-routes/src/routes/shared-routes.test.ts +++ b/packages/http-routes/src/routes/shared-routes.test.ts @@ -120,6 +120,7 @@ function createTestContext(dbs: Map = new Map()): HttpR getSystemDir: () => '/tmp/chatlab-test', getUserDataDir: () => '/tmp/chatlab-test/data', getDatabaseDir: () => '/tmp/chatlab-test/databases', + getVectorDir: () => '/tmp/chatlab-test/vector', getAiDataDir: () => '/tmp/chatlab-test/ai', getSettingsDir: () => '/tmp/chatlab-test/settings', getCacheDir: () => '/tmp/chatlab-test/cache', diff --git a/packages/http-routes/src/routes/web/ai-semantic-index.test.ts b/packages/http-routes/src/routes/web/ai-semantic-index.test.ts new file mode 100644 index 00000000..9be4b259 --- /dev/null +++ b/packages/http-routes/src/routes/web/ai-semantic-index.test.ts @@ -0,0 +1,133 @@ +import { describe, it, before, after } from 'node:test' +import assert from 'node:assert/strict' +import Fastify, { type FastifyInstance } from 'fastify' +import type { SemanticIndexService } from '@openchatlab/node-runtime' +import type { HttpRouteContext } from '../../context' +import { registerSemanticIndexRoutes } from './ai-semantic-index' + +type Call = [string, ...unknown[]] + +function makeFakeService(calls: Call[]): SemanticIndexService { + const status = (sessionId: string) => ({ + sessionId, + enabled: true, + indexStatus: 'completed' as const, + needsRebuild: false, + totalMessages: 10, + indexedMessages: 10, + chunkCount: 3, + coverage: 1, + queued: false, + running: false, + partial: false, + error: null, + modelId: 'm', + }) + return { + getConfig: () => ({ version: 1, mode: 'local', local: { modelId: 'm' }, api: null }), + hasApiKey: () => false, + setConfig: (c: unknown, opts?: unknown) => { + calls.push(['setConfig', c, opts]) + return c + }, + listEnabledStatuses: () => [status('s1')], + status, + statusForSessions: (ids: string[]) => { + calls.push(['statusForSessions', ids]) + return ids.map(status) + }, + enable: (id: string) => calls.push(['enable', id]), + disable: (id: string) => calls.push(['disable', id]), + build: (id: string) => calls.push(['build', id]), + pause: (id: string) => calls.push(['pause', id]), + cancel: (id: string) => calls.push(['cancel', id]), + rebuild: (id: string) => calls.push(['rebuild', id]), + buildAllPending: () => calls.push(['buildAllPending']), + cleanupUnused: () => ({ cleaned: 2 }), + search: async (id: string, query: string) => { + calls.push(['search', id, query]) + return { available: true, blocks: [], coverage: 1, partial: false } + }, + } as unknown as SemanticIndexService +} + +describe('semantic-index routes', () => { + let app: FastifyInstance + const calls: Call[] = [] + + before(async () => { + app = Fastify() + const ctx = { semanticIndexService: makeFakeService(calls) } as unknown as HttpRouteContext + registerSemanticIndexRoutes(app, ctx) + await app.ready() + }) + + after(async () => { + await app.close() + }) + + it('GET config returns current config', async () => { + const resp = await app.inject({ method: 'GET', url: '/_web/ai/semantic-index/config' }) + assert.equal(resp.statusCode, 200) + assert.equal(resp.json().config.mode, 'local') + }) + + it('PUT config forwards body to setConfig', async () => { + const config = { version: 1, mode: 'api', local: { modelId: 'm' }, api: { baseUrl: 'b', model: 'x' } } + const resp = await app.inject({ method: 'PUT', url: '/_web/ai/semantic-index/config', payload: { config } }) + assert.equal(resp.statusCode, 200) + assert.ok(calls.some((c) => c[0] === 'setConfig')) + }) + + it('POST enable forwards sessionId and returns status', async () => { + const resp = await app.inject({ + method: 'POST', + url: '/_web/ai/semantic-index/enable', + payload: { sessionId: 'sess-9' }, + }) + assert.equal(resp.statusCode, 200) + assert.equal(resp.json().status.sessionId, 'sess-9') + assert.ok(calls.some((c) => c[0] === 'enable' && c[1] === 'sess-9')) + }) + + it('POST enable without sessionId returns 400', async () => { + const resp = await app.inject({ method: 'POST', url: '/_web/ai/semantic-index/enable', payload: {} }) + assert.equal(resp.statusCode, 400) + }) + + it('POST status (batch) forwards sessionIds', async () => { + const resp = await app.inject({ + method: 'POST', + url: '/_web/ai/semantic-index/status', + payload: { sessionIds: ['a', 'b'] }, + }) + assert.equal(resp.statusCode, 200) + assert.equal(resp.json().sessions.length, 2) + }) + + it('POST cleanup returns cleaned count', async () => { + const resp = await app.inject({ method: 'POST', url: '/_web/ai/semantic-index/cleanup' }) + assert.equal(resp.statusCode, 200) + assert.equal(resp.json().cleaned, 2) + }) + + it('POST search forwards query and returns availability', async () => { + const resp = await app.inject({ + method: 'POST', + url: '/_web/ai/semantic-index/search', + payload: { sessionId: 's1', query: '排期' }, + }) + assert.equal(resp.statusCode, 200) + assert.equal(resp.json().available, true) + assert.ok(calls.some((c) => c[0] === 'search' && c[2] === '排期')) + }) + + it('routes are skipped when service is absent', async () => { + const bare = Fastify() + registerSemanticIndexRoutes(bare, {} as unknown as HttpRouteContext) + await bare.ready() + const resp = await bare.inject({ method: 'GET', url: '/_web/ai/semantic-index/config' }) + assert.equal(resp.statusCode, 404) + await bare.close() + }) +}) diff --git a/packages/http-routes/src/routes/web/ai-semantic-index.ts b/packages/http-routes/src/routes/web/ai-semantic-index.ts new file mode 100644 index 00000000..018273b1 --- /dev/null +++ b/packages/http-routes/src/routes/web/ai-semantic-index.ts @@ -0,0 +1,73 @@ +/** + * 语义索引共享 Web 路由(Electron Internal API 与 CLI Web 复用) + * + * 入参以 sessionId 暴露,不暴露 db_path_hash。service 缺失时整组路由优雅跳过。 + * AI pipeline 直接调用 SemanticIndexService,不经过这些 HTTP 路由。 + */ + +import type { FastifyInstance } from 'fastify' +import type { HttpRouteContext } from '../../context' +import type { SemanticIndexConfig } from '@openchatlab/node-runtime' + +export function registerSemanticIndexRoutes(server: FastifyInstance, ctx: HttpRouteContext): void { + const service = ctx.semanticIndexService + if (!service) return + + server.get('/_web/ai/semantic-index/config', async () => { + return { config: service.getConfig(), apiKeySet: service.hasApiKey() } + }) + + server.put<{ Body: { config: SemanticIndexConfig; apiKey?: string } }>( + '/_web/ai/semantic-index/config', + async (request) => { + const config = service.setConfig(request.body.config, { apiKey: request.body.apiKey }) + return { config, apiKeySet: service.hasApiKey() } + } + ) + + server.get('/_web/ai/semantic-index/enabled', async () => { + return { sessions: service.listEnabledStatuses() } + }) + + server.get<{ Querystring: { sessionId: string } }>('/_web/ai/semantic-index/status', async (request) => { + return { status: service.status(request.query.sessionId) } + }) + + server.post<{ Body: { sessionIds: string[] } }>('/_web/ai/semantic-index/status', async (request) => { + return { sessions: service.statusForSessions(request.body.sessionIds ?? []) } + }) + + const sessionAction = (path: string, action: (sessionId: string) => void | Promise): void => { + server.post<{ Body: { sessionId: string } }>(path, async (request, reply) => { + const { sessionId } = request.body + if (!sessionId) return reply.code(400).send({ error: 'sessionId is required' }) + await action(sessionId) + return { status: service.status(sessionId) } + }) + } + + sessionAction('/_web/ai/semantic-index/enable', (id) => service.enable(id)) + sessionAction('/_web/ai/semantic-index/disable', (id) => service.disable(id)) + sessionAction('/_web/ai/semantic-index/build', (id) => service.build(id)) + sessionAction('/_web/ai/semantic-index/pause', (id) => service.pause(id)) + sessionAction('/_web/ai/semantic-index/cancel', (id) => service.cancel(id)) + sessionAction('/_web/ai/semantic-index/rebuild', (id) => service.rebuild(id)) + + server.post('/_web/ai/semantic-index/build-pending', async () => { + service.buildAllPending() + return { sessions: service.listEnabledStatuses() } + }) + + server.post('/_web/ai/semantic-index/cleanup', async () => { + return service.cleanupUnused() + }) + + server.post<{ Body: { sessionId: string; query: string; finalTopK?: number } }>( + '/_web/ai/semantic-index/search', + async (request, reply) => { + const { sessionId, query, finalTopK } = request.body + if (!sessionId || !query) return reply.code(400).send({ error: 'sessionId and query are required' }) + return service.search(sessionId, query, { finalTopK }) + } + ) +} diff --git a/packages/http-routes/src/routes/web/cache.test.ts b/packages/http-routes/src/routes/web/cache.test.ts index 11cd0e08..48a3cfc0 100644 --- a/packages/http-routes/src/routes/web/cache.test.ts +++ b/packages/http-routes/src/routes/web/cache.test.ts @@ -13,6 +13,7 @@ function createPathProvider(overrides: Partial = {}): PathProvider getSystemDir: () => '/tmp/chatlab-test', getUserDataDir: () => '/tmp/chatlab-test/data', getDatabaseDir: () => '/tmp/chatlab-test/databases', + getVectorDir: () => '/tmp/chatlab-test/vector', getAiDataDir: () => '/tmp/chatlab-test/ai', getSettingsDir: () => '/tmp/chatlab-test/settings', getCacheDir: () => '/tmp/chatlab-test/cache', diff --git a/packages/node-runtime/src/ai/agent/index.ts b/packages/node-runtime/src/ai/agent/index.ts index ffaf379f..e4ea308f 100644 --- a/packages/node-runtime/src/ai/agent/index.ts +++ b/packages/node-runtime/src/ai/agent/index.ts @@ -6,6 +6,7 @@ export type { LlmRouteDecider, RequestRoute, RouteDecision, RouteDecisionSource, export { buildPlanGuidance, createAnalysisPlanner, createPlanContentBlock } from './planner' export { createDataSnapshotFromOverview } from './data-snapshot' export type { ChatOverviewForSnapshot } from './data-snapshot' +export { buildSemanticSearchGuidance } from './semantic-search-guidance' export type { AnalysisPlanIntent, AnalysisPlanner, diff --git a/packages/node-runtime/src/ai/agent/semantic-search-guidance.ts b/packages/node-runtime/src/ai/agent/semantic-search-guidance.ts new file mode 100644 index 00000000..b167769f --- /dev/null +++ b/packages/node-runtime/src/ai/agent/semantic-search-guidance.ts @@ -0,0 +1,24 @@ +/** + * 语义检索工具引导语 + * + * 仅当当前会话语义索引可检索、工具被暴露给 LLM 时,由两端 runner 注入 system prompt。 + * 引导模型在需要历史证据时调用 semantic_search_current_chat,避免寒暄/写作类问题无谓检索。 + */ + +function isChinese(locale?: string): boolean { + return (locale ?? '').toLowerCase().startsWith('zh') +} + +export function buildSemanticSearchGuidance(locale?: string): string { + if (isChinese(locale)) { + return [ + '当需要确认本对话历史中的具体事实、人物、地点、事件或过往提及时,调用 semantic_search_current_chat 检索相关片段。', + '寒暄、写作、解释通用概念等不依赖历史证据的问题不要调用该工具。', + ].join('') + } + return [ + 'When you need concrete facts, people, places, events, or past mentions from THIS conversation history, ', + 'call semantic_search_current_chat to retrieve relevant excerpts. ', + 'Do not call it for greetings, writing, or explaining general concepts that need no historical evidence.', + ].join('') +} diff --git a/packages/node-runtime/src/services/adapters/database-manager-adapter.test.ts b/packages/node-runtime/src/services/adapters/database-manager-adapter.test.ts index bfbddb93..f1eb6fb7 100644 --- a/packages/node-runtime/src/services/adapters/database-manager-adapter.test.ts +++ b/packages/node-runtime/src/services/adapters/database-manager-adapter.test.ts @@ -17,6 +17,7 @@ function createPathProvider(root: string): PathProvider { getSystemDir: () => root, getUserDataDir: () => path.join(root, 'data'), getDatabaseDir: () => path.join(root, 'data', 'databases'), + getVectorDir: () => path.join(root, 'data', 'vector'), getAiDataDir: () => path.join(root, 'ai'), getSettingsDir: () => path.join(root, 'settings'), getCacheDir: () => path.join(root, 'cache'), diff --git a/packages/tools/src/definitions/semantic-search-current-chat.test.ts b/packages/tools/src/definitions/semantic-search-current-chat.test.ts new file mode 100644 index 00000000..f3d1d7a0 --- /dev/null +++ b/packages/tools/src/definitions/semantic-search-current-chat.test.ts @@ -0,0 +1,136 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { semanticSearchCurrentChatTool } from './semantic-search-current-chat' +import type { SemanticSearchToolResult, SemanticSearchToolService, ToolExecutionContext } from '../types' +import { SEMANTIC_SEARCH_MAX_RESULTS_HARD_CAP } from '../types' + +function makeResult(over: Partial = {}): SemanticSearchToolResult { + return { + available: true, + text: 'U1: hello\nU2: world', + returned: 1, + hitCount: 3, + partial: false, + coverage: 1, + truncated: false, + timeRange: { earliest: '2024-01-01T00:00:00.000Z', latest: '2024-01-02T00:00:00.000Z' }, + sources: [ + { + startMessageId: 10, + endMessageId: 12, + score: 0.8, + chunkIds: ['c1'], + snippet: 'U1: hello', + startTime: '2024-01-01T00:00:00.000Z', + endTime: '2024-01-01T00:01:00.000Z', + }, + ], + ...over, + } +} + +function makeContext( + service: SemanticSearchToolService | undefined, + extra: Partial = {} +): ToolExecutionContext { + return { sessionId: 's1', locale: 'zh-CN', semanticIndexService: service, ...extra } +} + +describe('semanticSearchCurrentChatTool', () => { + it('is restricted to query + max_results only', () => { + const props = semanticSearchCurrentChatTool.inputSchema.properties + assert.deepEqual(Object.keys(props).sort(), ['max_results', 'query']) + assert.deepEqual(semanticSearchCurrentChatTool.inputSchema.required, ['query']) + }) + + it('returns an unavailable message when service is not injected', async () => { + const result = await semanticSearchCurrentChatTool.handler({ query: 'x' }, makeContext(undefined)) + assert.match(result.content, /not available/i) + assert.equal(result.rawMessages, undefined) + }) + + it('returns a hint for empty query without calling the service', async () => { + let called = false + const service: SemanticSearchToolService = { + canSearch: () => true, + searchForTool: async () => { + called = true + return makeResult() + }, + } + const result = await semanticSearchCurrentChatTool.handler({ query: ' ' }, makeContext(service)) + assert.equal(called, false) + assert.match(result.content, /query/i) + }) + + it('clamps max_results to the hard cap and forwards safe options', async () => { + let receivedMax: number | undefined + let receivedOpts: Record | undefined + const service: SemanticSearchToolService = { + canSearch: () => true, + searchForTool: async (_s, _q, opts) => { + receivedMax = opts?.maxResults + receivedOpts = opts as Record + return makeResult() + }, + } + await semanticSearchCurrentChatTool.handler( + { query: 'hello', max_results: 999 }, + makeContext(service, { + preprocessConfig: { desensitize: true }, + ownerPlatformId: 'owner', + maxToolResultTokens: 2000, + }) + ) + assert.equal(receivedMax, SEMANTIC_SEARCH_MAX_RESULTS_HARD_CAP) + assert.deepEqual(receivedOpts?.preprocessConfig, { desensitize: true }) + assert.equal(receivedOpts?.ownerPlatformId, 'owner') + assert.equal(receivedOpts?.maxResultTokens, 2000) + }) + + it('passes undefined maxResults when LLM does not specify (service uses configured default)', async () => { + let receivedMax: number | undefined = 1 + const service: SemanticSearchToolService = { + canSearch: () => true, + searchForTool: async (_s, _q, opts) => { + receivedMax = opts?.maxResults + return makeResult() + }, + } + await semanticSearchCurrentChatTool.handler({ query: 'hello' }, makeContext(service)) + assert.equal(receivedMax, undefined) + }) + + it('returns safe text + metadata, never rawMessages', async () => { + const service: SemanticSearchToolService = { + canSearch: () => true, + searchForTool: async () => makeResult(), + } + const result = await semanticSearchCurrentChatTool.handler({ query: 'hello' }, makeContext(service)) + assert.match(result.content, /U1: hello/) + assert.equal(result.rawMessages, undefined) + const data = result.data as Record + assert.equal(data.returned, 1) + assert.equal(data.hitCount, 3) + assert.ok(data.sources) + assert.equal(JSON.stringify(data).includes('rawMessages'), false) + }) + + it('reports no-results without throwing', async () => { + const service: SemanticSearchToolService = { + canSearch: () => true, + searchForTool: async () => makeResult({ returned: 0, sources: [], text: '', hitCount: 0 }), + } + const result = await semanticSearchCurrentChatTool.handler({ query: 'hello' }, makeContext(service)) + assert.match(result.content, /no relevant/i) + }) + + it('appends a truncation notice when truncated', async () => { + const service: SemanticSearchToolService = { + canSearch: () => true, + searchForTool: async () => makeResult({ truncated: true }), + } + const result = await semanticSearchCurrentChatTool.handler({ query: 'hello' }, makeContext(service)) + assert.match(result.content, /truncat/i) + }) +}) diff --git a/packages/tools/src/definitions/semantic-search-current-chat.ts b/packages/tools/src/definitions/semantic-search-current-chat.ts new file mode 100644 index 00000000..20c45eb0 --- /dev/null +++ b/packages/tools/src/definitions/semantic-search-current-chat.ts @@ -0,0 +1,92 @@ +/** + * 当前对话语义检索工具 + * + * 由 LLM 按需调用:当问题需要当前聊天历史中的具体事实/人物/地点/事件/过往提及时调用。 + * 仅检索当前会话,结果由 SemanticIndexService 经 applyPreprocessingPipeline 脱敏后返回, + * 工具层不接触原始消息,details 不夹带 rawMessages。 + */ + +import type { ToolDefinition, ToolExecutionContext, ToolResult, JsonSchema } from '../types' +import { SEMANTIC_SEARCH_MAX_RESULTS_HARD_CAP } from '../types' + +const inputSchema: JsonSchema = { + type: 'object', + properties: { + query: { + type: 'string', + description: + '语义检索查询,应改写成适合检索当前聊天历史的自然语言描述(围绕需要确认的事实/人物/地点/事件/过往提及)。', + }, + max_results: { + type: 'number', + description: `期望返回的相关片段数,可选。不填使用用户配置的默认值;可按问题复杂度提高,硬上限 ${SEMANTIC_SEARCH_MAX_RESULTS_HARD_CAP}。`, + minimum: 1, + maximum: SEMANTIC_SEARCH_MAX_RESULTS_HARD_CAP, + }, + }, + required: ['query'], +} + +async function handler(params: Record, context: ToolExecutionContext): Promise { + const service = context.semanticIndexService + if (!service) { + return { content: 'Semantic search is not available for this conversation.' } + } + + const query = typeof params.query === 'string' ? params.query.trim() : '' + if (!query) { + return { content: 'Please provide a non-empty query describing what to look up in this conversation history.' } + } + + const maxResults = + typeof params.max_results === 'number' && Number.isFinite(params.max_results) + ? Math.max(1, Math.min(SEMANTIC_SEARCH_MAX_RESULTS_HARD_CAP, Math.floor(params.max_results))) + : undefined + + const result = await service.searchForTool(context.sessionId, query, { + maxResults, + preprocessConfig: context.preprocessConfig, + ownerPlatformId: context.ownerPlatformId, + locale: context.locale, + maxResultTokens: context.maxToolResultTokens, + }) + + if (!result.available) { + return { content: `No semantic index available for this conversation (${result.reason ?? 'unavailable'}).` } + } + + const data = { + query, + returned: result.returned, + hitCount: result.hitCount, + partial: result.partial, + coverage: result.coverage, + truncated: result.truncated, + timeRange: result.timeRange, + sources: result.sources, + } + + if (result.returned === 0) { + return { content: 'No relevant excerpts found in this conversation history.', data } + } + + let content = result.text + if (result.partial) { + content = `Note: index is incomplete, evidence may be partial.\n\n${content}` + } + if (result.truncated) { + content = `${content}\n\nResults truncated, use a more specific query to continue searching.` + } + + return { content, data } +} + +export const semanticSearchCurrentChatTool: ToolDefinition = { + name: 'semantic_search_current_chat', + description: + 'Semantically search the CURRENT conversation history for relevant excerpts. Use when the question needs concrete facts, people, places, events, or past mentions from this chat. Do NOT use for greetings, writing, or explaining general concepts.', + inputSchema, + handler, + category: 'core', + truncationStrategy: 'keep_first', +} diff --git a/packages/tools/src/index.ts b/packages/tools/src/index.ts index 7a3cd1a8..e99fa13b 100644 --- a/packages/tools/src/index.ts +++ b/packages/tools/src/index.ts @@ -6,7 +6,7 @@ */ // === Registry === -export { MCP_TOOL_REGISTRY, AGENT_TOOL_REGISTRY, getToolByName } from './registry' +export { MCP_TOOL_REGISTRY, AGENT_TOOL_REGISTRY, SEMANTIC_SEARCH_TOOL_NAME, getToolByName } from './registry' // === Providers === export { CoreDataProvider } from './providers/core-data-provider' @@ -30,6 +30,7 @@ export { getSegmentSummariesTool } from './definitions/get-segment-summaries' export { responseTimeAnalysisTool } from './definitions/response-time-analysis' export { keywordFrequencyTool } from './definitions/keyword-frequency' export { renderChartTool } from './definitions/render-chart' +export { semanticSearchCurrentChatTool } from './definitions/semantic-search-current-chat' // === SQL Tools === export { SQL_TOOL_DEFS, createSqlToolDefinition, createAllSqlToolDefinitions } from './sql' @@ -62,4 +63,10 @@ export type { SqlToolDef, SqlToolExecution, SegmentResult, + SemanticSearchToolService, + SemanticSearchToolResult, + SemanticSearchToolSource, + SemanticSearchToolOptions, } from './types' + +export { SEMANTIC_SEARCH_DEFAULT_MAX_RESULTS, SEMANTIC_SEARCH_MAX_RESULTS_HARD_CAP } from './types' diff --git a/packages/tools/src/registry.test.ts b/packages/tools/src/registry.test.ts new file mode 100644 index 00000000..e5b1e645 --- /dev/null +++ b/packages/tools/src/registry.test.ts @@ -0,0 +1,18 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { AGENT_TOOL_REGISTRY, MCP_TOOL_REGISTRY, SEMANTIC_SEARCH_TOOL_NAME, getToolByName } from './registry' + +describe('semantic_search_current_chat registry placement', () => { + it('is registered in AGENT registry only, not MCP (privacy: no external semantic access in Phase 1)', () => { + const inAgent = AGENT_TOOL_REGISTRY.some((t) => t.name === SEMANTIC_SEARCH_TOOL_NAME) + const inMcp = MCP_TOOL_REGISTRY.some((t) => t.name === SEMANTIC_SEARCH_TOOL_NAME) + assert.equal(inAgent, true) + assert.equal(inMcp, false) + }) + + it('is resolvable by name and constrains inputs to query + max_results', () => { + const tool = getToolByName(SEMANTIC_SEARCH_TOOL_NAME) + assert.ok(tool) + assert.deepEqual(Object.keys(tool!.inputSchema.properties).sort(), ['max_results', 'query']) + }) +}) diff --git a/packages/tools/src/registry.ts b/packages/tools/src/registry.ts index 21b3da3e..1859bd34 100644 --- a/packages/tools/src/registry.ts +++ b/packages/tools/src/registry.ts @@ -29,6 +29,7 @@ import { getConversationBetweenTool } from './definitions/get-conversation-betwe import { getSegmentSummariesTool } from './definitions/get-segment-summaries' import { responseTimeAnalysisTool } from './definitions/response-time-analysis' import { keywordFrequencyTool } from './definitions/keyword-frequency' +import { semanticSearchCurrentChatTool } from './definitions/semantic-search-current-chat' import { SQL_TOOL_DEFS, createAllSqlToolDefinitions } from './sql' /** @@ -64,8 +65,18 @@ const SHARED_TOOLS: ToolDefinition[] = [ /** * Agent full toolset (Server Agent / Electron Agent). * Includes declarative SQL convenience tools on top of the shared set. + * + * semantic_search_current_chat 仅在 AGENT registry,不进 MCP(语义片段外部访问的隐私/权限后续单独设计)。 + * runner 会按当前会话是否可检索动态过滤,未启用/无 chunk/需重建时不暴露给 LLM。 */ -export const AGENT_TOOL_REGISTRY: ToolDefinition[] = [...SHARED_TOOLS, ...createAllSqlToolDefinitions(SQL_TOOL_DEFS)] +export const AGENT_TOOL_REGISTRY: ToolDefinition[] = [ + ...SHARED_TOOLS, + semanticSearchCurrentChatTool, + ...createAllSqlToolDefinitions(SQL_TOOL_DEFS), +] + +/** 语义检索工具名(runner 动态过滤用) */ +export const SEMANTIC_SEARCH_TOOL_NAME = semanticSearchCurrentChatTool.name /** * MCP Server toolset — slim registry optimised for external AI agents. diff --git a/packages/tools/src/types.ts b/packages/tools/src/types.ts index 905b81aa..01a0ff8e 100644 --- a/packages/tools/src/types.ts +++ b/packages/tools/src/types.ts @@ -214,6 +214,69 @@ export interface ToolDataProvider { getSchema(): Promise } +// ==================== Semantic Search Tool ==================== + +/** 单次语义检索默认返回片段数(用户可配置范围 5-15) */ +export const SEMANTIC_SEARCH_DEFAULT_MAX_RESULTS = 10 +/** 单次语义检索片段数硬上限(LLM 不可超过;须与 node-runtime SEARCH_MAX_RESULTS_HARD_CAP 一致) */ +export const SEMANTIC_SEARCH_MAX_RESULTS_HARD_CAP = 20 + +/** 工具返回的安全来源条目(已脱敏,不含原始消息) */ +export interface SemanticSearchToolSource { + startMessageId: number + endMessageId: number + score: number + chunkIds: string[] + /** 已脱敏的片段预览 */ + snippet: string + /** ISO 时间 */ + startTime?: string + endTime?: string +} + +/** 语义检索工具结果:text 为面向 LLM 的安全证据文本,其余为安全 metadata */ +export interface SemanticSearchToolResult { + available: boolean + reason?: string + /** 已清洗/脱敏/匿名化/截断的证据文本 */ + text: string + returned: number + hitCount: number + partial: boolean + coverage: number + truncated: boolean + timeRange?: { earliest: string; latest: string } + sources: SemanticSearchToolSource[] +} + +export interface SemanticSearchToolOptions { + /** 期望返回片段数;未指定时由 service 用配置默认值 */ + maxResults?: number + /** 预处理配置(脱敏/匿名化/清洗规则) */ + preprocessConfig?: Record + /** 当前用户平台 id,用于昵称匿名化 owner 识别 */ + ownerPlatformId?: string + locale?: string + /** 证据文本 token 预算 */ + maxResultTokens?: number +} + +/** + * 语义检索窄接口 + * + * 由 node-runtime 的 SemanticIndexService 实现并经 adapter 注入。 + * 工具层只消费已脱敏的安全结果,脱敏在 service 内通过 applyPreprocessingPipeline 完成。 + */ +export interface SemanticSearchToolService { + /** 当前会话是否可检索(已启用 + 有 chunk + 模型一致) */ + canSearch(sessionId: string): boolean + searchForTool( + sessionId: string, + query: string, + options?: SemanticSearchToolOptions + ): Promise +} + // ==================== Tool Context ==================== /** @@ -235,6 +298,12 @@ export interface ToolExecutionContext { abortSignal?: AbortSignal /** 抽象查询接口 */ dataProvider?: ToolDataProvider + /** 语义检索窄接口(仅当前会话可检索时由 adapter 注入) */ + semanticIndexService?: SemanticSearchToolService + /** 预处理配置(脱敏/匿名化/清洗),供需要自处理的工具(如语义检索)使用 */ + preprocessConfig?: Record + /** 当前用户平台 id,用于昵称匿名化 owner 识别 */ + ownerPlatformId?: string /** @deprecated 逐步迁移到 dataProvider 后移除;Electron 端不提供此字段 */ db?: DatabaseAdapter /** 搜索结果上下文:向前取多少条 */ @@ -243,6 +312,8 @@ export interface ToolExecutionContext { searchContextAfter?: number /** 消息条数上限 */ maxMessagesLimit?: number + /** 工具结果 token 预算(语义检索证据文本截断用) */ + maxToolResultTokens?: number /** NLP 分词回调(由平台注入 batchSegmentWithFrequency 实现) */ segmentText?: (texts: string[], locale: string, options: Record) => SegmentResult /** i18n 模板翻译回调(用于 SQL 工具模板国际化) */