diff --git a/README.md b/README.md index 9d71701..8f53d53 100644 --- a/README.md +++ b/README.md @@ -212,6 +212,9 @@ npm run mcp - `get_status` - `list_sessions` - `get_messages` +- `list_contacts` +- `search_messages` +- `get_session_context` ### 宿主配置示例(开发态) diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index 31acd34..8f1a6ff 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -27,6 +27,7 @@ export interface ContactInfo { nickname?: string avatarUrl?: string type: 'friend' | 'group' | 'official' | 'former_friend' | 'other' + lastContactTime?: number } export interface Message { @@ -100,6 +101,22 @@ export interface Contact { nickName: string } +function compareMessageCursorAsc( + a: Pick, + b: Pick +): number { + return Number(a.sortSeq || 0) - Number(b.sortSeq || 0) + || Number(a.createTime || 0) - Number(b.createTime || 0) + || Number(a.localId || 0) - Number(b.localId || 0) +} + +function compareMessageCursorDesc( + a: Pick, + b: Pick +): number { + return compareMessageCursorAsc(b, a) +} + // 表情包缓存 const emojiCache: Map = new Map() const emojiDownloading: Map> = new Map() @@ -1001,16 +1018,16 @@ class ChatService extends EventEmitter { n.user_name AS sender_username FROM ${tableName} m LEFT JOIN Name2Id n ON m.real_sender_id = n.rowid - ORDER BY m.sort_seq DESC + ORDER BY m.sort_seq DESC, m.create_time DESC, m.local_id DESC LIMIT ? OFFSET ?` } else if (hasName2Id) { sql = `SELECT m.*, n.user_name AS sender_username FROM ${tableName} m LEFT JOIN Name2Id n ON m.real_sender_id = n.rowid - ORDER BY m.sort_seq DESC + ORDER BY m.sort_seq DESC, m.create_time DESC, m.local_id DESC LIMIT ? OFFSET ?` } else { - sql = `SELECT * FROM ${tableName} ORDER BY sort_seq DESC LIMIT ? OFFSET ?` + sql = `SELECT * FROM ${tableName} ORDER BY sort_seq DESC, create_time DESC, local_id DESC LIMIT ? OFFSET ?` } const stmt = db.prepare(sql) @@ -1248,7 +1265,7 @@ class ChatService extends EventEmitter { } // 按 sort_seq 降序排序(最新的在前) - allMessages.sort((a, b) => b.sortSeq - a.sortSeq) + allMessages.sort(compareMessageCursorDesc) // 去重(同一条消息可能在多个数据库中) const seen = new Set() @@ -1363,7 +1380,7 @@ class ChatService extends EventEmitter { OR (m.sort_seq = ? AND m.create_time < ?) OR (m.sort_seq = ? AND m.create_time = ? AND m.local_id < ?) ) - ORDER BY m.sort_seq DESC + ORDER BY m.sort_seq DESC, m.create_time DESC, m.local_id DESC LIMIT ?` rows = db.prepare(sql).all( myRowId, @@ -1384,7 +1401,7 @@ class ChatService extends EventEmitter { OR (m.sort_seq = ? AND m.create_time < ?) OR (m.sort_seq = ? AND m.create_time = ? AND m.local_id < ?) ) - ORDER BY m.sort_seq DESC + ORDER BY m.sort_seq DESC, m.create_time DESC, m.local_id DESC LIMIT ?` rows = db.prepare(sql).all( cursorSortSeq, @@ -1402,7 +1419,7 @@ class ChatService extends EventEmitter { OR (sort_seq = ? AND create_time < ?) OR (sort_seq = ? AND create_time = ? AND local_id < ?) ) - ORDER BY sort_seq DESC + ORDER BY sort_seq DESC, create_time DESC, local_id DESC LIMIT ?` rows = db.prepare(sql).all( cursorSortSeq, @@ -1536,7 +1553,7 @@ class ChatService extends EventEmitter { } } - allMessages.sort((a, b) => b.sortSeq - a.sortSeq) + allMessages.sort(compareMessageCursorDesc) const seen = new Set() allMessages = allMessages.filter(msg => { @@ -1557,6 +1574,277 @@ class ChatService extends EventEmitter { } } + /** + * 基于 sortSeq 游标,获取更新的消息(严格大于 cursorSortSeq) + */ + async getMessagesAfter( + sessionId: string, + cursorSortSeq: number, + limit: number = 50, + cursorCreateTime?: number, + cursorLocalId?: number + ): Promise<{ success: boolean; messages?: Message[]; hasMore?: boolean; error?: string }> { + try { + if (!this.dbDir) { + const connectResult = await this.connect() + if (!connectResult.success) { + return { success: false, error: connectResult.error || '数据库未连接' } + } + } + + const myWxid = this.configService.get('myWxid') + const cleanedMyWxid = myWxid ? this.cleanAccountDirName(myWxid) : '' + + const dbTablePairs = this.findSessionTables(sessionId) + if (dbTablePairs.length === 0) { + return { success: false, error: '未找到该会话的消息表' } + } + + let allMessages: Message[] = [] + const fetchLimitPerDb = Math.max(limit + 1, 50) + const effectiveCursorCreateTime = cursorCreateTime ?? Number.MIN_SAFE_INTEGER + const effectiveCursorLocalId = cursorLocalId ?? Number.MIN_SAFE_INTEGER + + for (const { db, tableName, dbPath } of dbTablePairs) { + try { + const hasName2IdTable = this.checkTableExists(db, 'Name2Id') + + let myRowId: number | null = null + if (myWxid && hasName2IdTable) { + const cacheKeyOriginal = `${dbPath}:${myWxid}` + const cachedRowIdOriginal = this.myRowIdCache.get(cacheKeyOriginal) + + if (cachedRowIdOriginal !== undefined) { + myRowId = cachedRowIdOriginal + } else { + const row = db.prepare('SELECT rowid FROM Name2Id WHERE user_name = ?').get(myWxid) as any + if (row?.rowid) { + myRowId = row.rowid + this.myRowIdCache.set(cacheKeyOriginal, myRowId) + } else if (cleanedMyWxid && cleanedMyWxid !== myWxid) { + const cacheKeyCleaned = `${dbPath}:${cleanedMyWxid}` + const cachedRowIdCleaned = this.myRowIdCache.get(cacheKeyCleaned) + + if (cachedRowIdCleaned !== undefined) { + myRowId = cachedRowIdCleaned + } else { + const row2 = db.prepare('SELECT rowid FROM Name2Id WHERE user_name = ?').get(cleanedMyWxid) as any + myRowId = row2?.rowid ?? null + this.myRowIdCache.set(cacheKeyCleaned, myRowId) + } + } else { + this.myRowIdCache.set(cacheKeyOriginal, null) + } + } + } + + let sql: string + let rows: any[] + + if (hasName2IdTable && myRowId !== null) { + sql = `SELECT m.*, + CASE WHEN m.real_sender_id = ? THEN 1 ELSE 0 END AS computed_is_send, + n.user_name AS sender_username + FROM ${tableName} m + LEFT JOIN Name2Id n ON m.real_sender_id = n.rowid + WHERE ( + m.sort_seq > ? + OR (m.sort_seq = ? AND m.create_time > ?) + OR (m.sort_seq = ? AND m.create_time = ? AND m.local_id > ?) + ) + ORDER BY m.sort_seq ASC, m.create_time ASC, m.local_id ASC + LIMIT ?` + rows = db.prepare(sql).all( + myRowId, + cursorSortSeq, + cursorSortSeq, + effectiveCursorCreateTime, + cursorSortSeq, + effectiveCursorCreateTime, + effectiveCursorLocalId, + fetchLimitPerDb + ) as any[] + } else if (hasName2IdTable) { + sql = `SELECT m.*, n.user_name AS sender_username + FROM ${tableName} m + LEFT JOIN Name2Id n ON m.real_sender_id = n.rowid + WHERE ( + m.sort_seq > ? + OR (m.sort_seq = ? AND m.create_time > ?) + OR (m.sort_seq = ? AND m.create_time = ? AND m.local_id > ?) + ) + ORDER BY m.sort_seq ASC, m.create_time ASC, m.local_id ASC + LIMIT ?` + rows = db.prepare(sql).all( + cursorSortSeq, + cursorSortSeq, + effectiveCursorCreateTime, + cursorSortSeq, + effectiveCursorCreateTime, + effectiveCursorLocalId, + fetchLimitPerDb + ) as any[] + } else { + sql = `SELECT * FROM ${tableName} + WHERE ( + sort_seq > ? + OR (sort_seq = ? AND create_time > ?) + OR (sort_seq = ? AND create_time = ? AND local_id > ?) + ) + ORDER BY sort_seq ASC, create_time ASC, local_id ASC + LIMIT ?` + rows = db.prepare(sql).all( + cursorSortSeq, + cursorSortSeq, + effectiveCursorCreateTime, + cursorSortSeq, + effectiveCursorCreateTime, + effectiveCursorLocalId, + fetchLimitPerDb + ) as any[] + } + + for (const row of rows) { + const content = this.decodeMessageContent(row.message_content, row.compress_content) + const localType = row.local_type || row.type || 1 + const isSend = row.computed_is_send ?? row.is_send ?? null + + let emojiCdnUrl: string | undefined + let emojiMd5: string | undefined + let emojiProductId: string | undefined + let quotedContent: string | undefined + let quotedSender: string | undefined + let quotedImageMd5: string | undefined + let quotedEmojiMd5: string | undefined + let quotedEmojiCdnUrl: string | undefined + let imageMd5: string | undefined + let imageDatName: string | undefined + let isLivePhoto: boolean | undefined + let videoMd5: string | undefined + let videoDuration: number | undefined + let voiceDuration: number | undefined + + if (localType === 47 && content) { + const emojiInfo = this.parseEmojiInfo(content) + emojiCdnUrl = emojiInfo.cdnUrl + emojiMd5 = emojiInfo.md5 + emojiProductId = emojiInfo.productId + } else if (localType === 3 && content) { + const imageInfo = this.parseImageInfo(content) + imageMd5 = imageInfo.md5 + imageDatName = this.parseImageDatNameFromRow(row) + isLivePhoto = imageInfo.isLivePhoto + } else if (localType === 43 && content) { + videoMd5 = this.parseVideoMd5(content) + videoDuration = this.parseVideoDuration(content) + } else if (localType === 34 && content) { + voiceDuration = this.parseVoiceDuration(content) + } else if (localType === 244813135921 || (content && content.includes('57'))) { + const quoteInfo = this.parseQuoteMessage(content) + quotedContent = quoteInfo.content + quotedSender = quoteInfo.sender + quotedImageMd5 = quoteInfo.imageMd5 + quotedEmojiMd5 = quoteInfo.emojiMd5 + quotedEmojiCdnUrl = quoteInfo.emojiCdnUrl + } + + let fileName: string | undefined + let fileSize: number | undefined + let fileExt: string | undefined + let fileMd5: string | undefined + if (localType === 49 && content) { + const fileInfo = this.parseFileInfo(content) + fileName = fileInfo.fileName + fileSize = fileInfo.fileSize + fileExt = fileInfo.fileExt + fileMd5 = fileInfo.fileMd5 + } + + let chatRecordList: ChatRecordItem[] | undefined + if (content) { + const xmlType = this.extractXmlValue(content, 'type') + if (xmlType === '19' || localType === 49) { + chatRecordList = this.parseChatHistory(content) + } + } + + let transferPayerUsername: string | undefined + let transferReceiverUsername: string | undefined + if ((localType === 49 || localType === 8589934592049) && content) { + const xmlType = this.extractXmlValue(content, 'type') + if (xmlType === '2000') { + transferPayerUsername = this.extractXmlValue(content, 'payer_username') || undefined + transferReceiverUsername = this.extractXmlValue(content, 'receiver_username') || undefined + } + } + + const parsedContent = this.parseMessageContent(content, localType) + + allMessages.push({ + localId: row.local_id || 0, + serverId: row.server_id || 0, + localType, + createTime: row.create_time || 0, + sortSeq: row.sort_seq || 0, + isSend, + senderUsername: row.sender_username || null, + parsedContent, + rawContent: content, + emojiCdnUrl, + emojiMd5, + productId: emojiProductId, + quotedContent, + quotedSender, + quotedImageMd5, + quotedEmojiMd5, + quotedEmojiCdnUrl, + imageMd5, + imageDatName, + isLivePhoto, + videoMd5, + videoDuration, + voiceDuration, + fileName, + fileSize, + fileExt, + fileMd5, + chatRecordList, + transferPayerUsername, + transferReceiverUsername + }) + } + } catch (e: any) { + if (e?.code === 'SQLITE_CORRUPT' || e?.message?.includes('malformed')) { + console.error(`[ChatService] 数据库损坏: ${dbPath}`, e) + this.messageDbCache.delete(dbPath) + try { db.close() } catch { } + this.refreshMessageDbCache() + } else { + console.error('ChatService: 查询更新消息失败:', e) + } + } + } + + allMessages.sort(compareMessageCursorAsc) + + const seen = new Set() + allMessages = allMessages.filter(msg => { + const key = `${msg.serverId}-${msg.localId}-${msg.createTime}-${msg.sortSeq}` + if (seen.has(key)) return false + seen.add(key) + return true + }) + + const hasMore = allMessages.length > limit + const messages = allMessages.slice(0, limit) + + return { success: true, messages, hasMore } + } catch (e) { + console.error('ChatService: 获取更新消息失败:', e) + return { success: false, error: String(e) } + } + } + /** * 获取会话的所有语音消息(用于批量转写) * 复用 getMessages 的查询逻辑,只查询语音消息类型 @@ -1693,7 +1981,7 @@ class ChatService extends EventEmitter { } // 按 sort_seq 降序排序 - allVoiceMessages.sort((a, b) => b.sortSeq - a.sortSeq) + allVoiceMessages.sort(compareMessageCursorDesc) // 去重 const seen = new Set() diff --git a/electron/services/mcp/service.ts b/electron/services/mcp/service.ts index 547a4db..b134c01 100644 --- a/electron/services/mcp/service.ts +++ b/electron/services/mcp/service.ts @@ -2,12 +2,38 @@ import { existsSync, mkdirSync } from 'fs' import { writeFile } from 'fs/promises' import { join } from 'path' import { z } from 'zod' -import { chatService } from '../chatService' +import { chatService, type ChatSession, type ContactInfo, type Message } from '../chatService' import { ConfigService } from '../config' import { imageDecryptService } from '../imageDecryptService' import { videoService } from '../videoService' import { McpToolError } from './result' -import type { McpMessageItem, McpMessagesPayload, McpSessionItem, McpSessionsPayload } from './types' +import { + MCP_CONTACT_KINDS, + MCP_MESSAGE_KINDS, + type McpContactItem, + type McpContactKind, + type McpContactsPayload, + type McpCursor, + type McpMessageItem, + type McpMessageKind, + type McpMessageMatchField, + type McpMessagesPayload, + type McpSearchHit, + type McpSearchMessagesPayload, + type McpSessionContextPayload, + type McpSessionItem, + type McpSessionKind, + type McpSessionRef, + type McpSessionsPayload +} from './types' + +const MAX_LIST_LIMIT = 200 +const MAX_SEARCH_LIMIT = 100 +const MAX_CONTEXT_LIMIT = 100 +const SEARCH_BATCH_SIZE = 200 +const MAX_SEARCH_SESSIONS = 20 +const MAX_SCAN_PER_SESSION = 1000 +const MAX_SCAN_GLOBAL = 10000 const listSessionsArgsSchema = z.object({ q: z.string().optional(), @@ -28,22 +54,81 @@ const getMessagesArgsSchema = z.object({ includeMediaPaths: z.boolean().optional() }) +const listContactsArgsSchema = z.object({ + q: z.string().optional(), + offset: z.number().int().nonnegative().optional(), + limit: z.number().int().positive().optional(), + types: z.array(z.enum(MCP_CONTACT_KINDS)).optional() +}) + +const searchMessagesArgsSchema = z.object({ + query: z.string().trim().min(1), + sessionId: z.string().trim().min(1).optional(), + sessionIds: z.array(z.string().trim().min(1)).max(MAX_SEARCH_SESSIONS).optional(), + startTime: z.number().int().positive().optional(), + endTime: z.number().int().positive().optional(), + kinds: z.array(z.enum(MCP_MESSAGE_KINDS)).optional(), + direction: z.enum(['in', 'out']).optional(), + senderUsername: z.string().trim().min(1).optional(), + limit: z.number().int().positive().optional(), + includeRaw: z.boolean().optional(), + includeMediaPaths: z.boolean().optional() +}) + +const cursorSchema = z.object({ + sortSeq: z.number().int(), + createTime: z.number().int().positive(), + localId: z.number().int() +}) + +const getSessionContextArgsSchema = z.object({ + sessionId: z.string().trim().min(1), + mode: z.enum(['latest', 'around']), + anchorCursor: cursorSchema.optional(), + beforeLimit: z.number().int().positive().optional(), + afterLimit: z.number().int().positive().optional(), + includeRaw: z.boolean().optional(), + includeMediaPaths: z.boolean().optional() +}).superRefine((value, ctx) => { + if (value.mode === 'around' && !value.anchorCursor) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['anchorCursor'], + message: 'anchorCursor is required when mode=around' + }) + } +}) + type ListSessionsArgs = z.infer type GetMessagesArgs = z.infer +type ListContactsArgs = z.infer +type SearchMessagesArgs = z.infer +type GetSessionContextArgs = z.infer +type ContactWithLastContact = ContactInfo & { lastContactTime?: number } +type MessageNormalizeOptions = { + includeMediaPaths: boolean + includeRaw: boolean +} +type SearchRawHit = { + session: McpSessionRef + message: Message + matchedField: McpMessageMatchField + excerpt: string +} -function toTimestampMs(value?: number | null): number | null { - if (!value || !Number.isFinite(value) || value <= 0) return null +function toTimestampMs(value?: number | null): number { + if (!value || !Number.isFinite(value) || value <= 0) return 0 return value < 1_000_000_000_000 ? value * 1000 : value } -function detectSessionKind(sessionId: string): McpSessionItem['kind'] { +function detectSessionKind(sessionId: string): McpSessionKind { if (sessionId.includes('@chatroom')) return 'group' if (sessionId.startsWith('gh_')) return 'official' if (sessionId) return 'friend' return 'other' } -function detectMessageKind(message: Record): string { +function detectMessageKind(message: Pick): McpMessageKind { const localType = Number(message.localType || 0) const raw = String(message.rawContent || message.parsedContent || '') const xmlTypeMatch = raw.match(/\s*([^<]+)\s*<\/type>/i) @@ -94,6 +179,128 @@ function detectMessageKind(message: Record): string { return 'unknown' } +function compareMessageCursorAsc( + a: Pick, + b: Pick +): number { + return Number(a.sortSeq || 0) - Number(b.sortSeq || 0) + || Number(a.createTime || 0) - Number(b.createTime || 0) + || Number(a.localId || 0) - Number(b.localId || 0) +} + +function compareMessageCursorDesc( + a: Pick, + b: Pick +): number { + return compareMessageCursorAsc(b, a) +} + +function buildCursor(message: Pick): McpCursor { + return { + sortSeq: Number(message.sortSeq || 0), + createTime: Number(message.createTime || 0), + localId: Number(message.localId || 0) + } +} + +function sameCursor( + message: Pick, + cursor: McpCursor +): boolean { + return Number(message.sortSeq || 0) === cursor.sortSeq + && Number(message.createTime || 0) === cursor.createTime + && Number(message.localId || 0) === cursor.localId +} + +function uniqueMessageList(messages: Message[]): Message[] { + const seen = new Set() + return messages.filter((message) => { + const key = `${message.serverId}-${message.localId}-${message.createTime}-${message.sortSeq}` + if (seen.has(key)) return false + seen.add(key) + return true + }) +} + +function normalizeQuery(value?: string): string { + return String(value || '').trim().toLowerCase() +} + +function createExcerpt(source: string, matchedIndex: number, queryLength: number): string { + if (!source) return '' + const radius = 48 + const safeIndex = Math.max(0, matchedIndex) + const start = Math.max(0, safeIndex - radius) + const end = Math.min(source.length, safeIndex + queryLength + radius) + const prefix = start > 0 ? '...' : '' + const suffix = end < source.length ? '...' : '' + return `${prefix}${source.slice(start, end)}${suffix}` +} + +function findKeywordMatch(message: Message, query: string): { matchedField: McpMessageMatchField; excerpt: string } | null { + const normalizedQuery = normalizeQuery(query) + if (!normalizedQuery) return null + + const text = String(message.parsedContent || '') + const raw = String(message.rawContent || '') + const textIndex = text.toLowerCase().indexOf(normalizedQuery) + if (textIndex >= 0) { + return { + matchedField: 'text', + excerpt: createExcerpt(text, textIndex, normalizedQuery.length) + } + } + + const rawIndex = raw.toLowerCase().indexOf(normalizedQuery) + if (rawIndex >= 0) { + return { + matchedField: 'raw', + excerpt: createExcerpt(raw, rawIndex, normalizedQuery.length) + } + } + + return null +} + +function toSessionRef(session: Pick): McpSessionRef { + return { + sessionId: session.username, + displayName: session.displayName || session.username, + kind: detectSessionKind(session.username) + } +} + +function toSessionItem(session: ChatSession): McpSessionItem { + return { + ...toSessionRef(session), + lastMessagePreview: session.summary || '', + unreadCount: Number(session.unreadCount || 0), + lastTimestamp: Number(session.lastTimestamp || 0), + lastTimestampMs: toTimestampMs(Number(session.lastTimestamp || 0)) + } +} + +function toContactItem(contact: ContactWithLastContact): McpContactItem { + const lastContactTimestamp = Number(contact.lastContactTime || 0) + return { + contactId: contact.username, + displayName: contact.displayName, + remark: contact.remark || undefined, + nickname: contact.nickname || undefined, + kind: contact.type as McpContactKind, + lastContactTimestamp, + lastContactTimestampMs: toTimestampMs(lastContactTimestamp) + } +} + +function resolveSessionRef(sessionId: string, sessionMap: Map): McpSessionRef { + return sessionMap.get(sessionId) || { + sessionId, + displayName: sessionId, + kind: detectSessionKind(sessionId) + } +} + function mapChatError(errorMessage?: string): never { const message = errorMessage || 'Unknown chat service error.' @@ -103,7 +310,8 @@ function mapChatError(errorMessage?: string): never { message.includes('未找到账号') || message.includes('未找到 session.db') || message.includes('未找到会话表') || - message.includes('数据库未连接') + message.includes('数据库未连接') || + message.includes('联系人数据库未连接') ) { throw new McpToolError('DB_NOT_READY', 'Chat database is not ready.', message) } @@ -115,18 +323,15 @@ function mapChatError(errorMessage?: string): never { throw new McpToolError('INTERNAL_ERROR', 'Failed to query CipherTalk data.', message) } -async function getEmojiLocalPath(base: Record): Promise { - const emojiMd5 = base.emojiMd5 as string | undefined - const emojiCdnUrl = base.emojiCdnUrl as string | undefined - - if (!emojiMd5 && !emojiCdnUrl) return null +async function getEmojiLocalPath(message: Message): Promise { + if (!message.emojiMd5 && !message.emojiCdnUrl) return null try { const result = await chatService.downloadEmoji( - String(emojiCdnUrl || ''), - emojiMd5, - base.productId as string | undefined, - Number(base.createTime || 0) + String(message.emojiCdnUrl || ''), + message.emojiMd5, + message.productId, + Number(message.createTime || 0) ) return result.success ? result.cachePath || result.localPath || null : null @@ -135,14 +340,14 @@ async function getEmojiLocalPath(base: Record): Promise): Promise { - if (!base.imageMd5 && !base.imageDatName) return null +async function getImageLocalPath(sessionId: string, message: Message): Promise { + if (!message.imageMd5 && !message.imageDatName) return null try { const resolved = await imageDecryptService.resolveCachedImage({ sessionId, - imageMd5: base.imageMd5 as string | undefined, - imageDatName: base.imageDatName as string | undefined + imageMd5: message.imageMd5, + imageDatName: message.imageDatName }) if (resolved.success && resolved.localPath) { @@ -151,8 +356,8 @@ async function getImageLocalPath(sessionId: string, base: Record): string | null { - if (!base.videoMd5) return null +function getVideoLocalPath(message: Message): string | null { + if (!message.videoMd5) return null try { - const info = videoService.getVideoInfo(String(base.videoMd5)) + const info = videoService.getVideoInfo(String(message.videoMd5)) return info.exists ? info.videoUrl || null : null } catch { return null } } -async function getVoiceLocalPath(sessionId: string, base: Record): Promise { - const localId = Number(base.localId || 0) - const createTime = Number(base.createTime || 0) +async function getVoiceLocalPath(sessionId: string, message: Message): Promise { + const localId = Number(message.localId || 0) + const createTime = Number(message.createTime || 0) if (!localId || !createTime) return null try { @@ -200,8 +405,8 @@ async function getVoiceLocalPath(sessionId: string, base: Record): string | null { - const fileName = String(base.fileName || '') +function getFileLocalPath(message: Message): string | null { + const fileName = String(message.fileName || '') if (!fileName) return null const configService = new ConfigService() @@ -210,7 +415,7 @@ function getFileLocalPath(base: Record): string | null { const myWxid = String(configService.get('myWxid') || '') if (!dbPath || !myWxid) return null - const createTimeMs = toTimestampMs(Number(base.createTime || 0)) + const createTimeMs = toTimestampMs(Number(message.createTime || 0)) const fileDate = createTimeMs ? new Date(createTimeMs) : new Date() const monthDir = `${fileDate.getFullYear()}-${String(fileDate.getMonth() + 1).padStart(2, '0')}` return join(dbPath, myWxid, 'msg', 'file', monthDir, fileName) @@ -219,71 +424,77 @@ function getFileLocalPath(base: Record): string | null { } } -async function toMcpMessage(sessionId: string, includeMediaPaths: boolean, includeRaw: boolean, message: Record): Promise { +async function normalizeMessage( + sessionId: string, + message: Message, + options: MessageNormalizeOptions +): Promise { const kind = detectMessageKind(message) const direction = Number(message.isSend) === 1 ? 'out' : 'in' - const base: McpMessageItem = { + const normalized: McpMessageItem = { messageId: Number(message.localId || message.serverId || 0), timestamp: Number(message.createTime || 0), + timestampMs: toTimestampMs(Number(message.createTime || 0)), direction, kind, text: String(message.parsedContent || message.rawContent || ''), sender: { - username: (message.senderUsername as string | null) ?? null, + username: message.senderUsername ?? null, isSelf: direction === 'out' - } + }, + cursor: buildCursor(message) } - if (includeRaw) { - base.raw = String(message.rawContent || '') + if (options.includeRaw) { + normalized.raw = String(message.rawContent || '') } switch (kind) { case 'emoji': - base.media = { + normalized.media = { type: 'emoji', - md5: (message.emojiMd5 as string | undefined) || null + md5: message.emojiMd5 || null } - if (includeMediaPaths) { - base.media.localPath = await getEmojiLocalPath(message) + if (options.includeMediaPaths) { + normalized.media.localPath = await getEmojiLocalPath(message) } break case 'image': - base.media = { + normalized.media = { type: 'image', - md5: (message.imageMd5 as string | undefined) || null, + md5: message.imageMd5 || null, isLivePhoto: Boolean(message.isLivePhoto) } - if (includeMediaPaths) { - base.media.localPath = await getImageLocalPath(sessionId, message) + if (options.includeMediaPaths) { + normalized.media.localPath = await getImageLocalPath(sessionId, message) } break case 'video': - base.media = { + normalized.media = { type: 'video', - md5: (message.videoMd5 as string | undefined) || null, + md5: message.videoMd5 || null, durationSeconds: Number(message.videoDuration || 0) || null, isLivePhoto: Boolean(message.isLivePhoto) } - if (includeMediaPaths) { - base.media.localPath = getVideoLocalPath(message) + if (options.includeMediaPaths) { + normalized.media.localPath = getVideoLocalPath(message) } break case 'voice': - base.media = { + normalized.media = { type: 'voice', durationSeconds: Number(message.voiceDuration || 0) || null } - if (includeMediaPaths) { - base.media.localPath = await getVoiceLocalPath(sessionId, message) + if (options.includeMediaPaths) { + normalized.media.localPath = await getVoiceLocalPath(sessionId, message) } break case 'app_file': { - const localPath = includeMediaPaths ? getFileLocalPath(message) : null - base.media = { + const localPath = options.includeMediaPaths ? getFileLocalPath(message) : null + normalized.media = { type: 'file', - md5: (message.fileMd5 as string | undefined) || null, - fileName: (message.fileName as string | undefined) || null, + md5: message.fileMd5 || null, + fileName: message.fileName || null, fileSize: Number(message.fileSize || 0) || null, localPath, exists: localPath ? existsSync(localPath) : null @@ -294,7 +505,69 @@ async function toMcpMessage(sessionId: string, includeMediaPaths: boolean, inclu break } - return base + return normalized +} + +async function normalizeMessages( + sessionId: string, + messages: Message[], + options: MessageNormalizeOptions +): Promise { + return Promise.all(messages.map((message) => normalizeMessage(sessionId, message, options))) +} + +async function getSessionCatalog(): Promise<{ items: McpSessionItem[]; map: Map }> { + const result = await chatService.getSessions() + if (!result.success) { + mapChatError(result.error) + } + + const items = (result.sessions || []) + .map((session) => toSessionItem(session)) + .sort((a, b) => b.lastTimestamp - a.lastTimestamp || a.displayName.localeCompare(b.displayName, 'zh-CN')) + + const map = new Map() + for (const item of items) { + map.set(item.sessionId, { + sessionId: item.sessionId, + displayName: item.displayName, + kind: item.kind + }) + } + + return { items, map } +} + +function messageMatchesFilters( + message: Message, + filters: { + startTimeMs?: number + endTimeMs?: number + kinds?: Set + direction?: 'in' | 'out' + senderUsername?: string + } +): boolean { + const timestampMs = toTimestampMs(Number(message.createTime || 0)) + if (filters.startTimeMs && timestampMs < filters.startTimeMs) return false + if (filters.endTimeMs && timestampMs > filters.endTimeMs) return false + + if (filters.kinds?.size) { + const kind = detectMessageKind(message) + if (!filters.kinds.has(kind)) return false + } + + if (filters.direction) { + const direction = Number(message.isSend) === 1 ? 'out' : 'in' + if (direction !== filters.direction) return false + } + + if (filters.senderUsername) { + const senderUsername = String(message.senderUsername || '').trim().toLowerCase() + if (senderUsername !== filters.senderUsername) return false + } + + return true } export class McpReadService { @@ -304,24 +577,12 @@ export class McpReadService { throw new McpToolError('BAD_REQUEST', 'Invalid list_sessions arguments.', args.error.message) } - const query = String(args.data.q || '').trim().toLowerCase() + const query = normalizeQuery(args.data.q) const offset = Math.max(0, args.data.offset ?? 0) - const limit = Math.min(args.data.limit ?? 100, 200) + const limit = Math.min(args.data.limit ?? 100, MAX_LIST_LIMIT) const unreadOnly = Boolean(args.data.unreadOnly) - const result = await chatService.getSessions() - if (!result.success) { - mapChatError(result.error) - } - - let sessions = (result.sessions || []).map((session) => ({ - sessionId: session.username, - displayName: session.displayName || session.username, - kind: detectSessionKind(session.username), - lastMessagePreview: session.summary || '', - unreadCount: Number(session.unreadCount || 0), - lastTimestamp: Number(session.lastTimestamp || 0) - } satisfies McpSessionItem)) + let sessions = (await getSessionCatalog()).items if (query) { sessions = sessions.filter((session) => { @@ -337,8 +598,6 @@ export class McpReadService { sessions = sessions.filter((session) => session.unreadCount > 0) } - sessions.sort((a, b) => b.lastTimestamp - a.lastTimestamp) - const total = sessions.length const items = sessions.slice(offset, offset + limit) @@ -351,6 +610,51 @@ export class McpReadService { } } + async listContacts(rawArgs: ListContactsArgs): Promise { + const args = listContactsArgsSchema.safeParse(rawArgs) + if (!args.success) { + throw new McpToolError('BAD_REQUEST', 'Invalid list_contacts arguments.', args.error.message) + } + + const query = normalizeQuery(args.data.q) + const offset = Math.max(0, args.data.offset ?? 0) + const limit = Math.min(args.data.limit ?? 100, MAX_LIST_LIMIT) + const typeSet = args.data.types?.length ? new Set(args.data.types) : null + + const result = await chatService.getContacts() + if (!result.success) { + mapChatError(result.error) + } + + let contacts = (result.contacts || []).map((contact) => toContactItem(contact as ContactWithLastContact)) + + if (typeSet) { + contacts = contacts.filter((contact) => typeSet.has(contact.kind)) + } + + if (query) { + contacts = contacts.filter((contact) => { + return [ + contact.contactId, + contact.displayName, + contact.remark || '', + contact.nickname || '' + ].some((value) => value.toLowerCase().includes(query)) + }) + } + + const total = contacts.length + const items = contacts.slice(offset, offset + limit) + + return { + items, + total, + offset, + limit, + hasMore: offset + items.length < total + } + } + async getMessages(rawArgs: GetMessagesArgs, defaultIncludeMediaPaths: boolean): Promise { const args = getMessagesArgsSchema.safeParse(rawArgs) if (!args.success) { @@ -365,22 +669,20 @@ export class McpReadService { } = args.data const offset = Math.max(0, args.data.offset ?? 0) - const limit = Math.min(args.data.limit ?? 50, 200) + const limit = Math.min(args.data.limit ?? 50, MAX_LIST_LIMIT) const includeMediaPaths = args.data.includeMediaPaths ?? defaultIncludeMediaPaths - const keywordQuery = String(keyword || '').trim().toLowerCase() + const keywordQuery = normalizeQuery(keyword) const startTimeMs = toTimestampMs(args.data.startTime) const endTimeMs = toTimestampMs(args.data.endTime) - const matched: Record[] = [] - const batchSize = 200 - const maxScan = 5000 + const matched: Message[] = [] let scanOffset = 0 let scanned = 0 let reachedEnd = false const targetCount = offset + limit + 1 - while (scanned < maxScan && matched.length < targetCount) { - const result = await chatService.getMessages(sessionId, scanOffset, batchSize) + while (scanned < 5000 && matched.length < targetCount) { + const result = await chatService.getMessages(sessionId, scanOffset, SEARCH_BATCH_SIZE) if (!result.success) { mapChatError(result.error) } @@ -392,17 +694,9 @@ export class McpReadService { } for (const message of part) { - const timestampMs = toTimestampMs(Number(message.createTime || 0)) || 0 - const parsedContent = String(message.parsedContent || '') - const rawContent = String(message.rawContent || '') - - if (startTimeMs && timestampMs < startTimeMs) continue - if (endTimeMs && timestampMs > endTimeMs) continue - if (keywordQuery && !parsedContent.toLowerCase().includes(keywordQuery) && !rawContent.toLowerCase().includes(keywordQuery)) { - continue - } - - matched.push(message as unknown as Record) + if (!messageMatchesFilters(message, { startTimeMs, endTimeMs })) continue + if (keywordQuery && !findKeywordMatch(message, keywordQuery)) continue + matched.push(message) } scanOffset += part.length @@ -414,18 +708,10 @@ export class McpReadService { } } - matched.sort((a, b) => { - const timeDelta = Number(a.createTime || 0) - Number(b.createTime || 0) - if (timeDelta !== 0) { - return order === 'asc' ? timeDelta : -timeDelta - } - - const idDelta = Number(a.localId || 0) - Number(b.localId || 0) - return order === 'asc' ? idDelta : -idDelta - }) + matched.sort((a, b) => order === 'asc' ? compareMessageCursorAsc(a, b) : compareMessageCursorDesc(a, b)) const page = matched.slice(offset, offset + limit) - const items = await Promise.all(page.map((message) => toMcpMessage(sessionId, includeMediaPaths, includeRaw, message))) + const items = await normalizeMessages(sessionId, page, { includeMediaPaths, includeRaw }) return { items, @@ -434,4 +720,217 @@ export class McpReadService { hasMore: reachedEnd ? matched.length > offset + items.length : true } } + + async searchMessages(rawArgs: SearchMessagesArgs, defaultIncludeMediaPaths: boolean): Promise { + const args = searchMessagesArgsSchema.safeParse(rawArgs) + if (!args.success) { + throw new McpToolError('BAD_REQUEST', 'Invalid search_messages arguments.', args.error.message) + } + + const { items: sessions, map: sessionMap } = await getSessionCatalog() + const includeRaw = args.data.includeRaw ?? false + const includeMediaPaths = args.data.includeMediaPaths ?? defaultIncludeMediaPaths + const limit = Math.min(args.data.limit ?? 20, MAX_SEARCH_LIMIT) + const sessionIdCandidates = Array.from(new Set([ + ...(args.data.sessionId ? [args.data.sessionId] : []), + ...(args.data.sessionIds || []) + ])) + + if (sessionIdCandidates.length > MAX_SEARCH_SESSIONS) { + throw new McpToolError('BAD_REQUEST', `At most ${MAX_SEARCH_SESSIONS} sessionIds can be searched at once.`) + } + + const targetSessions = sessionIdCandidates.length > 0 + ? sessionIdCandidates.map((sessionId) => resolveSessionRef(sessionId, sessionMap)) + : sessions.slice(0, MAX_SEARCH_SESSIONS).map((session) => ({ + sessionId: session.sessionId, + displayName: session.displayName, + kind: session.kind + })) + + const kindSet = args.data.kinds?.length ? new Set(args.data.kinds) : undefined + const senderUsername = normalizeQuery(args.data.senderUsername) + const startTimeMs = toTimestampMs(args.data.startTime) + const endTimeMs = toTimestampMs(args.data.endTime) + + const rawHits: SearchRawHit[] = [] + let sessionsScanned = 0 + let messagesScanned = 0 + let truncated = false + + for (const session of targetSessions) { + sessionsScanned += 1 + + let sessionOffset = 0 + let sessionScanned = 0 + + while (sessionScanned < MAX_SCAN_PER_SESSION && messagesScanned < MAX_SCAN_GLOBAL) { + const fetchLimit = Math.min( + SEARCH_BATCH_SIZE, + MAX_SCAN_PER_SESSION - sessionScanned, + MAX_SCAN_GLOBAL - messagesScanned + ) + + if (fetchLimit <= 0) { + truncated = true + break + } + + const result = await chatService.getMessages(session.sessionId, sessionOffset, fetchLimit) + if (!result.success) { + mapChatError(result.error) + } + + const part = result.messages || [] + if (part.length === 0) break + + sessionOffset += part.length + sessionScanned += part.length + messagesScanned += part.length + + for (const message of part) { + if (!messageMatchesFilters(message, { + startTimeMs, + endTimeMs, + kinds: kindSet, + direction: args.data.direction, + senderUsername + })) { + continue + } + + const match = findKeywordMatch(message, args.data.query) + if (!match) continue + + rawHits.push({ + session, + message, + matchedField: match.matchedField, + excerpt: match.excerpt + }) + } + + if (!result.hasMore) break + } + + if (messagesScanned >= MAX_SCAN_GLOBAL) { + truncated = true + break + } + } + + rawHits.sort((a, b) => compareMessageCursorDesc(a.message, b.message)) + + const hits = await Promise.all(rawHits.slice(0, limit).map(async (hit): Promise => ({ + session: hit.session, + message: await normalizeMessage(hit.session.sessionId, hit.message, { + includeMediaPaths, + includeRaw + }), + excerpt: hit.excerpt, + matchedField: hit.matchedField + }))) + + return { + hits, + limit, + sessionsScanned, + messagesScanned, + truncated + } + } + + async getSessionContext(rawArgs: GetSessionContextArgs, defaultIncludeMediaPaths: boolean): Promise { + const args = getSessionContextArgsSchema.safeParse(rawArgs) + if (!args.success) { + throw new McpToolError('BAD_REQUEST', 'Invalid get_session_context arguments.', args.error.message) + } + + const { map: sessionMap } = await getSessionCatalog() + const session = resolveSessionRef(args.data.sessionId, sessionMap) + const includeRaw = args.data.includeRaw ?? false + const includeMediaPaths = args.data.includeMediaPaths ?? defaultIncludeMediaPaths + + if (args.data.mode === 'latest') { + const latestLimit = Math.min(args.data.beforeLimit ?? 30, MAX_CONTEXT_LIMIT) + const result = await chatService.getMessages(args.data.sessionId, 0, latestLimit) + if (!result.success) { + mapChatError(result.error) + } + + const messages = await normalizeMessages(args.data.sessionId, result.messages || [], { + includeMediaPaths, + includeRaw + }) + + return { + session, + mode: 'latest', + items: messages, + hasMoreBefore: Boolean(result.hasMore), + hasMoreAfter: false + } + } + + const anchorCursor = args.data.anchorCursor! + const beforeLimit = Math.min(args.data.beforeLimit ?? 20, MAX_CONTEXT_LIMIT) + const afterLimit = Math.min(args.data.afterLimit ?? 20, MAX_CONTEXT_LIMIT) + + const [beforeResult, anchorResult, afterResult] = await Promise.all([ + chatService.getMessagesBefore( + args.data.sessionId, + anchorCursor.sortSeq, + beforeLimit, + anchorCursor.createTime, + anchorCursor.localId + ), + chatService.getMessagesAfter( + args.data.sessionId, + anchorCursor.sortSeq, + 1, + anchorCursor.createTime, + anchorCursor.localId - 1 + ), + chatService.getMessagesAfter( + args.data.sessionId, + anchorCursor.sortSeq, + afterLimit, + anchorCursor.createTime, + anchorCursor.localId + ) + ]) + + if (!beforeResult.success) mapChatError(beforeResult.error) + if (!anchorResult.success) mapChatError(anchorResult.error) + if (!afterResult.success) mapChatError(afterResult.error) + + const anchorMessage = (anchorResult.messages || []).find((message) => sameCursor(message, anchorCursor)) + if (!anchorMessage) { + throw new McpToolError('BAD_REQUEST', 'Anchor cursor was not found in this session.') + } + + const [beforeItems, anchorItem, afterItems] = await Promise.all([ + normalizeMessages(args.data.sessionId, beforeResult.messages || [], { + includeMediaPaths, + includeRaw + }), + normalizeMessage(args.data.sessionId, anchorMessage, { + includeMediaPaths, + includeRaw + }), + normalizeMessages(args.data.sessionId, afterResult.messages || [], { + includeMediaPaths, + includeRaw + }) + ]) + + return { + session, + mode: 'around', + anchor: anchorItem, + items: [...beforeItems, anchorItem, ...afterItems], + hasMoreBefore: Boolean(beforeResult.hasMore), + hasMoreAfter: Boolean(afterResult.hasMore) + } + } } diff --git a/electron/services/mcp/tools.ts b/electron/services/mcp/tools.ts index 72af846..8b6b791 100644 --- a/electron/services/mcp/tools.ts +++ b/electron/services/mcp/tools.ts @@ -2,6 +2,7 @@ import { z } from 'zod' import { createToolError, createToolSuccess } from './result' import { getMcpConfigSnapshot, getMcpHealthPayload, getMcpStatusPayload } from './runtime' import { McpReadService } from './service' +import { MCP_CONTACT_KINDS, MCP_MESSAGE_KINDS } from './types' const readService = new McpReadService() @@ -71,4 +72,74 @@ export function registerCipherTalkMcpTools(server: any) { return createToolError(error) } }) + + server.registerTool('list_contacts', { + title: 'List Contacts', + description: 'List contacts, groups, and official accounts for agent-side resolution.', + inputSchema: { + q: z.string().optional().describe('Optional search keyword.'), + offset: z.number().int().nonnegative().optional().describe('Pagination offset.'), + limit: z.number().int().positive().optional().describe('Pagination limit.'), + types: z.array(z.enum(MCP_CONTACT_KINDS)).optional().describe('Optional contact kinds to include.') + } + }, async (args: unknown) => { + try { + const payload = await readService.listContacts((args || {}) as any) + return createToolSuccess(`Loaded ${payload.items.length} contacts.`, payload) + } catch (error) { + return createToolError(error) + } + }) + + server.registerTool('search_messages', { + title: 'Search Messages', + description: 'Search messages across one or more sessions and return agent-friendly hits.', + inputSchema: { + query: z.string().trim().min(1).describe('Required full-text query.'), + sessionId: z.string().trim().min(1).optional().describe('Single session identifier to search.'), + sessionIds: z.array(z.string().trim().min(1)).max(20).optional().describe('Multiple session identifiers to search.'), + startTime: z.number().int().positive().optional().describe('Start timestamp in seconds or milliseconds.'), + endTime: z.number().int().positive().optional().describe('End timestamp in seconds or milliseconds.'), + kinds: z.array(z.enum(MCP_MESSAGE_KINDS)).optional().describe('Optional message kinds to include.'), + direction: z.enum(['in', 'out']).optional().describe('Optional direction filter.'), + senderUsername: z.string().trim().min(1).optional().describe('Optional sender username filter.'), + limit: z.number().int().positive().optional().describe('Maximum number of hits to return.'), + includeRaw: z.boolean().optional().describe('Include raw message content when true.'), + includeMediaPaths: z.boolean().optional().describe('Resolve media local paths when true.') + } + }, async (args: unknown) => { + try { + const defaults = getMcpConfigSnapshot() + const payload = await readService.searchMessages((args || {}) as any, defaults.mcpExposeMediaPaths) + return createToolSuccess(`Loaded ${payload.hits.length} message hits.`, payload) + } catch (error) { + return createToolError(error) + } + }) + + server.registerTool('get_session_context', { + title: 'Get Session Context', + description: 'Return the latest session context or messages around a cursor anchor.', + inputSchema: { + sessionId: z.string().trim().min(1).describe('Required session identifier / username.'), + mode: z.enum(['latest', 'around']).describe('Context mode.'), + anchorCursor: z.object({ + sortSeq: z.number().int(), + createTime: z.number().int().positive(), + localId: z.number().int() + }).optional().describe('Required cursor when mode=around.'), + beforeLimit: z.number().int().positive().optional().describe('Latest count or before-context count.'), + afterLimit: z.number().int().positive().optional().describe('After-context count when mode=around.'), + includeRaw: z.boolean().optional().describe('Include raw message content when true.'), + includeMediaPaths: z.boolean().optional().describe('Resolve media local paths when true.') + } + }, async (args: unknown) => { + try { + const defaults = getMcpConfigSnapshot() + const payload = await readService.getSessionContext((args || {}) as any, defaults.mcpExposeMediaPaths) + return createToolSuccess(`Loaded ${payload.items.length} context messages.`, payload) + } catch (error) { + return createToolError(error) + } + }) } diff --git a/electron/services/mcp/types.ts b/electron/services/mcp/types.ts index 8f78d78..ce87e7f 100644 --- a/electron/services/mcp/types.ts +++ b/electron/services/mcp/types.ts @@ -2,13 +2,55 @@ export const MCP_TOOL_NAMES = [ 'health_check', 'get_status', 'list_sessions', - 'get_messages' + 'get_messages', + 'list_contacts', + 'search_messages', + 'get_session_context' +] as const + +export const MCP_CONTACT_KINDS = [ + 'friend', + 'group', + 'official', + 'former_friend', + 'other' +] as const + +export const MCP_MESSAGE_KINDS = [ + 'text', + 'image', + 'voice', + 'contact_card', + 'video', + 'emoji', + 'location', + 'voip', + 'system', + 'quote', + 'app_music', + 'app_link', + 'app_file', + 'app_chat_record', + 'app_mini_program', + 'app_quote', + 'app_pat', + 'app_announcement', + 'app_gift', + 'app_transfer', + 'app_red_packet', + 'app', + 'unknown' ] as const export type McpToolName = (typeof MCP_TOOL_NAMES)[number] +export type McpContactKind = (typeof MCP_CONTACT_KINDS)[number] +export type McpMessageKind = (typeof MCP_MESSAGE_KINDS)[number] export type McpLaunchMode = 'dev' | 'packaged' export type McpLauncherMode = 'dev-runner' | 'packaged-launcher' | 'direct' +export type McpSessionKind = 'friend' | 'group' | 'official' | 'other' +export type McpMessageMatchField = 'text' | 'raw' +export type McpSessionContextMode = 'latest' | 'around' export interface McpLaunchConfig { command: string @@ -54,13 +96,17 @@ export interface McpStatusPayload { warnings: string[] } -export interface McpSessionItem { +export interface McpSessionRef { sessionId: string displayName: string - kind: 'friend' | 'group' | 'official' | 'other' + kind: McpSessionKind +} + +export interface McpSessionItem extends McpSessionRef { lastMessagePreview: string unreadCount: number lastTimestamp: number + lastTimestampMs: number } export interface McpSessionsPayload { @@ -71,6 +117,30 @@ export interface McpSessionsPayload { hasMore: boolean } +export interface McpContactItem { + contactId: string + displayName: string + remark?: string + nickname?: string + kind: McpContactKind + lastContactTimestamp: number + lastContactTimestampMs: number +} + +export interface McpContactsPayload { + items: McpContactItem[] + total: number + offset: number + limit: number + hasMore: boolean +} + +export interface McpCursor { + sortSeq: number + createTime: number + localId: number +} + export interface McpMessageMedia { type: string localPath?: string | null @@ -85,13 +155,15 @@ export interface McpMessageMedia { export interface McpMessageItem { messageId: number timestamp: number + timestampMs: number direction: 'in' | 'out' - kind: string + kind: McpMessageKind text: string sender: { username: string | null isSelf: boolean } + cursor: McpCursor media?: McpMessageMedia raw?: string } @@ -102,3 +174,27 @@ export interface McpMessagesPayload { limit: number hasMore: boolean } + +export interface McpSearchHit { + session: McpSessionRef + message: McpMessageItem + excerpt: string + matchedField: McpMessageMatchField +} + +export interface McpSearchMessagesPayload { + hits: McpSearchHit[] + limit: number + sessionsScanned: number + messagesScanned: number + truncated: boolean +} + +export interface McpSessionContextPayload { + session: McpSessionRef + mode: McpSessionContextMode + anchor?: McpMessageItem + items: McpMessageItem[] + hasMoreBefore: boolean + hasMoreAfter: boolean +} diff --git a/src/pages/McpPage.tsx b/src/pages/McpPage.tsx index 8040856..1fdbdd0 100644 --- a/src/pages/McpPage.tsx +++ b/src/pages/McpPage.tsx @@ -258,7 +258,7 @@ function McpPage() { 默认解析媒体本地路径 - 控制 `get_messages` 默认是否解析并返回图片、视频、语音、文件等本地路径。 + 控制 `get_messages`、`search_messages`、`get_session_context` 默认是否解析并返回图片、视频、语音、文件等本地路径。 - v1 工具:`health_check`、`get_status`、`list_sessions`、`get_messages` + v1 工具:`health_check`、`get_status`、`list_sessions`、`get_messages`、`list_contacts`、`search_messages`、`get_session_context`