Skip to content

Commit 739691a

Browse files
committed
feat: 添加作用域会话支持和内置代理聊天工具
- 在代理处理程序和 IPC 中引入 `scopedSessions` 选项,以允许在响应中优先处理指定的会话。 - 将内置工具模式与已启用的工具合并,以增强功能。 - 实现了用于会话管理和消息搜索的新内置工具。 - 增强了代理聊天服务,以处理推理内容和系统提示后缀。 - 更新了 UI 组件,以支持在用户消息中显示附加资源。 - 改进了聊天输入中的@提及功能,用于引用会话。
1 parent 329e02a commit 739691a

12 files changed

Lines changed: 670 additions & 56 deletions

File tree

electron/main/ipc/agentHandlers.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ export function registerAgentHandlers(ctx: MainProcessContext): void {
4040
enableThinking?: boolean
4141
systemPrompt?: string
4242
enabledTools?: Array<{ type: string; function: { name: string; description?: string; parameters?: Record<string, unknown> } }>
43+
scopedSessions?: Array<{ id: string; name: string }>
4344
}) => {
4445
const requestId = options.requestId?.trim() || genRequestId()
4546
if (requestMap.has(requestId)) {
@@ -66,16 +67,26 @@ export function registerAgentHandlers(ctx: MainProcessContext): void {
6667
let assistantText = ''
6768
let reasoningText = ''
6869
try {
70+
const { BUILTIN_TOOL_SCHEMAS } = await import('../../services/agentBuiltinTools')
71+
const mergedTools = [...BUILTIN_TOOL_SCHEMAS, ...(options.enabledTools || [])]
72+
73+
let systemPromptSuffix: string | undefined
74+
if (options.scopedSessions && options.scopedSessions.length > 0) {
75+
const list = options.scopedSessions.map(s => `- ${s.name}(sessionId: ${s.id})`).join('\n')
76+
systemPromptSuffix = `用户已指定以下会话范围,请优先围绕这些会话回答,使用工具时传入对应的 sessionId:\n${list}`
77+
}
78+
6979
assistantText = await agentChatService.sendMessage({
7080
history: options.history as any,
7181
message: options.message,
7282
provider: options.provider,
7383
apiKey: options.apiKey,
7484
model: options.model,
7585
enableThinking: options.enableThinking !== false,
76-
systemPrompt: options.systemPrompt,
86+
systemPrompt: options.systemPrompt || undefined,
87+
systemPromptSuffix,
7788
signal: controller.signal,
78-
enabledTools: options.enabledTools as any,
89+
enabledTools: mergedTools as any,
7990
onStreamEvent: (streamEvent) => {
8091
if (streamEvent.type === 'reasoning_delta') {
8192
reasoningText += streamEvent.text
@@ -91,6 +102,15 @@ export function registerAgentHandlers(ctx: MainProcessContext): void {
91102
event.sender.send('agent:streamEvent', { requestId, event: streamEvent })
92103
},
93104
mcpCallTool: async (serverName, toolName, args) => {
105+
if (!serverName && toolName.startsWith('ct_')) {
106+
try {
107+
const { executeBuiltinTool } = await import('../../services/agentBuiltinTools')
108+
const result = await executeBuiltinTool(toolName, args as Record<string, unknown>)
109+
return { success: true, result }
110+
} catch (e) {
111+
return { success: false, error: String(e) }
112+
}
113+
}
94114
try {
95115
const { mcpClientService } = await import('../../services/mcpClientService')
96116
return await mcpClientService.callTool(serverName, toolName, args)

electron/preload.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -738,6 +738,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
738738
enableThinking?: boolean
739739
systemPrompt?: string
740740
enabledTools?: Array<{ type: string; function: { name: string; description?: string; parameters?: Record<string, unknown> } }>
741+
scopedSessions?: Array<{ id: string; name: string }>
741742
}) => ipcRenderer.invoke('agent:sendMessage', opts),
742743

743744
cancel: (requestId: string) => ipcRenderer.invoke('agent:cancel', requestId),
Lines changed: 302 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,302 @@
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

Comments
 (0)