|
| 1 | +import type { AgentRetriever } from './ai-agent/qa/data/retriever' |
| 2 | +import type { ChatSearchIndexService } from './search/chatSearchIndexService' |
| 3 | + |
| 4 | +export type BuiltinToolDef = { |
| 5 | + type: 'function' |
| 6 | + function: { |
| 7 | + name: string |
| 8 | + description: string |
| 9 | + parameters: Record<string, unknown> |
| 10 | + } |
| 11 | +} |
| 12 | + |
| 13 | +export const BUILTIN_TOOL_SCHEMAS: BuiltinToolDef[] = [ |
| 14 | + { |
| 15 | + type: 'function', |
| 16 | + function: { |
| 17 | + name: 'ct_list_sessions', |
| 18 | + description: '列出用户的微信会话(私聊、群聊)列表,返回会话名称和 sessionId。在需要了解用户有哪些聊天或需要定位特定会话时使用。', |
| 19 | + parameters: { |
| 20 | + type: 'object', |
| 21 | + properties: { |
| 22 | + limit: { type: 'number', description: '返回数量,默认 20,最多 100' } |
| 23 | + } |
| 24 | + } |
| 25 | + } |
| 26 | + }, |
| 27 | + { |
| 28 | + type: 'function', |
| 29 | + function: { |
| 30 | + name: 'ct_list_contacts', |
| 31 | + description: '列出用户的微信通讯录联系人,包括好友和群组。', |
| 32 | + parameters: { |
| 33 | + type: 'object', |
| 34 | + properties: { |
| 35 | + limit: { type: 'number', description: '返回数量,默认 30,最多 200' } |
| 36 | + } |
| 37 | + } |
| 38 | + } |
| 39 | + }, |
| 40 | + { |
| 41 | + type: 'function', |
| 42 | + function: { |
| 43 | + name: 'ct_search_messages', |
| 44 | + description: '在微信会话中按关键词搜索消息。有向量索引的会话使用混合检索(FTS + 向量 + reranker),无向量的使用高质量关键词检索。可指定 sessionId 限定范围,不填则跨最近会话搜索。', |
| 45 | + parameters: { |
| 46 | + type: 'object', |
| 47 | + properties: { |
| 48 | + keyword: { type: 'string', description: '搜索关键词' }, |
| 49 | + sessionId: { type: 'string', description: '(可选)限定搜索的会话 sessionId,不填则跨多个最近会话搜索' }, |
| 50 | + limit: { type: 'number', description: '返回结果数,默认 20,最多 50' } |
| 51 | + }, |
| 52 | + required: ['keyword'] |
| 53 | + } |
| 54 | + } |
| 55 | + }, |
| 56 | + { |
| 57 | + type: 'function', |
| 58 | + function: { |
| 59 | + name: 'ct_get_recent_messages', |
| 60 | + description: '获取指定微信会话的最近消息记录。需要先通过 ct_list_sessions 获取 sessionId。', |
| 61 | + parameters: { |
| 62 | + type: 'object', |
| 63 | + properties: { |
| 64 | + sessionId: { type: 'string', description: '会话的 sessionId(从 ct_list_sessions 获取)' }, |
| 65 | + limit: { type: 'number', description: '返回消息数量,默认 15,最多 50' } |
| 66 | + }, |
| 67 | + required: ['sessionId'] |
| 68 | + } |
| 69 | + } |
| 70 | + }, |
| 71 | + { |
| 72 | + type: 'function', |
| 73 | + function: { |
| 74 | + name: 'ct_grep_messages', |
| 75 | + description: '用正则表达式在聊天记录中精确匹配内容。适合查找特定格式的信息:手机号、金额、日期、链接、合同编号、特定词组等。返回带上下文的匹配片段。可指定 sessionId 限定单个会话,不填则扫描最近活跃的多个会话。指定时间范围时使用 SQL 级别过滤,可覆盖历史全量消息;不指定时间范围则扫描最近消息。', |
| 76 | + parameters: { |
| 77 | + type: 'object', |
| 78 | + properties: { |
| 79 | + pattern: { type: 'string', description: 'JavaScript 正则表达式,如 "1[3-9]\\\\d{9}" 匹配手机号,"\\\\d+元" 匹配金额' }, |
| 80 | + sessionId: { type: 'string', description: '(可选)限定搜索的会话 sessionId,不填则跨最近会话搜索' }, |
| 81 | + sender: { type: 'string', description: '(可选)按发送人过滤,模糊匹配用户名,填 "我" 则只看自己发的消息' }, |
| 82 | + startTime: { type: 'number', description: '(可选)起始时间戳(Unix 秒),配合 endTime 限定时间范围,可查任意历史区间' }, |
| 83 | + endTime: { type: 'number', description: '(可选)结束时间戳(Unix 秒),不填则到最新消息' }, |
| 84 | + limit: { type: 'number', description: '返回结果数,默认 20,最多 50' }, |
| 85 | + caseInsensitive: { type: 'boolean', description: '是否忽略大小写,默认 true' } |
| 86 | + }, |
| 87 | + required: ['pattern'] |
| 88 | + } |
| 89 | + } |
| 90 | + } |
| 91 | +] |
| 92 | + |
| 93 | +type SearchHitRow = { |
| 94 | + sessionId: string |
| 95 | + sessionName: string |
| 96 | + text: string |
| 97 | + sender: string | null |
| 98 | + time: number |
| 99 | + score: number |
| 100 | + source: string |
| 101 | +} |
| 102 | + |
| 103 | +/** |
| 104 | + * 对单个会话执行最优搜索: |
| 105 | + * - 有向量索引 → 完整混合检索(FTS + 向量 + RRF + reranker) |
| 106 | + * - 仅有 FTS 索引 → 高质量关键词检索(BM25 + LIKE + RRF) |
| 107 | + * - 两者都没有 → 原始扫描兜底 |
| 108 | + */ |
| 109 | +async function searchOneSession( |
| 110 | + retriever: InstanceType<typeof AgentRetriever>, |
| 111 | + indexService: InstanceType<typeof ChatSearchIndexService>, |
| 112 | + sessionId: string, |
| 113 | + sessionName: string | undefined, |
| 114 | + keyword: string, |
| 115 | + limit: number |
| 116 | +): Promise<SearchHitRow[]> { |
| 117 | + const vectorState = indexService.getSessionVectorIndexState(sessionId) |
| 118 | + const hasVectors = vectorState.vectorizedCount > 0 |
| 119 | + |
| 120 | + const { result } = await retriever.search({ |
| 121 | + sessionId, |
| 122 | + query: keyword, |
| 123 | + semanticQuery: hasVectors ? keyword : undefined, |
| 124 | + limit, |
| 125 | + expandEvidence: false |
| 126 | + }) |
| 127 | + |
| 128 | + return result.hits.map(hit => ({ |
| 129 | + sessionId: hit.session.sessionId, |
| 130 | + sessionName: sessionName ?? hit.session.displayName, |
| 131 | + text: hit.message.text, |
| 132 | + sender: hit.message.sender.isSelf ? '我' : (hit.message.sender.displayName || hit.message.sender.username), |
| 133 | + time: hit.message.timestamp, |
| 134 | + score: hit.score, |
| 135 | + source: hit.retrievalSource |
| 136 | + })) |
| 137 | +} |
| 138 | + |
| 139 | +export async function executeBuiltinTool(toolName: string, args: Record<string, unknown>): Promise<unknown> { |
| 140 | + const { chatService } = await import('./chatService') |
| 141 | + |
| 142 | + if (toolName === 'ct_list_sessions') { |
| 143 | + const limit = Math.min(100, Math.max(1, Number(args.limit) || 10)) |
| 144 | + const result = await chatService.getSessions(0, limit) |
| 145 | + if (!result.success) return { error: result.error } |
| 146 | + return (result.sessions || []).map(s => ({ |
| 147 | + sessionId: s.username, |
| 148 | + name: s.displayName || s.username, |
| 149 | + lastMessage: s.summary, |
| 150 | + lastTime: s.lastTimestamp |
| 151 | + })) |
| 152 | + } |
| 153 | + |
| 154 | + if (toolName === 'ct_list_contacts') { |
| 155 | + const limit = Math.min(200, Math.max(1, Number(args.limit) || 30)) |
| 156 | + const result = await chatService.getContacts() |
| 157 | + if (!result.success) return { error: result.error } |
| 158 | + return (result.contacts || []).slice(0, limit).map(c => ({ |
| 159 | + username: c.username, |
| 160 | + name: c.displayName, |
| 161 | + remark: c.remark, |
| 162 | + type: c.type |
| 163 | + })) |
| 164 | + } |
| 165 | + |
| 166 | + if (toolName === 'ct_search_messages') { |
| 167 | + const keyword = String(args.keyword || '') |
| 168 | + if (!keyword) return { error: '缺少 keyword 参数' } |
| 169 | + const limit = Math.min(50, Math.max(1, Number(args.limit) || 20)) |
| 170 | + const { agentRetriever } = await import('./ai-agent/qa/data/retriever') |
| 171 | + const { chatSearchIndexService } = await import('./search/chatSearchIndexService') |
| 172 | + const sessionId = args.sessionId ? String(args.sessionId) : undefined |
| 173 | + |
| 174 | + if (sessionId) { |
| 175 | + return searchOneSession(agentRetriever, chatSearchIndexService, sessionId, undefined, keyword, limit) |
| 176 | + } |
| 177 | + |
| 178 | + // 跨会话搜索:取前 10 个最近会话,每个最多 3 条,按 score 全局排序后取 top limit |
| 179 | + const sessionsResult = await chatService.getSessions(0, 10) |
| 180 | + const sessions = sessionsResult.sessions || [] |
| 181 | + const allHits: SearchHitRow[] = [] |
| 182 | + for (const s of sessions) { |
| 183 | + const hits = await searchOneSession( |
| 184 | + agentRetriever, chatSearchIndexService, |
| 185 | + s.username, s.displayName || s.username, |
| 186 | + keyword, 3 |
| 187 | + ) |
| 188 | + allHits.push(...hits) |
| 189 | + } |
| 190 | + allHits.sort((a, b) => b.score - a.score) |
| 191 | + return allHits.slice(0, limit) |
| 192 | + } |
| 193 | + |
| 194 | + if (toolName === 'ct_get_recent_messages') { |
| 195 | + const sessionId = String(args.sessionId || '') |
| 196 | + if (!sessionId) return { error: '缺少 sessionId 参数' } |
| 197 | + const limit = Math.min(50, Math.max(1, Number(args.limit) || 15)) |
| 198 | + const result = await chatService.getMessages(sessionId, 0, limit) |
| 199 | + if (!result.success) return { error: result.error } |
| 200 | + return (result.messages || []).map(m => { |
| 201 | + const raw = m.parsedContent || '' |
| 202 | + return { |
| 203 | + text: raw.length > 400 ? raw.slice(0, 400) + '…' : raw, |
| 204 | + sender: m.isSend === 1 ? '我' : (m.senderUsername || '对方'), |
| 205 | + time: m.createTime |
| 206 | + } |
| 207 | + }) |
| 208 | + } |
| 209 | + |
| 210 | + if (toolName === 'ct_grep_messages') { |
| 211 | + const pattern = String(args.pattern || '') |
| 212 | + if (!pattern) return { error: '缺少 pattern 参数' } |
| 213 | + |
| 214 | + let regex: RegExp |
| 215 | + try { |
| 216 | + regex = new RegExp(pattern, args.caseInsensitive !== false ? 'i' : '') |
| 217 | + } catch (e) { |
| 218 | + return { error: `无效的正则表达式: ${e instanceof Error ? e.message : String(e)}` } |
| 219 | + } |
| 220 | + |
| 221 | + const limit = Math.min(50, Math.max(1, Number(args.limit) || 20)) |
| 222 | + const sessionId = args.sessionId ? String(args.sessionId) : undefined |
| 223 | + const senderFilter = args.sender ? String(args.sender).toLowerCase() : undefined |
| 224 | + const startTime = args.startTime ? Number(args.startTime) : undefined |
| 225 | + const endTime = args.endTime ? Number(args.endTime) : undefined |
| 226 | + const hasTimeRange = startTime !== undefined || endTime !== undefined |
| 227 | + |
| 228 | + type GrepHit = { sessionId: string; sessionName: string; text: string; match: string; sender: string; time: number } |
| 229 | + |
| 230 | + const grepSession = async (sid: string, sName: string, scanLimit: number, maxHits: number): Promise<GrepHit[]> => { |
| 231 | + let messages: any[] |
| 232 | + if (hasTimeRange) { |
| 233 | + const r = await chatService.getMessagesByTimeRangeForSummary(sid, { |
| 234 | + startTime, |
| 235 | + endTime: endTime ?? Math.floor(Date.now() / 1000), |
| 236 | + limit: scanLimit |
| 237 | + }) |
| 238 | + if (!r.success) return [] |
| 239 | + messages = r.messages || [] |
| 240 | + } else { |
| 241 | + const r = await chatService.getMessages(sid, 0, scanLimit) |
| 242 | + if (!r.success) return [] |
| 243 | + messages = r.messages || [] |
| 244 | + } |
| 245 | + |
| 246 | + const hits: GrepHit[] = [] |
| 247 | + for (const m of messages) { |
| 248 | + if (hits.length >= maxHits) break |
| 249 | + const isSelf = m.isSend === 1 |
| 250 | + const msgSender = isSelf ? '我' : (m.senderUsername || '对方') |
| 251 | + |
| 252 | + if (senderFilter) { |
| 253 | + if (senderFilter === '我' || senderFilter === 'me') { |
| 254 | + if (!isSelf) continue |
| 255 | + } else if (!msgSender.toLowerCase().includes(senderFilter)) { |
| 256 | + continue |
| 257 | + } |
| 258 | + } |
| 259 | + |
| 260 | + const raw = m.parsedContent || '' |
| 261 | + const found = raw.match(regex) |
| 262 | + if (!found) continue |
| 263 | + const idx = raw.indexOf(found[0]) |
| 264 | + const start = Math.max(0, idx - 80) |
| 265 | + const end = Math.min(raw.length, idx + found[0].length + 80) |
| 266 | + let excerpt = raw.slice(start, end) |
| 267 | + if (start > 0) excerpt = '…' + excerpt |
| 268 | + if (end < raw.length) excerpt += '…' |
| 269 | + hits.push({ |
| 270 | + sessionId: sid, |
| 271 | + sessionName: sName, |
| 272 | + text: excerpt, |
| 273 | + match: found[0].slice(0, 120), |
| 274 | + sender: msgSender, |
| 275 | + time: m.createTime |
| 276 | + }) |
| 277 | + } |
| 278 | + return hits |
| 279 | + } |
| 280 | + |
| 281 | + // 有时间范围时走 SQL 过滤,扫描上限大幅提升 |
| 282 | + const singleScanLimit = hasTimeRange ? 2000 : 500 |
| 283 | + const crossScanLimit = hasTimeRange ? 1000 : 200 |
| 284 | + |
| 285 | + if (sessionId) { |
| 286 | + const sr = await chatService.getSessions(0, 200) |
| 287 | + const s = (sr.sessions || []).find((x: any) => x.username === sessionId) |
| 288 | + return grepSession(sessionId, s?.displayName || sessionId, singleScanLimit, limit) |
| 289 | + } |
| 290 | + |
| 291 | + const sessionsResult = await chatService.getSessions(0, 20) |
| 292 | + const allHits: GrepHit[] = [] |
| 293 | + for (const s of sessionsResult.sessions || []) { |
| 294 | + if (allHits.length >= limit) break |
| 295 | + const hits = await grepSession(s.username, s.displayName || s.username, crossScanLimit, limit - allHits.length) |
| 296 | + allHits.push(...hits) |
| 297 | + } |
| 298 | + return allHits |
| 299 | + } |
| 300 | + |
| 301 | + return { error: `未知内置工具: ${toolName}` } |
| 302 | +} |
0 commit comments