diff --git a/electron/main/ai/tools/definitions/deep-search-messages.ts b/electron/main/ai/tools/definitions/deep-search-messages.ts index cab27d1..ad16405 100644 --- a/electron/main/ai/tools/definitions/deep-search-messages.ts +++ b/electron/main/ai/tools/definitions/deep-search-messages.ts @@ -33,11 +33,27 @@ export function createTool(context: ToolContext): AgentTool { params.sender_id ) + const contextBefore = context.searchContextBefore ?? 2 + const contextAfter = context.searchContextAfter ?? 2 + let finalMessages = result.messages + + if ((contextBefore > 0 || contextAfter > 0) && result.messages.length > 0) { + const hitIds = result.messages.map((m) => m.id).filter((id): id is number => id != null) + if (hitIds.length > 0) { + finalMessages = await workerManager.getSearchMessageContext( + sessionId, + hitIds, + contextBefore, + contextAfter + ) + } + } + const data = { total: result.total, - returned: result.messages.length, + returned: finalMessages.length, timeRange: formatTimeRange(effectiveTimeFilter, locale), - rawMessages: result.messages, + rawMessages: finalMessages, } return { diff --git a/electron/main/ai/tools/definitions/search-messages.ts b/electron/main/ai/tools/definitions/search-messages.ts index af7846b..3d6ac01 100644 --- a/electron/main/ai/tools/definitions/search-messages.ts +++ b/electron/main/ai/tools/definitions/search-messages.ts @@ -34,11 +34,27 @@ export function createTool(context: ToolContext): AgentTool { params.sender_id ) + const contextBefore = context.searchContextBefore ?? 2 + const contextAfter = context.searchContextAfter ?? 2 + let finalMessages = result.messages + + if ((contextBefore > 0 || contextAfter > 0) && result.messages.length > 0) { + const hitIds = result.messages.map((m) => m.id).filter((id): id is number => id != null) + if (hitIds.length > 0) { + finalMessages = await workerManager.getSearchMessageContext( + sessionId, + hitIds, + contextBefore, + contextAfter + ) + } + } + const data = { total: result.total, - returned: result.messages.length, + returned: finalMessages.length, timeRange: formatTimeRange(effectiveTimeFilter, locale), - rawMessages: result.messages, + rawMessages: finalMessages, } return { diff --git a/electron/main/ai/tools/types.ts b/electron/main/ai/tools/types.ts index 1345950..232fdec 100644 --- a/electron/main/ai/tools/types.ts +++ b/electron/main/ai/tools/types.ts @@ -53,4 +53,8 @@ export interface ToolContext { locale?: string /** 聊天记录预处理配置(全局) */ preprocessConfig?: PreprocessConfig + /** 搜索结果上下文:向前取多少条(默认 3) */ + searchContextBefore?: number + /** 搜索结果上下文:向后取多少条(默认 3) */ + searchContextAfter?: number } diff --git a/electron/main/worker/dbWorker.ts b/electron/main/worker/dbWorker.ts index 3fd3fb0..02befc0 100644 --- a/electron/main/worker/dbWorker.ts +++ b/electron/main/worker/dbWorker.ts @@ -34,6 +34,7 @@ import { searchMessages, deepSearchMessages, getMessageContext, + getSearchMessageContext, getRecentMessages, getAllRecentMessages, getConversationBetween, @@ -124,6 +125,8 @@ const syncHandlers: Record any> = { // AI 查询 searchMessages: (p) => searchMessages(p.sessionId, p.keywords, p.filter, p.limit, p.offset, p.senderId), getMessageContext: (p) => getMessageContext(p.sessionId, p.messageIds, p.contextSize), + getSearchMessageContext: (p) => + getSearchMessageContext(p.sessionId, p.messageIds, p.contextBefore, p.contextAfter), getRecentMessages: (p) => getRecentMessages(p.sessionId, p.filter, p.limit), getAllRecentMessages: (p) => getAllRecentMessages(p.sessionId, p.filter, p.limit), getConversationBetween: (p) => getConversationBetween(p.sessionId, p.memberId1, p.memberId2, p.filter, p.limit), diff --git a/electron/main/worker/query/index.ts b/electron/main/worker/query/index.ts index 97aedec..39fc2c3 100644 --- a/electron/main/worker/query/index.ts +++ b/electron/main/worker/query/index.ts @@ -46,6 +46,7 @@ export { searchMessages, deepSearchMessages, getMessageContext, + getSearchMessageContext, getRecentMessages, getAllRecentMessages, getConversationBetween, diff --git a/electron/main/worker/query/messages.ts b/electron/main/worker/query/messages.ts index a604eea..70b88d3 100644 --- a/electron/main/worker/query/messages.ts +++ b/electron/main/worker/query/messages.ts @@ -526,6 +526,115 @@ export function getMessageContext( return rows.map(sanitizeMessageRow) } +/** + * 获取搜索结果的上下文消息(会话感知 + 区间合并去重) + * 用于 search_messages / deep_search_messages 自动扩展上下文。 + * 当存在会话索引时,上下文不跨会话边界;否则按 message.id 顺序取前后 N 条。 + * + * @param sessionId 数据库会话 ID + * @param messageIds 搜索命中的消息 ID 列表 + * @param contextBefore 每条命中消息向前取多少条上下文 + * @param contextAfter 每条命中消息向后取多少条上下文 + */ +export function getSearchMessageContext( + sessionId: string, + messageIds: number[], + contextBefore: number = 2, + contextAfter: number = 2 +): MessageResult[] { + ensureAvatarColumn(sessionId) + const db = openDatabase(sessionId) + if (!db) return [] + if (messageIds.length === 0) return [] + + const contextIds = new Set() + + const hasSessionData = + (db.prepare('SELECT 1 FROM message_context LIMIT 1').get() as { 1: number } | undefined) !== undefined + + for (const messageId of messageIds) { + contextIds.add(messageId) + + if (hasSessionData) { + const sessionRow = db + .prepare('SELECT session_id FROM message_context WHERE message_id = ?') + .get(messageId) as { session_id: number } | undefined + + if (sessionRow) { + if (contextBefore > 0) { + const rows = db + .prepare( + `SELECT mc.message_id as id + FROM message_context mc + WHERE mc.session_id = ? AND mc.message_id < ? + ORDER BY mc.message_id DESC + LIMIT ?` + ) + .all(sessionRow.session_id, messageId, contextBefore) as { id: number }[] + rows.forEach((r) => contextIds.add(r.id)) + } + if (contextAfter > 0) { + const rows = db + .prepare( + `SELECT mc.message_id as id + FROM message_context mc + WHERE mc.session_id = ? AND mc.message_id > ? + ORDER BY mc.message_id ASC + LIMIT ?` + ) + .all(sessionRow.session_id, messageId, contextAfter) as { id: number }[] + rows.forEach((r) => contextIds.add(r.id)) + } + continue + } + } + + // Fallback: no session data or message not indexed — use simple id-based context + if (contextBefore > 0) { + const rows = db + .prepare('SELECT id FROM message WHERE id < ? ORDER BY id DESC LIMIT ?') + .all(messageId, contextBefore) as { id: number }[] + rows.forEach((r) => contextIds.add(r.id)) + } + if (contextAfter > 0) { + const rows = db + .prepare('SELECT id FROM message WHERE id > ? ORDER BY id ASC LIMIT ?') + .all(messageId, contextAfter) as { id: number }[] + rows.forEach((r) => contextIds.add(r.id)) + } + } + + if (contextIds.size === 0) return [] + + const idList = Array.from(contextIds) + const placeholders = idList.map(() => '?').join(', ') + + const sql = ` + SELECT + msg.id, + m.id as senderId, + COALESCE(m.group_nickname, m.account_name, m.platform_id) as senderName, + m.platform_id as senderPlatformId, + m.aliases, + m.avatar, + msg.content, + msg.ts as timestamp, + msg.type, + msg.reply_to_message_id, + reply_msg.content as replyToContent, + COALESCE(reply_m.group_nickname, reply_m.account_name, reply_m.platform_id) as replyToSenderName + FROM message msg + JOIN member m ON msg.sender_id = m.id + LEFT JOIN message reply_msg ON msg.reply_to_message_id = reply_msg.platform_message_id + LEFT JOIN member reply_m ON reply_msg.sender_id = reply_m.id + WHERE msg.id IN (${placeholders}) + ORDER BY msg.ts ASC, msg.id ASC + ` + + const rows = db.prepare(sql).all(...idList) as DbMessageRow[] + return rows.map(sanitizeMessageRow) +} + /** * 获取指定消息之前的 N 条消息(用于向上无限滚动) * @param sessionId 会话 ID diff --git a/electron/main/worker/workerManager.ts b/electron/main/worker/workerManager.ts index 0c0ad22..f5f8b7b 100644 --- a/electron/main/worker/workerManager.ts +++ b/electron/main/worker/workerManager.ts @@ -494,6 +494,18 @@ export async function getMessageContext( return sendToWorker('getMessageContext', { sessionId, messageIds, contextSize }) } +/** + * 获取搜索结果的上下文消息(会话感知 + 区间合并去重) + */ +export async function getSearchMessageContext( + sessionId: string, + messageIds: number[], + contextBefore?: number, + contextAfter?: number +): Promise { + return sendToWorker('getSearchMessageContext', { sessionId, messageIds, contextBefore, contextAfter }) +} + /** * 获取最近消息(用于概览性问题) */ diff --git a/src/i18n/locales/en-US/settings.json b/src/i18n/locales/en-US/settings.json index fb8fc28..19b75ed 100644 --- a/src/i18n/locales/en-US/settings.json +++ b/src/i18n/locales/en-US/settings.json @@ -210,6 +210,12 @@ "title": "Context Limit", "description": "Number of recent conversation rounds to keep (1 round = User + AI). Prevents excessive token usage." }, + "searchContext": { + "title": "Search Context Window", + "description": "Automatically include surrounding messages for each search hit to help AI understand the context. Set to 0 to disable", + "before": "Before", + "after": "After" + }, "exportFormat": { "title": "Export Format", "description": "File format for exporting AI conversations", diff --git a/src/i18n/locales/ja-JP/settings.json b/src/i18n/locales/ja-JP/settings.json index df2cb53..37ee228 100644 --- a/src/i18n/locales/ja-JP/settings.json +++ b/src/i18n/locales/ja-JP/settings.json @@ -210,6 +210,12 @@ "title": "AI コンテキスト制限", "description": "会話ごとに保持する直近のやり取り数です(1 往復 = ユーザーの質問 + AI の回答)。文脈が長くなりすぎて Token を消費するのを防ぎます" }, + "searchContext": { + "title": "検索コンテキストウィンドウ", + "description": "検索ヒット時に前後の会話コンテキストを自動的に含めることで、AI がメッセージの背景を理解しやすくなります。0 に設定するとコンテキストなし", + "before": "前", + "after": "後" + }, "exportFormat": { "title": "会話エクスポート形式", "description": "AI チャットをエクスポートする際のファイル形式", diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json index e24f57b..1104384 100644 --- a/src/i18n/locales/zh-CN/settings.json +++ b/src/i18n/locales/zh-CN/settings.json @@ -210,6 +210,12 @@ "title": "AI上下文限制", "description": "每次对话保留最近的对话轮数(1轮 = 用户提问 + AI回复),防止上下文过长消耗 Token" }, + "searchContext": { + "title": "搜索上下文窗口", + "description": "搜索命中消息时自动携带前后的对话上下文,帮助 AI 理解消息背景。设为 0 则不携带上下文", + "before": "前", + "after": "后" + }, "exportFormat": { "title": "对话导出格式", "description": "导出 AI 对话时使用的文件格式", diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json index 4c855d3..4c2d1de 100644 --- a/src/i18n/locales/zh-TW/settings.json +++ b/src/i18n/locales/zh-TW/settings.json @@ -210,6 +210,12 @@ "title": "AI 上下文限制", "description": "每次對話只保留最近幾輪內容(1 輪 = 使用者提問 + AI 回覆),避免上下文過長而消耗過多 Token" }, + "searchContext": { + "title": "搜尋上下文視窗", + "description": "搜尋命中訊息時自動攜帶前後的對話上下文,幫助 AI 理解訊息背景。設為 0 則不攜帶上下文", + "before": "前", + "after": "後" + }, "exportFormat": { "title": "對話匯出格式", "description": "匯出 AI 對話時使用的檔案格式", diff --git a/src/pages/settings/components/AI/AIPromptConfigTab.vue b/src/pages/settings/components/AI/AIPromptConfigTab.vue index 7f6c529..dce7986 100644 --- a/src/pages/settings/components/AI/AIPromptConfigTab.vue +++ b/src/pages/settings/components/AI/AIPromptConfigTab.vue @@ -71,6 +71,24 @@ const enableAutoSkill = computed({ emit('config-changed') }, }) + +const searchContextBefore = computed({ + get: () => aiGlobalSettings.value.searchContextBefore ?? 3, + set: (val: number) => { + const clampedVal = Math.max(0, Math.min(20, val ?? 3)) + promptStore.updateAIGlobalSettings({ searchContextBefore: clampedVal }) + emit('config-changed') + }, +}) + +const searchContextAfter = computed({ + get: () => aiGlobalSettings.value.searchContextAfter ?? 3, + set: (val: number) => { + const clampedVal = Math.max(0, Math.min(20, val ?? 3)) + promptStore.updateAIGlobalSettings({ searchContextAfter: clampedVal }) + emit('config-changed') + }, +})