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 <cursoragent@cursor.com>
This commit is contained in:
diguaandCursor committed 2026-06-21 16:18:39 +08:00
1 parent e3f38cfb8b
commit b9599da1c8
25 files changed
+741 -20

No files matched your search

+28 -4
View File
@@ -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<void> {
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)
+11 -1
View File
@@ -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<string, unknown>
/** 当前用户平台 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 {
+21 -2
View File
@@ -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),
},
})
+29 -1
View File
@@ -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(),
+21 -2
View File
@@ -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<string, unknown>) => 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,
-4
View File
@@ -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)
}
+10 -1
View File
@@ -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))
@@ -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<string, unknown> | 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<AgentToolResult<unknown>> {
const toolParams = (params && typeof params === 'object' ? params : {}) as Record<string, unknown>
+3
View File
@@ -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
+34 -2
View File
@@ -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<I
let newServer: FastifyInstance | null = null
let newDbManager: DatabaseManager | null = null
let newSemanticIndexService: SemanticIndexService | null = null
try {
const token = `int_${randomBytes(32).toString('hex')}`
@@ -105,6 +108,16 @@ export async function startInternalServer(pathProvider: PathProvider): Promise<I
const newMergeCache = new MergeSessionCache(pathProvider)
newMergeCache.cleanupOrphans()
// 语义索引 service:每进程一个实例,启动时仅做状态恢复。构建失败(如 sqlite-vec
// 扩展加载失败)不应阻断 server,路由会优雅跳过。
try {
newSemanticIndexService = createSemanticIndexService({ pathProvider, sessionAdapter })
newSemanticIndexService.recover()
} catch (err) {
console.warn('[semantic-index] service unavailable:', err instanceof Error ? err.message : String(err))
newSemanticIndexService = null
}
const electronStreamImport = async (dm: DatabaseManager, filePath: string) => {
const deps: StreamImportDeps = {
openDatabase(sessionId: string) {
@@ -145,6 +158,7 @@ export async function startInternalServer(pathProvider: PathProvider): Promise<I
llmConfigStore,
customProviderStore: new CustomProviderStore(configStorage),
customModelStore: new CustomModelStore(configStorage),
semanticIndexService: newSemanticIndexService ?? undefined,
openDirectory: (dirPath) => shell.openPath(dirPath).then(() => {}),
showInFolder: (filePath) => {
shell.showItemInFolder(filePath)
@@ -153,7 +167,7 @@ export async function startInternalServer(pathProvider: PathProvider): Promise<I
downloadsDir: getDownloadsDir(),
defaultUserDataDir: getDefaultUserDataDir(),
isCustomDataDir: path.resolve(getUserDataDir()) !== path.resolve(getDefaultUserDataDir()),
runAgentStream: createElectronRunAgentStream(),
runAgentStream: createElectronRunAgentStream(newSemanticIndexService ?? undefined),
executeAiTool: executeElectronAiTool,
}
@@ -231,6 +245,7 @@ export async function startInternalServer(pathProvider: PathProvider): Promise<I
server = newServer
dbManager = newDbManager
mergeCache = newMergeCache
semanticIndexService = newSemanticIndexService
endpoint = { baseUrl: `http://127.0.0.1:${port}`, token }
console.log(`[InternalAPI] Server started on port ${port}`)
@@ -246,9 +261,15 @@ export async function startInternalServer(pathProvider: PathProvider): Promise<I
} catch {
/* best-effort */
}
try {
newSemanticIndexService?.close()
} catch {
/* best-effort */
}
server = null
dbManager = null
mergeCache = null
semanticIndexService = null
endpoint = null
throw err
}
@@ -263,6 +284,11 @@ export function getInternalDbManager(): DatabaseManager | null {
return dbManager
}
/** Main-process SemanticIndexService backing the internal server (null before startup / unavailable). */
export function getInternalSemanticIndexService(): SemanticIndexService | null {
return semanticIndexService
}
export async function stopInternalServer(): Promise<void> {
if (!server) return
try {
@@ -280,10 +306,16 @@ export async function stopInternalServer(): Promise<void> {
} catch {
/* best-effort */
}
try {
semanticIndexService?.close()
} catch {
/* best-effort */
}
server = null
endpoint = null
dbManager = null
mergeCache = null
semanticIndexService = null
console.log('[InternalAPI] Server stopped')
}
}
+4
View File
@@ -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<void>
showInFolder?: (filePath: string) => Promise<void>
+2
View File
@@ -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)
@@ -120,6 +120,7 @@ function createTestContext(dbs: Map<string, DatabaseAdapter> = 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',
@@ -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()
})
})
@@ -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>): 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 })
}
)
}
@@ -13,6 +13,7 @@ function createPathProvider(overrides: Partial<PathProvider> = {}): 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',
@@ -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,
@@ -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('')
}
@@ -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'),
@@ -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> = {}): 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> = {}
): 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<string, unknown> | undefined
const service: SemanticSearchToolService = {
canSearch: () => true,
searchForTool: async (_s, _q, opts) => {
receivedMax = opts?.maxResults
receivedOpts = opts as Record<string, unknown>
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<string, unknown>
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)
})
})
@@ -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<string, unknown>, context: ToolExecutionContext): Promise<ToolResult> {
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',
}
+8 -1
View File
@@ -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'
+18
View File
@@ -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'])
})
})
+12 -1
View File
@@ -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.
+71
View File
@@ -214,6 +214,69 @@ export interface ToolDataProvider {
getSchema(): Promise<SchemaTableInfo[]>
}
// ==================== 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<string, unknown>
/** 当前用户平台 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<SemanticSearchToolResult>
}
// ==================== Tool Context ====================
/**
@@ -235,6 +298,12 @@ export interface ToolExecutionContext {
abortSignal?: AbortSignal
/** 抽象查询接口 */
dataProvider?: ToolDataProvider
/** 语义检索窄接口(仅当前会话可检索时由 adapter 注入) */
semanticIndexService?: SemanticSearchToolService
/** 预处理配置(脱敏/匿名化/清洗),供需要自处理的工具(如语义检索)使用 */
preprocessConfig?: Record<string, unknown>
/** 当前用户平台 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<string, unknown>) => SegmentResult
/** i18n 模板翻译回调(用于 SQL 工具模板国际化) */