diff --git a/docs/agent/api.md b/docs/agent/api.md index 5ff6d27..25b792d 100644 --- a/docs/agent/api.md +++ b/docs/agent/api.md @@ -112,3 +112,37 @@ curl -H "$AUTH" "$BASE/chatlog?talker=技术交流群&time=2026-08-07" ## 与 MCP 的关系 当前实现没有把 `6131` 暴露为 MCP Server。需要在 Agent 中使用时,请安装随应用提供的 Reader Skill,并让 Skill 通过普通 HTTP 请求调用本 API。 + +## LLM-friendly Query Tool API + +这些端点提供稳定的结构化 Query primitive,不接收自然语言问题,也不会调用 AI。它们与现有 API 共用端口、Bearer Token、loopback 和 CORS 安全策略。 + +```bash +BASE="http://127.0.0.1:6131/api/v1" +AUTH="Authorization: Bearer ${TRACEMEMO_API_TOKEN:-$WECHATEXPLORER_API_TOKEN}" + +# 能力目录 +curl -H "$AUTH" "$BASE/query/capabilities" + +# BOBO 的第一条真实互动 +curl -X POST -H "$AUTH" -H 'Content-Type: application/json' "$BASE/query/messages" \ + -d '{"target":{"query":"BOBO"},"timeRange":{"kind":"all"},"direction":"any","order":"asc","limit":1,"excludeSystem":true}' + +# 上个月 BOBO 发来的文件 +curl -X POST -H "$AUTH" -H 'Content-Type: application/json' "$BASE/query/messages" \ + -d '{"target":{"query":"BOBO"},"timeRange":{"kind":"previous_month"},"direction":"from_target","messageTypes":["file"],"order":"desc","limit":1}' + +# 受限语义关键词检索(最多 4 个 variants) +curl -X POST -H "$AUTH" -H 'Content-Type: application/json' "$BASE/query/search" \ + -d '{"target":{"query":"BOBO"},"timeRange":{"kind":"all"},"query":"答应之后给我或者帮我完成某件事情","variants":["我给你","我发你","弄好给你"],"limit":20}' + +# 按会话和时间范围提取可供总结的证据 +curl -X POST -H "$AUTH" -H 'Content-Type: application/json' "$BASE/query/conversation-overview" \ + -d '{"target":{"query":"BOBO"},"timeRange":{"kind":"previous_month"}}' +``` + +`query/messages` 的 `messageRef` 是服务端生成的不透明引用,可直接传给 `query/message-context` 获取前后文;不要自行构造 wxid、md5 或数据库路径。 + +每条消息都会返回 `messageType`(`text`、`image`、`voice`、`video`、`file`、`link`、`sticker`、`system` 或 `other`)。非文本消息不会伪造 `text`;可识别的图片、视频、贴纸和文件会返回不含密钥或本地路径的 `attachment` 元数据。 + +`conversation-overview` 同时返回 `sourceCoverage` 与 `selection`:前者描述时间范围内源消息是否完整及 `sourceMessageCount`,后者描述从源消息中选出的 Evidence 数量及是否抽样。`evidence` 最终按 `timestamp` 升序返回,`messageRef` 是唯一推荐的消息引用。 diff --git a/src/main/http-server.ts b/src/main/http-server.ts index 8e6eba9..ca31e0e 100644 --- a/src/main/http-server.ts +++ b/src/main/http-server.ts @@ -26,6 +26,7 @@ import { agentHubService } from './services/agent-hub-service' import { safeError, safeLog, safeWarn } from './safe-log' import { apiTokenStore } from './api-token-store' import { HttpMediaError, readImageMedia, type HttpImageResult } from './http-media-service' +import { LocalQueryApiService } from './services/local-query-api-service' export const DEFAULT_HTTP_HOST = '127.0.0.1' export const DEFAULT_HTTP_PORT = 6131 @@ -51,6 +52,12 @@ export interface HttpServerOptions { scheduledReportContactsProvider?: ScheduledReportApiDependencies['listContacts'] scheduledReportDatabaseReadyProvider?: ScheduledReportApiDependencies['isDatabaseReady'] scheduledReportPlatform?: NodeJS.Platform + queryApiService?: LocalQueryApiService +} + +let configuredQueryApiService: LocalQueryApiService | undefined +export function setLocalQueryApiService(service: LocalQueryApiService | undefined): void { + configuredQueryApiService = service } type RouteHandler = (ctx: RouteContext) => void | Promise @@ -542,6 +549,45 @@ function createScheduledReportRoute( } const MEDIA_ROUTE_PREFIX = '/api/v1/media/' +const QUERY_ROUTE_PREFIX = '/api/v1/query/' + +function queryStatusCode(status: string): number { + if (status === 'completed') return 200 + if (status === 'contact_not_found') return 404 + if (status === 'ambiguous_contact') return 409 + if (status === 'knowledge_unavailable') return 503 + if (status === 'retrieval_incomplete') return 206 + return 400 +} + +function createQueryRoute(api: LocalQueryApiService): RouteHandler | undefined { + return async ({ req, res, body }) => { + const pathname = new URL(req.url || '/', 'http://localhost').pathname + if (pathname === '/api/v1/query/capabilities') { + if (req.method !== 'GET') return sendError(res, 405, '需要 GET 请求') + return sendJson(res, 200, api.capabilities()) + } + if (req.method !== 'POST') return sendError(res, 405, '需要 POST 请求') + let payload: any + try { payload = JSON.parse(typeof body === 'string' ? body : '') } catch { return sendError(res, 400, 'invalid_request') } + if (!payload || typeof payload !== 'object') return sendError(res, 400, 'invalid_request') + try { + const result = pathname === '/api/v1/query/messages' + ? await api.messages(payload) + : pathname === '/api/v1/query/search' + ? await api.search(payload) + : pathname === '/api/v1/query/message-context' + ? await api.context(payload) + : pathname === '/api/v1/query/conversation-overview' + ? await api.overview(payload) + : undefined + if (!result) return sendError(res, 404, `端点不存在: ${pathname}`) + return sendJson(res, queryStatusCode(result.status), result) + } catch (error) { + return sendError(res, 400, error instanceof Error ? error.message : 'invalid_request') + } + } +} function createMediaRoute( mediaProvider: (messageId: string) => Promise @@ -599,6 +645,7 @@ export function startHttpServer( const tokenProvider = options.tokenProvider || (() => apiTokenStore.getTokenForAuthentication()) const mediaProvider = options.mediaProvider || readImageMedia const scheduledReportApi = createScheduledReportApi(options) + const queryApi = options.queryApiService || configuredQueryApiService || new LocalQueryApiService() return new Promise((resolve, reject) => { const server: Server = http.createServer(async (req, res) => { try { @@ -613,6 +660,7 @@ export function startHttpServer( const handler = routes[url.pathname] || createScheduledReportRoute(url.pathname, scheduledReportApi) || + (url.pathname.startsWith(QUERY_ROUTE_PREFIX) ? createQueryRoute(queryApi) : undefined) || (url.pathname.startsWith(MEDIA_ROUTE_PREFIX) ? createMediaRoute(mediaProvider) : undefined) diff --git a/src/main/index.ts b/src/main/index.ts index cf3f6c4..f34b042 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -74,7 +74,7 @@ import type { import { KeyServiceMac } from './key-service-mac' import { KeyService as KeyServiceWin } from './key-service-win' import * as chat from './services/chat-service' -import { apiServer } from './http-server' +import { apiServer, setLocalQueryApiService } from './http-server' import { skillResourceService } from './services/skill-resource-service' import { buildLocalApiCurlCommand, testLocalApiRequest } from './services/local-api-test-service' import { isWechatRunning } from './services/wechat-process-status' @@ -163,6 +163,7 @@ import { WINDOWS_VC_RUNTIME_ERROR_MESSAGE } from '../shared/windows-runtime' import { KnowledgeSearchService } from './knowledge/knowledge-search-service' +import { LocalQueryApiService } from './services/local-query-api-service' import { AiSearchPipelineService } from './services/ai-search-pipeline-service' import { runLegacySafeStorageHelper } from './legacy-safe-storage-helper' import { runFirstLaunchMigration } from './app-data-migration' @@ -182,6 +183,7 @@ let voiceService: VoiceService | null = null let voiceRecognition: VoiceRecognitionUseCase | null = null let voiceBatchService: VoiceBatchService | null = null let knowledgeSearchService: KnowledgeSearchService | null = null +let localQueryApiService: LocalQueryApiService | null = null let aiSearchPipelineService: AiSearchPipelineService | null = null let imageDecryptService: ImageDecryptService | null = null let stickerService: StickerService | null = null @@ -614,6 +616,8 @@ app.whenReady().then(async () => { knowledgeSearchService?.indexVoiceTranscript(update) ) aiSearchPipelineService = new AiSearchPipelineService(knowledgeSearchService, aiProviderService) + localQueryApiService = new LocalQueryApiService(knowledgeSearchService) + setLocalQueryApiService(localQueryApiService) knowledgeSearchService.onStatusChange((status) => { for (const window of BrowserWindow.getAllWindows()) { if (!window.isDestroyed()) window.webContents.send('knowledge:status', status) diff --git a/src/main/services/local-query-api-service.ts b/src/main/services/local-query-api-service.ts new file mode 100644 index 0000000..e5f1523 --- /dev/null +++ b/src/main/services/local-query-api-service.ts @@ -0,0 +1,112 @@ +import { listContactsAsync, listMessagesAsync, isReady, type FormattedContact, type FormattedMessage } from './chat-service' +import { resolveContact } from './contact-resolution-service' +import type { KnowledgeSearchService } from '../knowledge/knowledge-search-service' +import { inferAiSearchTimeRange } from '../../shared/ai-search' +import type { QueryCapabilitiesResponse, QueryMessage, QueryMessageType, QueryTimeRange, ResolvedTimeRange, QueryMessagesRequest, SearchMessagesRequest, MessageContextRequest, ConversationOverviewRequest } from '../../shared/local-query-api' + +const LIMIT_MAX = 200 +const CONTEXT_MAX = 50 +const kinds: QueryMessageType[] = ['text', 'image', 'voice', 'video', 'file', 'link', 'sticker', 'system', 'other'] + +function kindOf(message: FormattedMessage): QueryMessageType { + if (message.contentData?.type === 'system') return 'system' + if (message.exportMediaType) return message.exportMediaType + if (message.type === '语音' || message.voiceTranscript) return 'voice' + if (message.contentData?.type === 'image') return 'image' + if (message.contentData?.type === 'video') return 'video' + if (message.contentData?.type === 'sticker') return 'sticker' + if (message.contentData?.type === 'share') return message.contentData.typeVal === '6' ? 'file' : 'link' + if (message.contentData?.type === 'miniProgram') return 'link' + if (message.type === '文件') return 'file' + if (message.content?.trim()) return 'text' + return 'other' +} +function toRef(conversationId: string, messageId: string): string { + return Buffer.from(JSON.stringify({ c: conversationId, m: messageId }), 'utf8').toString('base64url') +} +function fromRef(value: string): { c: string; m: string } | null { + try { + const parsed = JSON.parse(Buffer.from(value, 'base64url').toString('utf8')) + return typeof parsed?.c === 'string' && typeof parsed?.m === 'string' ? parsed : null + } catch { return null } +} +function contactView(contact: FormattedContact) { return { displayName: contact.m_nsNickName || contact.m_nsUsrName, type: contact.type } as const } +function resolvedTimeRange(input: QueryTimeRange, now = new Date()): ResolvedTimeRange { + if (input.kind === 'absolute') { + if ((input.startTime !== undefined && !Number.isFinite(input.startTime)) || (input.endTime !== undefined && !Number.isFinite(input.endTime))) throw new Error('时间范围无效') + if (input.startTime !== undefined && input.endTime !== undefined && input.endTime < input.startTime) throw new Error('时间范围无效') + return { ...input, label: '指定时间范围' } + } + const map: Record[1]> = { all: 'all', today: 'today', yesterday: 'all', this_week: 'all', last_7_days: '7d', this_month: 'all', previous_month: 'all', this_year: 'all', previous_year: 'all' } + const phrase: Record = { today: '今天', yesterday: '昨天', this_week: '本周', last_7_days: '近 7 天', this_month: '本月', previous_month: '上个月', this_year: '今年', previous_year: '去年', all: '' } + const range = inferAiSearchTimeRange(phrase[input.kind], map[input.kind], now) + return { kind: input.kind, startTime: range.startTime, endTime: range.endTime, label: range.label } +} +function toQueryMessage(conversationId: string, message: FormattedMessage, target: FormattedContact): QueryMessage { + const kind = kindOf(message) + const content = message.contentData + const attachment = + kind === 'image' && content?.type === 'image' + ? { kind: 'image' as const } + : kind === 'video' && content?.type === 'video' + ? { kind: 'video' as const, sizeBytes: content.byteLength } + : kind === 'sticker' && content?.type === 'sticker' + ? { kind: 'sticker' as const, url: content.url || content.thumbUrl } + : kind === 'file' + ? { kind: 'file' as const, name: message.exportMediaName || (content?.type === 'share' ? content.title : undefined), url: content?.type === 'share' ? content.url : undefined } + : undefined + const text = message.content?.trim() || message.voiceTranscript?.trim() || undefined + return { messageRef: toRef(conversationId, message.id), timestamp: (message.createTime || 0) * 1000, datetime: message.datetime, sender: message.isSender ? '我' : (message.name || target.m_nsNickName), direction: message.isSender ? 'to_target' : 'from_target', messageType: kind, sourceKind: kind, ...(attachment ? { attachment } : {}), ...(text ? { text } : {}) } +} + +export class LocalQueryApiService { + constructor(private readonly knowledge?: KnowledgeSearchService, private readonly nowProvider: () => Date = () => new Date()) {} + capabilities(): QueryCapabilitiesResponse { + return { version: 1, tools: { query_messages: { operation: '读取指定联系人的确定性消息', directions: ['any', 'from_target', 'to_target'], messageTypes: kinds, timeRanges: ['all', 'today', 'yesterday', 'this_week', 'last_7_days', 'this_month', 'previous_month', 'this_year', 'previous_year', 'absolute'], limitMax: LIMIT_MAX }, search_messages: { operation: '受限 Knowledge 关键词检索', timeRanges: ['all', 'today', 'yesterday', 'this_week', 'last_7_days', 'this_month', 'previous_month', 'this_year', 'previous_year', 'absolute'], limitMax: LIMIT_MAX }, message_context: { operation: '读取消息前后文', timeRanges: ['all'], limitMax: CONTEXT_MAX }, conversation_overview: { operation: '按会话时间片提取概览证据', timeRanges: ['all', 'today', 'yesterday', 'this_week', 'last_7_days', 'this_month', 'previous_month', 'this_year', 'previous_year', 'absolute'], limitMax: LIMIT_MAX } } } + } + private async resolve(target: { query: string }) { + const contacts = await listContactsAsync() + const result = resolveContact(target.query, contacts) + return { contacts, result } + } + async messages(request: QueryMessagesRequest) { + if (!request?.target?.query?.trim() || !request.timeRange || !['any', 'from_target', 'to_target'].includes(request.direction || 'any') || (request.messageTypes || []).some((type) => !kinds.includes(type))) return { status: 'invalid_request' as const } + if (!isReady()) return { status: 'knowledge_unavailable' as const } + const { contacts, result } = await this.resolve(request.target) + if (!result.matched || !result.conversationId) return { status: result.ambiguous ? 'ambiguous_contact' as const : 'contact_not_found' as const, candidates: result.candidates.map((candidate) => ({ displayName: candidate.displayName, type: contacts.find((c) => c.md5 === candidate.conversationId)?.type || 'user' })) } + const contact = contacts.find((c) => c.md5 === result.conversationId)!; if (contact.type === 'group' && request.direction && request.direction !== 'any') return { status: 'unsupported_query' as const }; const range = resolvedTimeRange(request.timeRange, this.nowProvider()) + const raw = await listMessagesAsync(contact.md5, range.startTime, range.endTime) + const direction = request.direction || 'any'; const allowed = new Set(request.messageTypes || kinds) + const filtered = raw.filter((message) => !(request.excludeSystem !== false && kindOf(message) === 'system')).filter((message) => allowed.has(kindOf(message))).filter((message) => direction === 'any' || (direction === 'to_target' ? message.isSender : !message.isSender)).sort((a, b) => ((a.createTime || 0) - (b.createTime || 0)) * ((request.order || 'asc') === 'asc' ? 1 : -1)).slice(0, Math.min(LIMIT_MAX, Math.max(1, request.limit || 20))) + return { status: 'completed' as const, target: contactView(contact), query: { direction, messageTypes: request.messageTypes || [], order: request.order || 'asc', limit: Math.min(LIMIT_MAX, Math.max(1, request.limit || 20)), excludeSystem: request.excludeSystem !== false, resolvedTimeRange: range }, coverage: { state: 'complete' as const }, returnedCount: filtered.length, messages: filtered.map((message) => toQueryMessage(contact.md5, message, contact)) } + } + async search(request: SearchMessagesRequest) { + if (!request?.target?.query?.trim() || !request.timeRange || typeof request.query !== 'string' || !request.query.trim() || (request.variants || []).some((value) => typeof value !== 'string')) return { status: 'invalid_request' as const } + const { contacts, result } = await this.resolve(request.target) + if (!result.matched || !result.conversationId) return { status: result.ambiguous ? 'ambiguous_contact' as const : 'contact_not_found' as const, candidates: result.candidates.map((candidate) => ({ displayName: candidate.displayName, type: contacts.find((c) => c.md5 === candidate.conversationId)?.type || 'user' })) } + if (!this.knowledge) return { status: 'knowledge_unavailable' as const } + const contact = contacts.find((c) => c.md5 === result.conversationId)!; const range = resolvedTimeRange(request.timeRange, this.nowProvider()); const probes = [request.query, ...(request.variants || [])] + if (probes.length > 5) return { status: 'invalid_request' as const } + const all = new Map(); let coverage: 'complete' | 'partial' | 'unknown' = 'unknown' + for (const probe of probes) { const found = await this.knowledge.search({ text: probe, terms: [probe], conversationIds: [contact.md5], startTime: range.startTime, endTime: range.endTime, limit: Math.min(LIMIT_MAX, Math.max(1, request.limit || 20)) }); coverage = found.state === 'ready' ? 'complete' : found.evidence.length ? 'partial' : 'unknown'; for (const item of found.evidence) all.set(`${item.conversationId}:${item.messageId}`, { messageRef: toRef(item.conversationId, item.messageId), messageId: item.messageId, timestamp: item.timestamp, sender: item.sender, sourceKind: item.sourceKind, text: item.text }) } + return { status: 'completed' as const, target: contactView(contact), resolvedTimeRange: range, coverage: { state: coverage }, probeCount: probes.length, evidenceCount: all.size, evidence: Array.from(all.values()).map(({ messageId: _messageId, ...item }) => item).slice(0, request.limit || 20) } + } + async context(request: MessageContextRequest) { + const ref = fromRef(request.messageRef); if (!ref) return { status: 'invalid_request' as const } + const before = Math.min(CONTEXT_MAX, Math.max(0, request.before ?? 10)); const after = Math.min(CONTEXT_MAX, Math.max(0, request.after ?? 10)); const messages = await listMessagesAsync(ref.c); const index = messages.findIndex((message) => message.id === ref.m); if (index < 0) return { status: 'contact_not_found' as const } + const contact = (await listContactsAsync()).find((item) => item.md5 === ref.c); if (!contact) return { status: 'contact_not_found' as const }; const map = (message: FormattedMessage) => toQueryMessage(ref.c, message, contact) + return { status: 'completed' as const, anchor: map(messages[index]), before: messages.slice(Math.max(0, index - before), index).map(map), after: messages.slice(index + 1, index + 1 + after).map(map) } + } + async overview(request: ConversationOverviewRequest) { + if (!request?.target?.query?.trim() || !request.timeRange) return { status: 'invalid_request' as const } + const { contacts, result } = await this.resolve(request.target); if (!result.matched || !result.conversationId) return { status: result.ambiguous ? 'ambiguous_contact' as const : 'contact_not_found' as const, candidates: result.candidates.map((candidate) => ({ displayName: candidate.displayName, type: contacts.find((c) => c.md5 === candidate.conversationId)?.type || 'user' })) }; if (!this.knowledge) return { status: 'knowledge_unavailable' as const } + const contact = contacts.find((c) => c.md5 === result.conversationId)!; const range = resolvedTimeRange(request.timeRange, this.nowProvider()); const found = await this.knowledge.search({ text: '', terms: [], conversationIds: [contact.md5], startTime: range.startTime, endTime: range.endTime, limit: LIMIT_MAX }); const retrieval = found.conversationRetrieval + const sourceMessageCount = retrieval?.totalMessages || 0 + const evidence = found.evidence + .map((item, index) => ({ messageRef: toRef(item.conversationId, item.messageId), timestamp: item.timestamp, sender: item.sender, sourceKind: item.sourceKind, text: item.text, index })) + .sort((left, right) => left.timestamp - right.timestamp || left.index - right.index) + .map(({ index: _index, ...item }) => item) + const sourceState = retrieval?.complete ? 'complete' as const : 'partial' as const + return { status: found.state === 'ready' ? 'completed' as const : 'retrieval_incomplete' as const, target: contactView(contact), resolvedTimeRange: range, coverage: { state: sourceState }, sourceMessageCount, evidenceCount: evidence.length, sourceCoverage: { state: sourceState, sourceMessageCount }, selection: { mode: 'temporal_coverage' as const, selectedEvidenceCount: evidence.length, sampled: evidence.length < sourceMessageCount }, voiceCoverage: found.voiceCoverage, evidence } + } +} diff --git a/src/shared/local-query-api.ts b/src/shared/local-query-api.ts new file mode 100644 index 0000000..a6fb7f8 --- /dev/null +++ b/src/shared/local-query-api.ts @@ -0,0 +1,95 @@ +import type { KnowledgeEvidence, KnowledgeVoiceCoverage } from './knowledge' + +export type QueryDirection = 'any' | 'from_target' | 'to_target' +export type QueryOrder = 'asc' | 'desc' +export type QueryMessageType = + | 'text' + | 'image' + | 'voice' + | 'video' + | 'file' + | 'link' + | 'sticker' + | 'system' + | 'other' +export type QueryTimeRange = + | { kind: 'all' | 'today' | 'yesterday' | 'this_week' | 'last_7_days' | 'this_month' | 'previous_month' | 'this_year' | 'previous_year' } + | { kind: 'absolute'; startTime?: number; endTime?: number } +export interface QueryTarget { query: string } +export interface ResolvedTimeRange { kind: QueryTimeRange['kind']; startTime?: number; endTime?: number; label: string } +export interface QueryMessagesRequest { + target: QueryTarget + timeRange: QueryTimeRange + direction?: QueryDirection + messageTypes?: QueryMessageType[] + order?: QueryOrder + limit?: number + excludeSystem?: boolean +} +export interface QueryMessage { + messageRef: string + timestamp: number + datetime: string + sender: string + direction: 'from_target' | 'to_target' + messageType: QueryMessageType + sourceKind: QueryMessageType + text?: string + attachment?: { + kind: Exclude + name?: string + url?: string + sizeBytes?: number + } +} +export interface QueryMessagesResponse { + status: string + target?: { displayName: string; type: 'user' | 'group' } + query?: Omit & { resolvedTimeRange: ResolvedTimeRange } + coverage?: { state: 'complete' | 'partial' | 'unknown' } + returnedCount?: number + messages?: QueryMessage[] + candidates?: Array<{ displayName: string; type: 'user' | 'group' }> +} +export interface SearchMessagesRequest { + target: QueryTarget + timeRange: QueryTimeRange + query: string + variants?: string[] + limit?: number +} +export interface SearchMessagesResponse { + status: string + target?: { displayName: string; type: 'user' | 'group' } + resolvedTimeRange?: ResolvedTimeRange + coverage?: { state: 'complete' | 'partial' | 'unknown' } + probeCount?: number + evidenceCount?: number + evidence?: Array & { messageRef: string }> + candidates?: Array<{ displayName: string; type: 'user' | 'group' }> +} +export interface MessageContextRequest { messageRef: string; before?: number; after?: number } +export interface MessageContextResponse { + status: string + anchor?: QueryMessage + before?: QueryMessage[] + after?: QueryMessage[] +} +export interface ConversationOverviewRequest { target: QueryTarget; timeRange: QueryTimeRange } +export interface ConversationOverviewResponse { + status: string + target?: { displayName: string; type: 'user' | 'group' } + resolvedTimeRange?: ResolvedTimeRange + coverage?: { state: 'complete' | 'partial' | 'unknown' } + sourceMessageCount?: number + evidenceCount?: number + sourceCoverage?: { state: 'complete' | 'partial' | 'unknown'; sourceMessageCount: number } + selection?: { mode: 'temporal_coverage'; selectedEvidenceCount: number; sampled: boolean } + voiceCoverage?: KnowledgeVoiceCoverage + evidence?: Array & { messageRef: string }> + candidates?: Array<{ displayName: string; type: 'user' | 'group' }> +} +export interface QueryCapabilitiesResponse { + version: 1 + tools: Record +} diff --git a/tests/integration/local-api-auth.test.ts b/tests/integration/local-api-auth.test.ts index c7d8dba..deb3fb2 100644 --- a/tests/integration/local-api-auth.test.ts +++ b/tests/integration/local-api-auth.test.ts @@ -127,6 +127,15 @@ describe('Local API authentication', () => { await expect(response.json()).resolves.toMatchObject({ count: 1 }) }) + it('exposes the query capability catalog only with the existing bearer token', async () => { + const handle = await startFixtureServer() + const path = `${baseUrl(handle)}/api/v1/query/capabilities` + expect((await fetch(path)).status).toBe(401) + const response = await fetch(path, { headers: { Authorization: `Bearer ${VALID_TOKEN}` } }) + expect(response.status).toBe(200) + await expect(response.json()).resolves.toMatchObject({ version: 1, tools: { query_messages: { limitMax: 200 } } }) + }) + it.each([ ['GET', '/api/v1/current_time'], ['GET', '/api/v1/contact'], @@ -139,7 +148,9 @@ describe('Local API authentication', () => { ['POST', '/api/v1/report'], ['GET', '/api/v1/agent/status'], ['POST', '/api/v1/agent/group-report'], - ['POST', '/api/v1/agent/send'] + ['POST', '/api/v1/agent/send'], + ['GET', '/api/v1/query/capabilities'], + ['POST', '/api/v1/query/messages'] ])('protects every non-health route: %s %s', async (method, pathname) => { const handle = await startFixtureServer() const response = await fetch(`${baseUrl(handle)}${pathname}`, { diff --git a/tests/unit/local-query-api-service.test.ts b/tests/unit/local-query-api-service.test.ts new file mode 100644 index 0000000..f5629a0 --- /dev/null +++ b/tests/unit/local-query-api-service.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest' + +const fixture = vi.hoisted(() => ({ + contacts: [ + { m_nsUsrName: 'wxid-bobo', m_nsNickName: 'BOBO', md5: 'md5-bobo', type: 'user' as const }, + { m_nsUsrName: 'wxid-bobo-2', m_nsNickName: 'BOBO', md5: 'md5-bobo-2', type: 'user' as const } + ], + messages: [ + { id: 'm1', from: 'user', type: '图片', datetime: '2026/8/2 10:00:00', content: '', contentData: { type: 'image', md5: 'image-md5' }, isSender: false, name: 'BOBO', createTime: Math.floor(new Date('2026-08-02T10:00:00+08:00').getTime() / 1000) }, + { id: 'm2', from: 'assistant', type: '文件', datetime: '2026/8/3 10:00:00', content: '', isSender: true, exportMediaType: 'file' as const, exportMediaName: 'a.pdf', createTime: Math.floor(new Date('2026-08-03T10:00:00+08:00').getTime() / 1000) } + ] +})) +vi.mock('../../src/main/services/chat-service', () => ({ + isReady: () => true, + listContactsAsync: vi.fn(async () => fixture.contacts), + listMessagesAsync: vi.fn(async () => fixture.messages) +})) + +import { LocalQueryApiService } from '../../src/main/services/local-query-api-service' + +describe('LocalQueryApiService', () => { + const knowledge = { search: vi.fn(async () => ({ state: 'ready', evidence: [{ conversationId: 'md5-bobo', messageId: 'm1', timestamp: 1, sender: 'BOBO', sourceKind: 'text', text: '你好' }], conversationRetrieval: { totalMessages: 2, chunkCount: 1, complete: true }, voiceCoverage: undefined })) } as any + let service: LocalQueryApiService + beforeEach(() => { vi.clearAllMocks(); if (!fixture.contacts.some((contact) => contact.md5 === 'md5-bobo-2')) fixture.contacts.push({ m_nsUsrName: 'wxid-bobo-2', m_nsNickName: 'BOBO', md5: 'md5-bobo-2', type: 'user' }); service = new LocalQueryApiService(knowledge, () => new Date('2026-09-09T12:00:00+08:00')) }) + + it('publishes capability contract and resolves previous month', async () => { + expect(service.capabilities().tools.query_messages.messageTypes).toContain('file') + const result = await service.messages({ target: { query: 'BOBO' }, timeRange: { kind: 'previous_month' }, direction: 'any', order: 'asc', limit: 1 }) + expect(result.status).toBe('ambiguous_contact') + fixture.contacts.splice(1) + const resolved = await service.messages({ target: { query: 'BOBO' }, timeRange: { kind: 'previous_month' }, direction: 'any', order: 'asc', limit: 1 }) + expect(resolved.query?.resolvedTimeRange).toMatchObject({ startTime: expect.any(Number), endTime: expect.any(Number), label: '2026年8月' }) + expect(resolved.returnedCount).toBe(1) + }) + + it('filters direction and message type, and rejects too many variants', async () => { + fixture.contacts.splice(1) + const result = await service.messages({ target: { query: 'BOBO' }, timeRange: { kind: 'all' }, direction: 'to_target', messageTypes: ['file'], limit: 1 }) + expect(result.messages?.[0].sourceKind).toBe('file') + await expect(service.search({ target: { query: 'BOBO' }, timeRange: { kind: 'all' }, query: 'x', variants: ['1', '2', '3', '4', '5'] })).resolves.toMatchObject({ status: 'invalid_request' }) + }) + + it('returns canonical type and attachment for the earliest non-text message', async () => { + fixture.contacts.splice(1) + const result = await service.messages({ target: { query: 'BOBO' }, timeRange: { kind: 'all' }, order: 'asc', limit: 1, excludeSystem: true }) + expect(result.messages?.[0]).toMatchObject({ messageType: 'image', sourceKind: 'image', attachment: { kind: 'image' } }) + expect(result.messages?.[0]).not.toHaveProperty('text') + }) + + it('sorts overview evidence while preserving the selected set and exposes sampling', async () => { + fixture.contacts.splice(1) + knowledge.search.mockResolvedValueOnce({ state: 'ready', evidence: [ + { conversationId: 'md5-bobo', messageId: 'late', timestamp: 300, sender: 'BOBO', sourceKind: 'text', text: 'late' }, + { conversationId: 'md5-bobo', messageId: 'early', timestamp: 100, sender: 'BOBO', sourceKind: 'text', text: 'early' } + ], conversationRetrieval: { totalMessages: 82, chunkCount: 4, complete: true }, voiceCoverage: undefined }) + const result = await service.overview({ target: { query: 'BOBO' }, timeRange: { kind: 'previous_month' } }) + expect(result.sourceCoverage).toEqual({ state: 'complete', sourceMessageCount: 82 }) + expect(result.selection).toEqual({ mode: 'temporal_coverage', selectedEvidenceCount: 2, sampled: true }) + expect(result.evidence?.map((item) => item.timestamp)).toEqual([100, 300]) + }) +})