Skip to content

Commit fb7935a

Browse files
committed
feat: 添加 ExportCard 组件以导出聊天会话
功能:增强 MessageBubble,增加复制和分享功能 功能:实现 ShareModal,用于将消息以图片形式分享 重构:移除未使用的 AttachMenuItem 类型及相关代码 功能:扩展 useAgentChat 钩子以支持导出会话 功能:添加 CardBlock 类型以支持导出向导功能 修复:更新 ElectronAPI 类型以包含用于本地消息处理的新方法
1 parent 739691a commit fb7935a

18 files changed

Lines changed: 1661 additions & 203 deletions

electron/main/ipc/agentHandlers.ts

Lines changed: 339 additions & 21 deletions
Large diffs are not rendered by default.

electron/preload.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -737,6 +737,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
737737
model: string
738738
enableThinking?: boolean
739739
systemPrompt?: string
740+
commandHint?: string
741+
readLimit?: number
740742
enabledTools?: Array<{ type: string; function: { name: string; description?: string; parameters?: Record<string, unknown> } }>
741743
scopedSessions?: Array<{ id: string; name: string }>
742744
}) => ipcRenderer.invoke('agent:sendMessage', opts),
@@ -751,8 +753,19 @@ contextBridge.exposeInMainWorld('electronAPI', {
751753

752754
newConversation: () => ipcRenderer.invoke('agent:newConversation'),
753755

756+
appendLocalMessages: (opts: {
757+
conversationId?: number
758+
messages: Array<{
759+
role: 'user' | 'assistant'
760+
content?: string
761+
blocks?: unknown[]
762+
}>
763+
}) => ipcRenderer.invoke('agent:appendLocalMessages', opts),
764+
754765
updateTitle: (id: number, title: string) => ipcRenderer.invoke('agent:updateTitle', id, title),
755766

767+
getLastConversationId: () => ipcRenderer.invoke('agent:getLastConversationId'),
768+
756769
generateTitle: (opts: {
757770
conversationId: number
758771
userMessage: string

electron/services/agentBuiltinTools.ts

Lines changed: 113 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,21 @@ export type BuiltinToolDef = {
1111
}
1212

1313
export const BUILTIN_TOOL_SCHEMAS: BuiltinToolDef[] = [
14+
{
15+
type: 'function',
16+
function: {
17+
name: 'ct_initiate_export',
18+
description: '初始化聊天记录导出工作流。在通过 ct_list_sessions 确认目标会话后调用此工具,系统将展示一个交互式导出确认卡片供用户选择格式并执行导出。每次导出流程只需调用一次。',
19+
parameters: {
20+
type: 'object',
21+
properties: {
22+
sessionId: { type: 'string', description: '目标会话的 sessionId(从 ct_list_sessions 获取)' },
23+
sessionName: { type: 'string', description: '目标会话的显示名称' }
24+
},
25+
required: ['sessionId', 'sessionName']
26+
}
27+
}
28+
},
1429
{
1530
type: 'function',
1631
function: {
@@ -19,7 +34,7 @@ export const BUILTIN_TOOL_SCHEMAS: BuiltinToolDef[] = [
1934
parameters: {
2035
type: 'object',
2136
properties: {
22-
limit: { type: 'number', description: '返回数量,默认 20,最多 100' }
37+
limit: { type: 'number', description: '返回数量,默认 20,上限由用户在输入框设定' }
2338
}
2439
}
2540
}
@@ -32,7 +47,7 @@ export const BUILTIN_TOOL_SCHEMAS: BuiltinToolDef[] = [
3247
parameters: {
3348
type: 'object',
3449
properties: {
35-
limit: { type: 'number', description: '返回数量,默认 30,最多 200' }
50+
limit: { type: 'number', description: '返回数量,默认 30,上限由用户在输入框设定' }
3651
}
3752
}
3853
}
@@ -47,7 +62,7 @@ export const BUILTIN_TOOL_SCHEMAS: BuiltinToolDef[] = [
4762
properties: {
4863
keyword: { type: 'string', description: '搜索关键词' },
4964
sessionId: { type: 'string', description: '(可选)限定搜索的会话 sessionId,不填则跨多个最近会话搜索' },
50-
limit: { type: 'number', description: '返回结果数,默认 20,最多 50' }
65+
limit: { type: 'number', description: '返回结果数,默认 20,上限由用户在输入框设定' }
5166
},
5267
required: ['keyword']
5368
}
@@ -62,12 +77,30 @@ export const BUILTIN_TOOL_SCHEMAS: BuiltinToolDef[] = [
6277
type: 'object',
6378
properties: {
6479
sessionId: { type: 'string', description: '会话的 sessionId(从 ct_list_sessions 获取)' },
65-
limit: { type: 'number', description: '返回消息数量,默认 15,最多 50' }
80+
limit: { type: 'number', description: '返回消息数量,默认 15,上限由用户在输入框设定' }
6681
},
6782
required: ['sessionId']
6883
}
6984
}
7085
},
86+
{
87+
type: 'function',
88+
function: {
89+
name: 'ct_get_moments',
90+
description: '获取微信朋友圈时间线。可按时间范围、关键词、发布者过滤。返回动态列表,含作者、时间、内容文字、点赞数和评论摘要。',
91+
parameters: {
92+
type: 'object',
93+
properties: {
94+
limit: { type: 'number', description: '返回条数,默认 20,上限由读取条数设定' },
95+
offset: { type: 'number', description: '分页偏移量,默认 0' },
96+
keyword: { type: 'string', description: '(可选)按内容关键词过滤' },
97+
usernames: { type: 'array', items: { type: 'string' }, description: '(可选)按发布者微信 ID 过滤' },
98+
startTime: { type: 'number', description: '(可选)起始时间戳(Unix 秒)' },
99+
endTime: { type: 'number', description: '(可选)结束时间戳(Unix 秒)' }
100+
}
101+
}
102+
}
103+
},
71104
{
72105
type: 'function',
73106
function: {
@@ -81,7 +114,7 @@ export const BUILTIN_TOOL_SCHEMAS: BuiltinToolDef[] = [
81114
sender: { type: 'string', description: '(可选)按发送人过滤,模糊匹配用户名,填 "我" 则只看自己发的消息' },
82115
startTime: { type: 'number', description: '(可选)起始时间戳(Unix 秒),配合 endTime 限定时间范围,可查任意历史区间' },
83116
endTime: { type: 'number', description: '(可选)结束时间戳(Unix 秒),不填则到最新消息' },
84-
limit: { type: 'number', description: '返回结果数,默认 20,最多 50' },
117+
limit: { type: 'number', description: '返回结果数,默认 20,上限由用户在输入框设定' },
85118
caseInsensitive: { type: 'boolean', description: '是否忽略大小写,默认 true' }
86119
},
87120
required: ['pattern']
@@ -136,11 +169,20 @@ async function searchOneSession(
136169
}))
137170
}
138171

139-
export async function executeBuiltinTool(toolName: string, args: Record<string, unknown>): Promise<unknown> {
172+
export async function executeBuiltinTool(toolName: string, args: Record<string, unknown>, context?: { readLimit?: number }): Promise<unknown> {
140173
const { chatService } = await import('./chatService')
174+
const userReadLimit = context?.readLimit ?? 50
175+
176+
if (toolName === 'ct_initiate_export') {
177+
const sessionId = String(args.sessionId || '')
178+
const sessionName = String(args.sessionName || '')
179+
if (!sessionId) return { error: '缺少 sessionId 参数' }
180+
// 返回结构化标记,useAgentChat 的 onDone 检测到后注入 CardBlock
181+
return { __ct_card: 'export-wizard', sessionId, sessionName, message: `已准备好「${sessionName}」的导出确认卡片。` }
182+
}
141183

142184
if (toolName === 'ct_list_sessions') {
143-
const limit = Math.min(100, Math.max(1, Number(args.limit) || 10))
185+
const limit = Math.min(userReadLimit, Math.max(1, Number(args.limit) || Math.min(20, userReadLimit)))
144186
const result = await chatService.getSessions(0, limit)
145187
if (!result.success) return { error: result.error }
146188
return (result.sessions || []).map(s => ({
@@ -152,7 +194,7 @@ export async function executeBuiltinTool(toolName: string, args: Record<string,
152194
}
153195

154196
if (toolName === 'ct_list_contacts') {
155-
const limit = Math.min(200, Math.max(1, Number(args.limit) || 30))
197+
const limit = Math.min(userReadLimit, Math.max(1, Number(args.limit) || Math.min(30, userReadLimit)))
156198
const result = await chatService.getContacts()
157199
if (!result.success) return { error: result.error }
158200
return (result.contacts || []).slice(0, limit).map(c => ({
@@ -166,7 +208,7 @@ export async function executeBuiltinTool(toolName: string, args: Record<string,
166208
if (toolName === 'ct_search_messages') {
167209
const keyword = String(args.keyword || '')
168210
if (!keyword) return { error: '缺少 keyword 参数' }
169-
const limit = Math.min(50, Math.max(1, Number(args.limit) || 20))
211+
const limit = Math.min(userReadLimit, Math.max(1, Number(args.limit) || Math.min(20, userReadLimit)))
170212
const { agentRetriever } = await import('./ai-agent/qa/data/retriever')
171213
const { chatSearchIndexService } = await import('./search/chatSearchIndexService')
172214
const sessionId = args.sessionId ? String(args.sessionId) : undefined
@@ -175,17 +217,43 @@ export async function executeBuiltinTool(toolName: string, args: Record<string,
175217
return searchOneSession(agentRetriever, chatSearchIndexService, sessionId, undefined, keyword, limit)
176218
}
177219

178-
// 跨会话搜索:取前 10 个最近会话,每个最多 3 条,按 score 全局排序后取 top limit
179-
const sessionsResult = await chatService.getSessions(0, 10)
220+
// 全量跨会话搜索:取全部会话(上限 1000),分批并行,每批 20 个
221+
const sessionsResult = await chatService.getSessions(0, 1000)
180222
const sessions = sessionsResult.sessions || []
181223
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
224+
const BATCH = 20
225+
const PER_SESSION = 3
226+
const kw = keyword.toLowerCase()
227+
for (let i = 0; i < sessions.length; i += BATCH) {
228+
const batch = sessions.slice(i, i + BATCH)
229+
const batchHits = await Promise.all(
230+
batch.map(async s => {
231+
const state = chatSearchIndexService.getSessionVectorIndexState(s.username)
232+
if (state.indexedCount === 0) {
233+
// 未建 FTS 索引:降级为扫描最近 200 条消息做关键词匹配
234+
const r = await chatService.getMessages(s.username, 0, 200)
235+
if (!r.success) return []
236+
return (r.messages || [])
237+
.filter((m: any) => (m.parsedContent || '').toLowerCase().includes(kw))
238+
.slice(0, PER_SESSION)
239+
.map((m: any): SearchHitRow => ({
240+
sessionId: s.username,
241+
sessionName: s.displayName || s.username,
242+
text: m.parsedContent || '',
243+
sender: m.isSend === 1 ? '我' : (m.senderUsername || '对方'),
244+
time: m.createTime,
245+
score: 0.5,
246+
source: 'scan'
247+
}))
248+
}
249+
return searchOneSession(
250+
agentRetriever, chatSearchIndexService,
251+
s.username, s.displayName || s.username,
252+
keyword, PER_SESSION
253+
)
254+
})
187255
)
188-
allHits.push(...hits)
256+
allHits.push(...batchHits.flat())
189257
}
190258
allHits.sort((a, b) => b.score - a.score)
191259
return allHits.slice(0, limit)
@@ -194,7 +262,7 @@ export async function executeBuiltinTool(toolName: string, args: Record<string,
194262
if (toolName === 'ct_get_recent_messages') {
195263
const sessionId = String(args.sessionId || '')
196264
if (!sessionId) return { error: '缺少 sessionId 参数' }
197-
const limit = Math.min(50, Math.max(1, Number(args.limit) || 15))
265+
const limit = Math.min(userReadLimit, Math.max(1, Number(args.limit) || Math.min(15, userReadLimit)))
198266
const result = await chatService.getMessages(sessionId, 0, limit)
199267
if (!result.success) return { error: result.error }
200268
return (result.messages || []).map(m => {
@@ -207,6 +275,32 @@ export async function executeBuiltinTool(toolName: string, args: Record<string,
207275
})
208276
}
209277

278+
if (toolName === 'ct_get_moments') {
279+
const limit = Math.min(userReadLimit, Math.max(1, Number(args.limit) || Math.min(20, userReadLimit)))
280+
const offset = Math.max(0, Number(args.offset) || 0)
281+
const keyword = args.keyword ? String(args.keyword) : undefined
282+
const usernames = Array.isArray(args.usernames) ? (args.usernames as unknown[]).map(String) : undefined
283+
const startTime = args.startTime ? Number(args.startTime) : undefined
284+
const endTime = args.endTime ? Number(args.endTime) : undefined
285+
286+
const { snsService } = await import('./snsService')
287+
const result = await snsService.getTimeline(limit, offset, usernames, keyword, startTime, endTime)
288+
if (!result.success) return { error: result.error || '获取朋友圈失败,请确认朋友圈数据库已加载' }
289+
290+
return (result.timeline || []).map(post => ({
291+
id: post.id,
292+
author: post.nickname || post.username,
293+
time: post.createTime,
294+
text: (post.contentDesc || '').slice(0, 500),
295+
mediaCount: post.media?.length || 0,
296+
likeCount: post.likes?.length || 0,
297+
comments: (post.comments || []).slice(0, 5).map(c => ({
298+
author: c.nickname,
299+
text: c.content,
300+
})),
301+
}))
302+
}
303+
210304
if (toolName === 'ct_grep_messages') {
211305
const pattern = String(args.pattern || '')
212306
if (!pattern) return { error: '缺少 pattern 参数' }
@@ -218,7 +312,7 @@ export async function executeBuiltinTool(toolName: string, args: Record<string,
218312
return { error: `无效的正则表达式: ${e instanceof Error ? e.message : String(e)}` }
219313
}
220314

221-
const limit = Math.min(50, Math.max(1, Number(args.limit) || 20))
315+
const limit = Math.min(userReadLimit, Math.max(1, Number(args.limit) || Math.min(20, userReadLimit)))
222316
const sessionId = args.sessionId ? String(args.sessionId) : undefined
223317
const senderFilter = args.sender ? String(args.sender).toLowerCase() : undefined
224318
const startTime = args.startTime ? Number(args.startTime) : undefined

electron/services/agentConversationDb.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,13 @@ class AgentConversationDb {
132132
updateTitle(id: number, title: string): void {
133133
this.getDb().prepare('UPDATE agent_conversations SET title = ? WHERE id = ?').run(title, id)
134134
}
135+
136+
hasConversation(id: number): boolean {
137+
const row = this.getDb()
138+
.prepare('SELECT 1 as ok FROM agent_conversations WHERE id = ? LIMIT 1')
139+
.get(id) as { ok?: number } | undefined
140+
return Boolean(row?.ok)
141+
}
135142
}
136143

137144
export const agentConversationDb = new AgentConversationDb()

electron/services/config.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ interface ConfigSchema {
4242
cachePath: string
4343
lastOpenedDb: string
4444
lastSession: string
45+
lastAgentConversationId: number
4546

4647
// 导出相关
4748
exportPath: string
@@ -109,6 +110,7 @@ interface ConfigSchema {
109110
aiEnableCache: boolean
110111
aiEnableThinking: boolean // 是否显示思考过程
111112
aiMessageLimit: number // 摘要提取的消息条数限制
113+
agentReadLimit: number // Agent 内置工具单次读取消息条数上限(500-2000)
112114
aiAgentDecisionMaxTokens: number // 会话问答 Agent 每轮决策输出 token 上限
113115
aiAgentAnswerMaxTokens: number // 会话问答最终回答输出 token 上限
114116
aiEmbeddingMode: 'local' | 'online'
@@ -148,6 +150,7 @@ const defaults: ConfigSchema = {
148150
cachePath: '',
149151
lastOpenedDb: '',
150152
lastSession: '',
153+
lastAgentConversationId: 0,
151154
exportPath: '',
152155
theme: 'cloud-dancer',
153156
themeMode: 'light',
@@ -190,6 +193,7 @@ const defaults: ConfigSchema = {
190193
aiEnableCache: true,
191194
aiEnableThinking: true, // 默认显示思考过程
192195
aiMessageLimit: 3000, // 默认3000条,用户可调至5000
196+
agentReadLimit: 500, // 默认500条
193197
aiAgentDecisionMaxTokens: 2048,
194198
aiAgentAnswerMaxTokens: 8192,
195199
aiEmbeddingMode: 'local',

src/pages/ExportPage.tsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { useState, useEffect, useCallback } from 'react'
1+
import { useState, useEffect, useCallback, useRef } from 'react'
2+
import { useLocation } from 'react-router-dom'
23
import { Search, Download, FolderOpen, RefreshCw, Check, FileJson, FileText, Table, Loader2, X, FileSpreadsheet, Database, FileCode, CheckCircle, XCircle, ExternalLink, MessageSquare, Users, User, Filter, Image, Video, CircleUserRound, Smile, Mic } from 'lucide-react'
34
import DateRangePicker from '../components/DateRangePicker'
45
import { useTitleBarStore } from '../stores/titleBarStore'
@@ -59,6 +60,8 @@ type SessionTypeFilter = 'all' | 'group' | 'private'
5960
function ExportPage() {
6061
const [activeTab, setActiveTab] = useState<ExportTab>('chat')
6162
const setTitleBarContent = useTitleBarStore(state => state.setRightContent)
63+
const location = useLocation()
64+
const preSelectAppliedRef = useRef(false)
6265

6366
// 聊天导出状态
6467
const [sessions, setSessions] = useState<ChatSession[]>([])
@@ -195,13 +198,20 @@ function ExportPage() {
195198
if (sessionsResult.success && sessionsResult.sessions) {
196199
setSessions(sessionsResult.sessions)
197200
setFilteredSessions(sessionsResult.sessions)
201+
if (!preSelectAppliedRef.current) {
202+
const state = location.state as { preSelectedSessions?: string[] } | null
203+
if (state?.preSelectedSessions?.length) {
204+
preSelectAppliedRef.current = true
205+
setSelectedSessions(new Set(state.preSelectedSessions))
206+
}
207+
}
198208
}
199209
} catch (e) {
200210
console.error('加载会话失败:', e)
201211
} finally {
202212
setIsLoading(false)
203213
}
204-
}, [])
214+
}, [location.state])
205215

206216
// 加载通讯录
207217
const loadContacts = useCallback(async () => {

0 commit comments

Comments
 (0)