From 49684f33655e0dd6ba42f3a498b6a2e2c6bc0d8d Mon Sep 17 00:00:00 2001 From: Wxw-Gu Date: Tue, 28 Jul 2026 15:52:31 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=20AI=20=E6=99=BA?= =?UTF-8?q?=E8=83=BD=E6=A3=80=E7=B4=A2=E4=B8=8E=E5=AE=9A=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 AI 查询规划和主题变体多轮检索 修复无结果时回退全量消息导致的错误结论 优化目标成员优先级和大数据量消息匹配性能 新增检索诊断日志、任务中心和持久化缓存 支持证据按时间定位档案并闪烁提示 --- src/main/index.ts | 9 +- src/main/services/settings-store.ts | 2 + src/preload/index.d.ts | 4 + src/renderer/src/App.tsx | 138 +- src/renderer/src/components/ChatWindow.tsx | 21 +- .../src/components/chat/MessageBubble.tsx | 8 +- .../src/components/chat/MessageGroup.tsx | 9 +- .../src/components/chat/MessageList.tsx | 27 +- .../components/search/AISearchWorkspace.tsx | 1323 +++++++++++++++++ .../features/settings/SettingsWorkspace.tsx | 28 +- .../settings/pages/AccountDatabasePage.tsx | 27 - .../features/settings/pages/AdvancedPage.tsx | 60 + src/renderer/src/main.tsx | 3 + src/renderer/src/styles/archive.css | 28 + src/renderer/src/styles/search.css | 1170 +++++++++++++++ src/renderer/src/styles/settings-advanced.css | 46 + 16 files changed, 2794 insertions(+), 109 deletions(-) create mode 100644 src/renderer/src/components/search/AISearchWorkspace.tsx create mode 100644 src/renderer/src/features/settings/pages/AdvancedPage.tsx create mode 100644 src/renderer/src/styles/archive.css create mode 100644 src/renderer/src/styles/search.css create mode 100644 src/renderer/src/styles/settings-advanced.css diff --git a/src/main/index.ts b/src/main/index.ts index ae59395..dfcc040 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -579,7 +579,13 @@ app.whenReady().then(async () => { ipcMain.handle( 'db:getMessages', - async (_, userMd5: string, startTime?: number, endTime?: number, options?: { limit?: number }) => { + async ( + _, + userMd5: string, + startTime?: number, + endTime?: number, + options?: { limit?: number } + ) => { const messages = await chat.listMessagesAsync(userMd5, startTime, endTime, options) if (chat.isReady()) { saveCachedMessages(chat.getCurrentAccountRoot(), userMd5, startTime, endTime, messages) @@ -654,7 +660,6 @@ app.whenReady().then(async () => { return { success: false, error: String(error) } } }) - ipcMain.handle('report:listGenerated', async () => { return listGeneratedReports() }) diff --git a/src/main/services/settings-store.ts b/src/main/services/settings-store.ts index 91cab34..f2ed04e 100644 --- a/src/main/services/settings-store.ts +++ b/src/main/services/settings-store.ts @@ -29,6 +29,7 @@ export interface AppSettings { imageAesKey: string imageKeyFallbackDisabled: boolean recallProtectionEnabled: boolean + debugEnabled: boolean autoLogin: boolean autoLoginPreferenceSet: boolean } @@ -105,6 +106,7 @@ const DEFAULT_SETTINGS: AppSettings = { imageAesKey: '', imageKeyFallbackDisabled: false, recallProtectionEnabled: false, + debugEnabled: false, autoLogin: ['1', 'true', 'yes', 'on'].includes( String(import.meta.env.VITE_AUTO_LOGIN || '') .trim() diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index 8f32c6f..929de27 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -252,6 +252,7 @@ declare global { apiPort: number imageKeyRoot: string recallProtectionEnabled: boolean + debugEnabled: boolean autoLogin: boolean autoLoginPreferenceSet: boolean imageXorKey: string @@ -279,6 +280,7 @@ declare global { apiPort: number imageKeyRoot: string recallProtectionEnabled: boolean + debugEnabled: boolean autoLogin: boolean autoLoginPreferenceSet: boolean imageXorKey: string @@ -294,6 +296,7 @@ declare global { apiPort: number imageKeyRoot: string recallProtectionEnabled: boolean + debugEnabled: boolean autoLogin: boolean autoLoginPreferenceSet: boolean imageXorKey: string @@ -307,6 +310,7 @@ declare global { apiPort: number imageKeyRoot: string recallProtectionEnabled: boolean + debugEnabled: boolean autoLogin: boolean autoLoginPreferenceSet: boolean imageXorKey: string diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index ecee3db..fb9610e 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -21,6 +21,7 @@ import { SummaryDateRange, SummaryMessageType } from './utils/group-report' import { Contact, Message } from '../../shared/types' import { DatabaseConnectionMode, DatabaseConnectionPage } from './components/DatabaseConnectionPage' import { ExportWorkspace } from './components/export/ExportWorkspace' +import { AISearchWorkspace } from './components/search/AISearchWorkspace' import type { ExportJobProgress, ExportRequest, ExportTaskRecord } from '../../shared/export' const SIDEBAR_MIN_WIDTH = 260 @@ -101,10 +102,7 @@ const enrichQuotedMessages = (messages: Message[], referenceMessages: Message[]) if (source?.name && !isInternalReferenceSender(source.name)) quotedSender = source.name } - if ( - quotedSender === quote.quotedSender && - quotedImageDatName === quote.quotedImageDatName - ) { + if (quotedSender === quote.quotedSender && quotedImageDatName === quote.quotedImageDatName) { return message } return { @@ -227,6 +225,7 @@ function App(): React.ReactElement { getDevelopmentDatabaseKey() ? 'manual' : 'automatic' ) const [activePage, setActivePage] = useState('archive') + const [archiveJumpTime, setArchiveJumpTime] = useState(null) const [settingsCategory, setSettingsCategory] = useState('account-database') const [reportSourceContact, setReportSourceContact] = useState(null) const [reportWorkspaceView, setReportWorkspaceView] = useState('result') @@ -326,7 +325,9 @@ function App(): React.ReactElement { localStorage.setItem('wxe_export_tasks', JSON.stringify(exportTasks.slice(0, 20))) }, [exportTasks]) - const handleStartExport = async (request: ExportRequest): Promise => { + const handleStartExport = async ( + request: ExportRequest + ): Promise => { const task: ExportTaskRecord = { jobId: request.jobId, contactId: request.userMd5, @@ -336,7 +337,9 @@ function App(): React.ReactElement { progress: { jobId: request.jobId, phase: 'reading', processed: 0, percent: 0 }, createdAt: Date.now() } - setExportTasks((current) => [task, ...current.filter((item) => item.jobId !== task.jobId)].slice(0, 20)) + setExportTasks((current) => + [task, ...current.filter((item) => item.jobId !== task.jobId)].slice(0, 20) + ) const result = await window.api.startExport(request) setExportTasks((current) => current.map((item) => @@ -561,23 +564,25 @@ function App(): React.ReactElement { setIsAuthenticated(true) setIsDatabaseConnected(false) setBootState('login') - void initPromise.then(async (result) => { - const success = typeof result === 'boolean' ? result : result.success - if (!success) { - const error = typeof result === 'boolean' ? '' : result.error - setDbKeyStatus(`后台连接失败${error ? `: ${error}` : ''}`) + void initPromise + .then(async (result) => { + const success = typeof result === 'boolean' ? result : result.success + if (!success) { + const error = typeof result === 'boolean' ? '' : result.error + setDbKeyStatus(`后台连接失败${error ? `: ${error}` : ''}`) + setDbKeyStatusKind('error') + return + } + setIsNativeMonitorActive(typeof result !== 'boolean' && result.monitoring === true) + setIsDatabaseConnected(true) + setDbKeyStatus('已连接数据库') + // Cached contacts/self info are enough for startup. Native refresh is + // intentionally user-triggered so it cannot freeze the first session. + }) + .catch((error) => { + console.warn('[Startup] background database init failed:', error) setDbKeyStatusKind('error') - return - } - setIsNativeMonitorActive(typeof result !== 'boolean' && result.monitoring === true) - setIsDatabaseConnected(true) - setDbKeyStatus('已连接数据库') - // Cached contacts/self info are enough for startup. Native refresh is - // intentionally user-triggered so it cannot freeze the first session. - }).catch((error) => { - console.warn('[Startup] background database init failed:', error) - setDbKeyStatusKind('error') - }) + }) return } const result = await initPromise @@ -686,17 +691,13 @@ function App(): React.ReactElement { if (hasBootstrap) { // Cached contacts are sufficient for the first paint. Refresh native data in the background. setIsAuthenticated(true) - void Promise.all([ - loadContacts({ waitForAvatars: false }), - refreshSelfInfo(3) - ]).catch((error) => { - console.warn('[Startup] background refresh failed:', error) - }) + void Promise.all([loadContacts({ waitForAvatars: false }), refreshSelfInfo(3)]).catch( + (error) => { + console.warn('[Startup] background refresh failed:', error) + } + ) } else { - await Promise.all([ - loadContacts({ waitForAvatars: false }), - refreshSelfInfo(3) - ]) + await Promise.all([loadContacts({ waitForAvatars: false }), refreshSelfInfo(3)]) setIsAuthenticated(true) } setStartupProgress({ @@ -911,6 +912,7 @@ function App(): React.ReactElement { } const handleSelectContact = async (contact: Contact, forceLive = false): Promise => { + setArchiveJumpTime(null) setSelectedContact(contact) selectedContactMd5Ref.current = contact.md5 currentGroupSnapshotRef.current = null @@ -984,6 +986,26 @@ function App(): React.ReactElement { } } + const handleOpenSearchEvidence = async (contact: Contact, createTime?: number): Promise => { + setActivePage('archive') + await handleSelectContact(contact) + if (!createTime || selectedContactMd5Ref.current !== contact.md5) return + + try { + const windowStart = Math.max(0, createTime - 12 * 3600) + const windowEnd = createTime + 12 * 3600 + const nearbyMessages = await window.api.getMessages(contact.md5, windowStart, windowEnd) + if (selectedContactMd5Ref.current !== contact.md5) return + const focusedMessages = sortMessagesChronologically(nearbyMessages) + messageHistoryRef.current = focusedMessages + setMessages(applyGroupMemberMeta(contact, mergeSyntheticMessages(contact, focusedMessages))) + setArchiveJumpTime(createTime) + } catch (error) { + console.warn('[Search] evidence context load failed:', error) + setReportNotice('证据所在时间段加载失败,请在档案中手动查看') + } + } + React.useEffect(() => { if (!isDatabaseConnected || !selectedContact) return void handleSelectContact(selectedContact) @@ -1045,7 +1067,10 @@ function App(): React.ReactElement { if (selectedContactMd5Ref.current !== contact.md5) return messageHistoryRef.current = mergeMessagePages(olderMessages, historyMessages) setMessages((current) => - applyGroupMemberMeta(contact, mergeSyntheticMessages(contact, mergeMessagePages(olderMessages, current))) + applyGroupMemberMeta( + contact, + mergeSyntheticMessages(contact, mergeMessagePages(olderMessages, current)) + ) ) } catch (error) { console.warn('[Messages] older page load failed:', error) @@ -1083,12 +1108,9 @@ function App(): React.ReactElement { } refreshInFlight = true try { - const latestMessages = await window.api.getMessages( - contactMd5, - undefined, - undefined, - { limit: INITIAL_MESSAGE_COUNT } - ) + const latestMessages = await window.api.getMessages(contactMd5, undefined, undefined, { + limit: INITIAL_MESSAGE_COUNT + }) const nextMessages = applyGroupMemberMeta( selectedContact, mergeSyntheticMessages(selectedContact, latestMessages) @@ -1114,7 +1136,9 @@ function App(): React.ReactElement { const unsubscribe = window.api.onWcdbChange(({ json }) => { const eventText = String(json || '').toLowerCase() - const targetIds = [contactMd5, selectedContact.m_nsUsrName].filter(Boolean).map((value) => value.toLowerCase()) + const targetIds = [contactMd5, selectedContact.m_nsUsrName] + .filter(Boolean) + .map((value) => value.toLowerCase()) if (!targetIds.some((targetId) => eventText.includes(targetId))) return if (refreshTimer) window.clearTimeout(refreshTimer) refreshTimer = window.setTimeout(() => { @@ -1327,24 +1351,6 @@ function App(): React.ReactElement { return { success: true } } - const renderPlaceholderPage = ( - page: Exclude - ): React.ReactElement => { - const labels: Record, string> = { - search: '检索', - export: '导出', - api: 'API', - settings: '设置' - } - return ( -
-
WechatExplorer
-

{labels[page]}

-

这个工作区会在后续 UI 重构阶段接入真实功能。

-
- ) - } - const renderArchiveWorkspace = (): React.ReactElement => (
) @@ -1489,7 +1496,20 @@ function App(): React.ReactElement { /> ) case 'search': - return renderPlaceholderPage(activePage) + return ( + void handleSelectContact(contact)} + onOpenEvidence={(contact, createTime) => + void handleOpenSearchEvidence(contact, createTime) + } + onOpenAISettings={openModelSettings} + onNotice={setReportNotice} + /> + ) case 'export': return ( Promise onCreateGroupReport?: () => void isAiLoading?: boolean + jumpToTime?: number | null } const ChatWindow: React.FC = ({ @@ -31,7 +32,8 @@ const ChatWindow: React.FC = ({ onReloadAvatars, onLoadOlderMessages, onCreateGroupReport, - isAiLoading = false + isAiLoading = false, + jumpToTime }) => { const isGroupChat = Boolean( contact?.type === 'group' || contact?.m_nsUsrName?.endsWith('@chatroom') @@ -73,19 +75,23 @@ const ChatWindow: React.FC = ({ }, [contact?.md5]) useEffect(() => { - if (!isAtLatest) return - const frame = window.requestAnimationFrame(() => scrollToBottom()) - return () => window.cancelAnimationFrame(frame) - }, [isAtLatest, messages, scrollToBottom]) + if (jumpToTime !== undefined && jumpToTime !== null) setIsAtLatest(false) + }, [jumpToTime]) useEffect(() => { - if (!isAtLatest) return + if (!isAtLatest || (jumpToTime !== undefined && jumpToTime !== null)) return + const frame = window.requestAnimationFrame(() => scrollToBottom()) + return () => window.cancelAnimationFrame(frame) + }, [isAtLatest, jumpToTime, messages, scrollToBottom]) + + useEffect(() => { + if (!isAtLatest || (jumpToTime !== undefined && jumpToTime !== null)) return const content = messageListRef.current?.querySelector('.virtual-message-list') if (!content) return const observer = new ResizeObserver(() => scrollToBottom()) observer.observe(content) return () => observer.disconnect() - }, [contact?.md5, isAtLatest, scrollToBottom]) + }, [contact?.md5, isAtLatest, jumpToTime, scrollToBottom]) const openImagePreview = (imageUrl: string): void => { setPreviewImage(imageUrl) @@ -206,6 +212,7 @@ const ChatWindow: React.FC = ({ onScroll={handleMessageListScroll} onReachTop={onLoadOlderMessages} onImageClick={openImagePreview} + jumpToTime={jumpToTime} /> void + isJumpTarget?: boolean } const RICH_MESSAGE_TYPES = [ @@ -39,7 +40,8 @@ export function MessageBubble({ isGroupChat, isMine, showAvatarSpace, - onImageClick + onImageClick, + isJumpTarget }: MessageBubbleProps): React.ReactElement { const isVoice = message.type === '语音' const isImage = message.type === '图片' @@ -48,7 +50,9 @@ export function MessageBubble({ const hoverTime = formatMessageTime(message) return ( -
+
void + jumpTargetMessageId?: string } export function MessageGroup({ @@ -16,7 +17,8 @@ export function MessageGroup({ contact, isGroupChat, showAvatar, - onImageClick + onImageClick, + jumpTargetMessageId }: MessageGroupProps): React.ReactElement { const firstMessage = group.messages[0] @@ -57,9 +59,7 @@ export function MessageGroup({ )} {!isMine && !shouldShowAvatar &&
}
- {!isMine && isGroupChat && ( -
{displayName}
- )} + {!isMine && isGroupChat &&
{displayName}
} {group.messages.map((message, index) => ( ))}
diff --git a/src/renderer/src/components/chat/MessageList.tsx b/src/renderer/src/components/chat/MessageList.tsx index dafe01f..d2fe589 100644 --- a/src/renderer/src/components/chat/MessageList.tsx +++ b/src/renderer/src/components/chat/MessageList.tsx @@ -16,6 +16,7 @@ interface MessageListProps { onScroll: (event: React.UIEvent) => void onReachTop?: () => Promise onImageClick: (imageUrl: string) => void + jumpToTime?: number | null } export function MessageList({ @@ -29,14 +30,13 @@ export function MessageList({ bottomRef, onScroll, onReachTop, - onImageClick + onImageClick, + jumpToTime }: MessageListProps): React.ReactElement { const groups = React.useMemo(() => buildMessageGroups(messages), [messages]) const groupsRef = React.useRef(groups) const loadingOlderRef = React.useRef(false) groupsRef.current = groups - // TanStack Virtual intentionally exposes mutable measurement methods. - // eslint-disable-next-line react-hooks/incompatible-library const virtualizer = useVirtualizer({ count: groups.length, getScrollElement: () => listRef.current, @@ -45,11 +45,29 @@ export function MessageList({ overscan: 8 }) const virtualItems = virtualizer.getVirtualItems() + const jumpTarget = React.useMemo(() => { + if (jumpToTime === undefined || jumpToTime === null) return null + const groupIndex = groups.findIndex((group) => + group.messages.some((message) => (message.createTime || 0) >= jumpToTime) + ) + if (groupIndex < 0) return null + const message = groups[groupIndex].messages.find((item) => (item.createTime || 0) >= jumpToTime) + return { groupIndex, messageId: message?.id } + }, [groups, jumpToTime]) + + React.useEffect(() => { + if (!jumpTarget) return + const frame = window.requestAnimationFrame(() => { + virtualizer.scrollToIndex(jumpTarget.groupIndex, { align: 'center' }) + }) + return () => window.cancelAnimationFrame(frame) + }, [jumpTarget, virtualizer]) const handleScroll = (event: React.UIEvent): void => { onScroll(event) const scrollElement = event.currentTarget if ( + (jumpToTime !== undefined && jumpToTime !== null) || scrollElement.scrollTop >= 48 || loadingOlderRef.current || isLoadingMessages || @@ -117,7 +135,7 @@ export function MessageList({ key={virtualItem.key} ref={virtualizer.measureElement} data-index={virtualItem.index} - className="virtual-message-group" + className={`virtual-message-group ${jumpTarget?.groupIndex === virtualItem.index ? 'archive-jump-target-group' : ''}`} style={{ transform: `translateY(${virtualItem.start}px)` }} >
) diff --git a/src/renderer/src/components/search/AISearchWorkspace.tsx b/src/renderer/src/components/search/AISearchWorkspace.tsx new file mode 100644 index 0000000..4c60155 --- /dev/null +++ b/src/renderer/src/components/search/AISearchWorkspace.tsx @@ -0,0 +1,1323 @@ +import React, { useMemo, useRef, useState } from 'react' +import type { AIRuntimeModelConfig } from '../../../../shared/ai-provider' +import type { Contact, Message } from '../../../../shared/types' +import { SearchIcon } from '../chat/icons' + +type SearchStage = 'idle' | 'loading' | 'result' | 'insufficient' +type SearchScope = 'global' | 'conversation' +type SearchRange = 'today' | '7d' | '30d' | 'all' +type SearchIntent = 'general' | 'topic' | 'participants' | 'mixed' + +interface EvidenceItem { + contact: Contact + message: Message +} + +interface AISearchCacheRecord { + version: 1 + key: string + createdAt: number + answer: string + evidence: EvidenceItem[] + senderNames: Record + messageCount: number +} + +interface GroupMemberName { + wxid: string + nickname?: string + groupNickname?: string + remark?: string + wechatNickname?: string +} + +interface SenderDirectory { + displayNames: Record + aliases: Record +} + +interface SearchQueryPlan { + intent: SearchIntent + keywords: string[] + variants: string[] + source: 'local' | 'ai' | 'hybrid' +} + +interface SearchPassSummary { + label: string + keywords: string[] + messageCount: number +} + +interface AISearchWorkspaceProps { + contacts: Contact[] + selectedContact: Contact | null + dbReady: boolean + aiModelConfig: AIRuntimeModelConfig + onSelectContact: (contact: Contact) => void + onOpenEvidence: (contact: Contact, createTime?: number) => void + onOpenAISettings: () => void + onNotice: (message: string) => void +} + +const RANGE_LABELS: Record = { + today: '今天', + '7d': '近 7 天', + '30d': '近 30 天', + all: '全部历史' +} + +const SEARCH_CACHE_KEY = 'wxe_ai_search_cache_v8' +const SEARCH_HISTORY_KEY = 'wxe_ai_search_history_v1' +const SEARCH_CACHE_LIMIT = 20 +const currentTimestamp = (): number => Date.now() + +const SEARCH_INTENT_PHRASES = [ + '全局搜一下', + '全局搜索', + '搜索一下', + '搜一下', + '查询一下', + '查一下', + '找一下', + '我和谁聊过', + '谁和我聊过', + '哪些人和我聊过', + '最近讨论了什么', + '最近聊了什么', + '最近说了什么', + '讨论了什么', + '讨论什么', + '聊了什么', + '聊些什么', + '说了什么', + '说些什么', + '最近讨论', + '最近聊天', + '这个话题', + '相关话题', + '的聊天', + '的内容', + '的记录', + '关于', + '聊天', + '记录', + '聊天记录', + '帮我', + '请问' +].sort((left, right) => right.length - left.length) + +const SEARCH_STOP_WORDS = new Set([ + '我', + '谁', + '什么', + '哪些', + '哪个', + '人', + '和', + '聊过', + '说过', + '提到', + '讨论', + '聊天', + '记录', + '说', + '聊', + '话题', + '内容', + '相关', + '最近', + '一下' +]) + +const messageText = (message: Message): string => + String(message.content || '').trim() || `[${message.type || '消息'}]` + +const normalizeSearchText = (value: string): string => value.toLowerCase().replace(/\s+/g, '') + +const includesSearchAlias = (query: string, alias: string): boolean => { + const normalizedAlias = normalizeSearchText(alias.trim()) + return Boolean(normalizedAlias) && normalizeSearchText(query).includes(normalizedAlias) +} + +const extractSearchKeywords = (query: string): string[] => { + const cleanedQuery = SEARCH_INTENT_PHRASES.reduce( + (value, phrase) => value.split(phrase).join(' '), + query.toLowerCase() + ) + const tokens = cleanedQuery + .split(/[\s,,。!?!?、::;;"“”‘’()()[\]【】]+/) + .map((token) => token.trim()) + .filter((token) => token.length >= 2 && !SEARCH_STOP_WORDS.has(token)) + return Array.from(new Set(tokens)) +} + +const getFuzzySearchKeywords = (keywords: string[]): string[] => + Array.from( + new Set( + keywords.flatMap((keyword) => { + const variants = [keyword] + if (/^[\u4e00-\u9fff]+$/.test(keyword) && keyword.length > 2) { + variants.push(keyword.slice(-2)) + } + return variants + }) + ) + ) + +const normalizeSearchTerms = (terms: unknown): string[] => { + if (!Array.isArray(terms)) return [] + return Array.from( + new Set( + terms + .filter((term): term is string => typeof term === 'string') + .map((term) => term.trim()) + .filter((term) => term.length >= 2 && term.length <= 32) + ) + ).slice(0, 16) +} + +const buildLocalSearchPlan = (query: string): SearchQueryPlan => { + const keywords = extractSearchKeywords(query) + const asksParticipants = /我和谁|谁和我|哪些人|哪个人|哪些联系人/.test(query) + const intent: SearchIntent = asksParticipants + ? keywords.length + ? 'mixed' + : 'participants' + : keywords.length + ? 'topic' + : 'general' + return { + intent, + keywords, + variants: getFuzzySearchKeywords(keywords), + source: 'local' + } +} + +const parseSearchPlanResponse = (value: string): Partial | null => { + const jsonMatch = value.match(/\{[\s\S]*\}/) + if (!jsonMatch) return null + try { + const parsed = JSON.parse(jsonMatch[0]) as Record + const intent = ['general', 'topic', 'participants', 'mixed'].includes(String(parsed.intent)) + ? (parsed.intent as SearchIntent) + : undefined + return { + intent, + keywords: normalizeSearchTerms(parsed.keywords), + variants: normalizeSearchTerms(parsed.variants) + } + } catch { + return null + } +} + +const mergeSearchPlans = ( + localPlan: SearchQueryPlan, + aiPlan: Partial | null +): SearchQueryPlan => { + if (!aiPlan) return localPlan + const keywords = normalizeSearchTerms([...(localPlan.keywords || []), ...(aiPlan.keywords || [])]) + const variants = normalizeSearchTerms([ + ...getFuzzySearchKeywords(keywords), + ...(localPlan.variants || []), + ...(aiPlan.variants || []) + ]) + return { + intent: aiPlan.intent || localPlan.intent, + keywords, + variants, + source: 'hybrid' + } +} + +const messageIdentity = (message: Message): string => + message.localId + ? `local:${message.localId}` + : message.id || `${message.createTime}:${message.content}` + +const evidenceIdentity = ({ contact, message }: EvidenceItem): string => + `${contact.md5}:${messageIdentity(message)}` + +const formatMessageTime = (message: Message): string => { + if (message.datetime) return message.datetime + if (message.createTime) return new Date(message.createTime * 1000).toLocaleString('zh-CN') + return '未知时间' +} + +const getRangeStart = (range: SearchRange): number | undefined => { + if (range === 'all') return undefined + if (range === 'today') { + const now = new Date() + return Math.floor(new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime() / 1000) + } + const days = range === '7d' ? 7 : 30 + return Math.floor(Date.now() / 1000) - days * 86400 +} + +const messageDateKey = (message: Message): string => { + if (!message.createTime) return 'unknown' + const date = new Date(message.createTime * 1000) + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + return `${year}-${month}-${day}` +} + +const formatMessageDate = (dateKey: string): string => { + if (dateKey === 'unknown') return '--/--' + const [, month, day] = dateKey.split('-') + return `${month}/${day}` +} + +const selectEvenly = (items: T[], count: number): T[] => { + if (count >= items.length) return items + if (count <= 0) return [] + if (count === 1) return [items[items.length - 1]] + return Array.from({ length: count }, (_item, index) => { + const itemIndex = Math.round((index * (items.length - 1)) / (count - 1)) + return items[itemIndex] + }) +} + +const selectEvidenceByDate = (items: EvidenceItem[], maxItems: number): EvidenceItem[] => { + const buckets = new Map() + items.forEach((item) => { + const key = messageDateKey(item.message) + const bucket = buckets.get(key) || [] + bucket.push(item) + buckets.set(key, bucket) + }) + const entries = Array.from(buckets.entries()).sort(([left], [right]) => left.localeCompare(right)) + const targetCount = Math.min(maxItems, items.length) + const counts = entries.map(() => 0) + let remaining = targetCount + while (remaining > 0) { + let bestIndex = -1 + let bestRemaining = 0 + entries.forEach(([, bucket], index) => { + const available = bucket.length - counts[index] + if (available > bestRemaining) { + bestIndex = index + bestRemaining = available + } + }) + if (bestIndex < 0) break + counts[bestIndex] += 1 + remaining -= 1 + } + return entries + .flatMap((entry, index) => selectEvenly(entry[1], counts[index])) + .sort((left, right) => (left.message.createTime || 0) - (right.message.createTime || 0)) +} + +const looksLikeUserId = (value: string): boolean => + value.startsWith('wxid_') || value.includes('@chatroom') || /^\d{6,}$/.test(value) + +const formatMemberName = (member: GroupMemberName): string => + member.groupNickname || member.wechatNickname || member.nickname || member.remark || member.wxid + +const senderName = (message: Message, contact: Contact, names: Record): string => { + const identifiers = [message.senderId, message.from, message.name].filter( + (value): value is string => Boolean(value?.trim()) + ) + const mappedName = identifiers.map((value) => names[value]).find(Boolean) + if (mappedName) return mappedName + if (message.name && !looksLikeUserId(message.name)) return message.name + if (message.isSender) return '我' + return contact.type === 'user' ? contact.m_nsNickName || '联系人' : '群成员' +} + +const compactCacheItem = ({ contact, message }: EvidenceItem): EvidenceItem => ({ + contact: { + md5: contact.md5, + m_nsUsrName: contact.m_nsUsrName, + m_nsNickName: contact.m_nsNickName, + type: contact.type, + avatar: contact.avatar + }, + message: { + id: message.id, + from: message.from, + type: message.type, + datetime: message.datetime, + content: message.content, + isSender: message.isSender, + name: message.name, + senderId: message.senderId, + localId: message.localId, + serverId: message.serverId, + createTime: message.createTime + } +}) + +const buildSearchCacheKey = ( + scope: SearchScope, + contactMd5: string, + range: SearchRange, + query: string +): string => JSON.stringify([scope, contactMd5, range, query.trim().toLowerCase()]) + +const parseSearchCacheKey = ( + key: string +): { scope: SearchScope; contactMd5: string; range: SearchRange; query: string } | null => { + try { + const parts = JSON.parse(key) as unknown + if ( + !Array.isArray(parts) || + !['global', 'conversation'].includes(String(parts[0])) || + !['today', '7d', '30d', 'all'].includes(String(parts[2])) || + typeof parts[3] !== 'string' + ) { + return null + } + return { + scope: parts[0] as SearchScope, + contactMd5: typeof parts[1] === 'string' ? parts[1] : '', + range: parts[2] as SearchRange, + query: parts[3] + } + } catch { + return null + } +} + +const readSearchCache = (key: string): AISearchCacheRecord | null => { + try { + const records = JSON.parse( + localStorage.getItem(SEARCH_CACHE_KEY) || '[]' + ) as AISearchCacheRecord[] + const record = records.find((item) => item.version === 1 && item.key === key) + return record || null + } catch { + return null + } +} + +const readSearchCacheByQuery = ( + query: string +): { record: AISearchCacheRecord; location: ReturnType } | null => { + try { + const records = JSON.parse( + localStorage.getItem(SEARCH_CACHE_KEY) || '[]' + ) as AISearchCacheRecord[] + const normalizedQuery = query.trim().toLowerCase() + for (const record of records) { + const location = parseSearchCacheKey(record.key) + if (record.version === 1 && location?.query === normalizedQuery) { + return { record, location } + } + } + return null + } catch { + return null + } +} + +const writeSearchCache = (record: AISearchCacheRecord): void => { + try { + const records = JSON.parse( + localStorage.getItem(SEARCH_CACHE_KEY) || '[]' + ) as AISearchCacheRecord[] + const nextRecords = [record, ...records.filter((item) => item.key !== record.key)].slice( + 0, + SEARCH_CACHE_LIMIT + ) + localStorage.setItem(SEARCH_CACHE_KEY, JSON.stringify(nextRecords)) + } catch { + // A large message result must not break the search itself. + } +} + +const inlineMarkdown = (value: string, keyPrefix: string): React.ReactNode[] => + value.split(/(\*\*.*?\*\*|`.*?`|\*.*?\*)/g).map((part, index) => { + const key = `${keyPrefix}-${index}` + if (part.startsWith('**') && part.endsWith('**')) { + return {part.slice(2, -2)} + } + if (part.startsWith('`') && part.endsWith('`')) { + return {part.slice(1, -1)} + } + if (part.startsWith('*') && part.endsWith('*')) { + return {part.slice(1, -1)} + } + return {part} + }) + +const renderMarkdown = (value: string): React.ReactNode => + value.split(/\r?\n/).map((line, index) => { + const key = `markdown-${index}` + if (!line.trim()) return
+ const heading = /^(#{1,3})\s+(.+)$/.exec(line) + if (heading) { + const Heading = `h${heading[1].length}` as 'h1' | 'h2' | 'h3' + return {inlineMarkdown(heading[2], key)} + } + const bullet = /^\s*[-*]\s+(.+)$/.exec(line) + if (bullet) { + return ( +
+ + {inlineMarkdown(bullet[1], key)} +
+ ) + } + const numbered = /^\s*\d+[.)]\s+(.+)$/.exec(line) + if (numbered) { + return ( +
+ {line.trim().match(/^\d+/)?.[0]}. + {inlineMarkdown(numbered[1], key)} +
+ ) + } + return

{inlineMarkdown(line, key)}

+ }) + +export function AISearchWorkspace({ + contacts, + selectedContact, + dbReady, + aiModelConfig, + onSelectContact, + onOpenEvidence, + onOpenAISettings, + onNotice +}: AISearchWorkspaceProps): React.ReactElement { + const allContacts = useMemo(() => contacts.filter((contact) => contact.md5), [contacts]) + const availableContacts = allContacts.slice(0, 80) + const [scope, setScope] = useState('global') + const [scopeContactMd5, setScopeContactMd5] = useState(selectedContact?.md5 || '') + const [range, setRange] = useState('7d') + const [contactFilter, setContactFilter] = useState('') + const [query, setQuery] = useState('') + const [stage, setStage] = useState('idle') + const [answer, setAnswer] = useState('') + const [evidence, setEvidence] = useState([]) + const [selectedEvidence, setSelectedEvidence] = useState(0) + const [analysisError, setAnalysisError] = useState('') + const [messageCount, setMessageCount] = useState(0) + const [history, setHistory] = useState(() => { + try { + const stored = JSON.parse(localStorage.getItem(SEARCH_HISTORY_KEY) || '[]') + return Array.isArray(stored) + ? stored.filter((item): item is string => typeof item === 'string') + : [] + } catch { + return [] + } + }) + const [senderNames, setSenderNames] = useState>({}) + const [cachedAt, setCachedAt] = useState(0) + const [debugEnabled, setDebugEnabled] = useState(false) + const [debugPanelOpen, setDebugPanelOpen] = useState(false) + const [debugEntries, setDebugEntries] = useState([]) + const [appLogPath, setAppLogPath] = useState('') + const bypassCacheRef = useRef(false) + + React.useEffect(() => { + void Promise.all([window.api.getSettings(), window.api.getAppLogPath()]).then( + ([settingsResult, logPath]) => { + setDebugEnabled(settingsResult.settings.debugEnabled) + setAppLogPath(logPath) + } + ) + }, []) + + const addDebugEntry = (message: string, details: Record = {}): void => { + const entry = `${new Date().toLocaleTimeString('zh-CN')} ${message} ${JSON.stringify(details)}` + setDebugEntries((current) => [entry, ...current].slice(0, 80)) + if (debugEnabled) { + void window.api + .writeAppLog({ level: 'info', scope: 'ai-search', message, details }) + .catch(() => undefined) + } + } + + const activeContact = + availableContacts.find( + (contact) => contact.md5 === (scopeContactMd5 || selectedContact?.md5) + ) || selectedContact + const visibleContacts = availableContacts.filter((contact) => { + const keyword = contactFilter.trim().toLowerCase() + if (!keyword) return true + return ( + contact.m_nsNickName.toLowerCase().includes(keyword) || + contact.m_nsUsrName.toLowerCase().includes(keyword) + ) + }) + const sourceLabel = + scope === 'conversation' ? activeContact?.m_nsNickName || '未选择会话' : '全局搜索' + const modelLabel = aiModelConfig.configured + ? `${aiModelConfig.providerName} · ${aiModelConfig.modelName}` + : '尚未配置 AI 模型' + + const buildSourceContacts = (): Contact[] => { + if (scope === 'conversation') return activeContact ? [activeContact] : [] + return allContacts + } + + const loadSourcePages = async ( + sourceContacts: Contact[], + startTime: number | undefined + ): Promise<{ contact: Contact; messages: Message[] }[]> => { + const pages = new Array<{ contact: Contact; messages: Message[] }>(sourceContacts.length) + let nextIndex = 0 + const worker = async (): Promise => { + while (nextIndex < sourceContacts.length) { + const index = nextIndex + nextIndex += 1 + const contact = sourceContacts[index] + pages[index] = { + contact, + messages: await window.api.getMessages(contact.md5, startTime) + } + } + } + const workerCount = Math.min(6, sourceContacts.length) + await Promise.all(Array.from({ length: workerCount }, () => worker())) + return pages + } + + const rememberQuery = (value: string): void => { + setHistory((current) => { + const next = [value, ...current.filter((item) => item !== value)].slice(0, 10) + try { + localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(next)) + } catch { + // History persistence is optional and must not interrupt analysis. + } + return next + }) + } + + const removeHistoryQuery = (historyQuery: string): void => { + setHistory((current) => { + const next = current.filter((item) => item !== historyQuery) + try { + localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(next)) + } catch { + // History persistence is optional and must not interrupt analysis. + } + return next + }) + try { + const records = JSON.parse( + localStorage.getItem(SEARCH_CACHE_KEY) || '[]' + ) as AISearchCacheRecord[] + const queryKey = historyQuery.trim().toLowerCase() + const nextRecords = records.filter((item) => { + try { + const keyParts = JSON.parse(item.key) as unknown + return !( + Array.isArray(keyParts) && + typeof keyParts[3] === 'string' && + keyParts[3] === queryKey + ) + } catch { + return true + } + }) + localStorage.setItem(SEARCH_CACHE_KEY, JSON.stringify(nextRecords)) + } catch { + // Cache cleanup is optional and must not interrupt the current workspace. + } + onNotice('已删除这条最近提问') + } + + const applyCachedResult = (cached: AISearchCacheRecord, queryValue = query.trim()): void => { + setAnswer(cached.answer) + setEvidence(cached.evidence) + setSenderNames(cached.senderNames) + setMessageCount(cached.messageCount) + setCachedAt(cached.createdAt) + rememberQuery(queryValue) + } + + const restoreHistoryQuery = (historyQuery: string): void => { + setQuery(historyQuery) + setSelectedEvidence(0) + const cacheKey = buildSearchCacheKey( + scope, + scope === 'conversation' ? activeContact?.md5 || '' : '', + range, + historyQuery + ) + const cached = readSearchCache(cacheKey) || readSearchCacheByQuery(historyQuery)?.record || null + if (!cached) { + setAnswer('') + setEvidence([]) + setCachedAt(0) + setStage('idle') + onNotice('这条提问没有可恢复的缓存,请点击开始分析重新读取消息') + return + } + const cachedLocation = parseSearchCacheKey(cached.key) + if (cachedLocation) { + setScope(cachedLocation.scope) + setRange(cachedLocation.range) + setScopeContactMd5(cachedLocation.contactMd5) + } + setAnalysisError('') + applyCachedResult(cached, historyQuery) + setStage('result') + onNotice('已恢复这条提问的检索结果') + } + + const loadSenderDirectory = async (sourceContacts: Contact[]): Promise => { + const groupContacts = sourceContacts.filter((contact) => contact.type === 'group') + const snapshots = await Promise.all( + groupContacts.map(async (contact) => { + const snapshot = (await window.api.getGroupSnapshot(contact.md5)) as { + members?: GroupMemberName[] + } | null + return snapshot?.members || [] + }) + ) + const displayNames: Record = {} + const aliases: Record = {} + snapshots.flat().forEach((member) => { + if (!member.wxid) return + displayNames[member.wxid] = formatMemberName(member) + aliases[member.wxid] = [ + member.groupNickname, + member.nickname, + member.remark, + member.wechatNickname, + member.wxid + ].filter((name): name is string => Boolean(name?.trim())) + }) + return { displayNames, aliases } + } + + const runAnalysis = async (event?: React.FormEvent): Promise => { + event?.preventDefault() + const normalizedQuery = query.trim() + if (!normalizedQuery) { + setAnalysisError('先输入一个想了解的问题') + setStage('insufficient') + return + } + if (!dbReady) { + setAnalysisError('数据库尚未连接,暂时无法读取聊天记录') + setStage('insufficient') + return + } + if (!aiModelConfig.configured) { + setAnalysisError('尚未配置 AI 模型,请先在设置中添加可用的模型供应商') + setStage('insufficient') + return + } + + const sourceContacts = buildSourceContacts() + if (!sourceContacts.length) { + setAnalysisError('没有可检索的聊天范围') + setStage('insufficient') + return + } + + setStage('loading') + setAnalysisError('') + setAnswer('') + setEvidence([]) + setSelectedEvidence(0) + setCachedAt(0) + const cacheKey = buildSearchCacheKey( + scope, + scope === 'conversation' ? activeContact?.md5 || '' : '', + range, + normalizedQuery + ) + try { + const cached = bypassCacheRef.current ? null : readSearchCache(cacheKey) + bypassCacheRef.current = false + if (cached) { + addDebugEntry('检索命中缓存', { scope, range, messageCount: cached.messageCount }) + applyCachedResult(cached, normalizedQuery) + setStage('result') + onNotice('已使用最近的检索缓存,可点击刷新数据读取最新消息') + return + } + const localSearchPlan = buildLocalSearchPlan(normalizedQuery) + let searchPlan = localSearchPlan + try { + const planResult = await window.api.aiChat([ + { + role: 'system', + content: + '你是本地聊天检索规划器,不回答用户问题。请从用户问题中提取用于本地数据库检索的主题词和同义短语,只输出 JSON:{"intent":"general|topic|participants|mixed","keywords":["..."],"variants":["..."]}。删除“全局搜索、我和谁聊过、这个话题”等意图词,不要编造人名或聊天内容。' + }, + { + role: 'user', + content: `用户问题:${normalizedQuery}` + } + ]) + const aiPlan = + planResult.success && planResult.data ? parseSearchPlanResponse(planResult.data) : null + searchPlan = mergeSearchPlans(localSearchPlan, aiPlan) + addDebugEntry('AI 检索规划完成', { + source: searchPlan.source, + intent: searchPlan.intent, + keywords: searchPlan.keywords, + variants: searchPlan.variants, + aiPlanSuccess: Boolean(aiPlan) + }) + } catch (error) { + addDebugEntry('AI 检索规划失败,使用本地规划', { + error: error instanceof Error ? error.message : '未知错误', + keywords: localSearchPlan.keywords + }) + } + const startTime = getRangeStart(range) + const pages = await loadSourcePages(sourceContacts, startTime) + const sourceMessages = pages.flatMap((page) => + page.messages.map((message) => ({ contact: page.contact, message })) + ) + const uniqueMessages = Array.from( + new Map( + sourceMessages.map((item) => [ + `${item.contact.md5}:${messageIdentity(item.message)}`, + item + ]) + ).values() + ).sort((left, right) => (left.message.createTime || 0) - (right.message.createTime || 0)) + if (!uniqueMessages.length) { + addDebugEntry('检索没有消息', { contactCount: sourceContacts.length, scope, range }) + setAnalysisError('当前范围内没有找到可分析的消息,请扩大时间范围或更换会话') + setStage('insufficient') + return + } + + const queryKeywords = searchPlan.keywords + const fuzzySearchKeywords = searchPlan.variants + const contactNamesInQuery = sourceContacts.filter((contact) => + [contact.m_nsNickName, contact.m_nsUsrName, contact.remark, contact.wechatNickname] + .filter((name): name is string => Boolean(name?.trim())) + .some((name) => includesSearchAlias(normalizedQuery, name)) + ) + const senderDirectoryContacts = + scope === 'conversation' ? sourceContacts : contactNamesInQuery + const querySenderDirectory = await loadSenderDirectory(senderDirectoryContacts) + const matchedSenderIds = new Set( + Object.entries(querySenderDirectory.aliases) + .filter(([, aliases]) => + aliases.some((name) => includesSearchAlias(normalizedQuery, name)) + ) + .map(([senderId]) => senderId) + ) + const matchedContactIds = new Set(contactNamesInQuery.map((contact) => contact.md5)) + const senderMatchedMessages = uniqueMessages.filter(({ message }) => { + const senderFields = [message.name, message.senderId, message.from].filter( + (value): value is string => Boolean(value?.trim()) + ) + return ( + senderFields.some((value) => matchedSenderIds.has(value)) || + senderFields.some((value) => includesSearchAlias(normalizedQuery, value)) + ) + }) + const senderMessageIds = new Set(senderMatchedMessages.map(evidenceIdentity)) + const searchPasses = [ + { label: '主题精确匹配', keywords: queryKeywords }, + { label: 'AI 变体匹配', keywords: fuzzySearchKeywords } + ] + const keywordMatchedMap = new Map() + const passSummaries: SearchPassSummary[] = [] + for (const pass of searchPasses) { + const passMatches = uniqueMessages.filter(({ message }) => { + const text = normalizeSearchText(messageText(message)) + return pass.keywords.some((keyword) => text.includes(normalizeSearchText(keyword))) + }) + passMatches.forEach((item) => keywordMatchedMap.set(evidenceIdentity(item), item)) + passSummaries.push({ + label: pass.label, + keywords: pass.keywords, + messageCount: passMatches.length + }) + } + const keywordMatchedMessages = Array.from(keywordMatchedMap.values()) + const keywordMessageIds = new Set(keywordMatchedMessages.map(evidenceIdentity)) + const relevantMessages = uniqueMessages.filter(({ contact, message }) => { + if (matchedContactIds.has(contact.md5)) return true + const itemKey = evidenceIdentity({ contact, message }) + return senderMessageIds.has(itemKey) || keywordMessageIds.has(itemKey) + }) + const hasSearchConstraint = + queryKeywords.length > 0 || fuzzySearchKeywords.length > 0 || matchedContactIds.size > 0 + if (!relevantMessages.length && hasSearchConstraint) { + const attemptedTerms = Array.from(new Set([...queryKeywords, ...fuzzySearchKeywords])).join( + '、' + ) + const noResultMessage = `${RANGE_LABELS[range]}内没有找到包含“${attemptedTerms || '主题关键词'}”的聊天消息。已完成精确匹配和智能变体匹配,未回退到全量消息;可以扩大时间范围或换一个更具体的词。` + addDebugEntry('检索未找到相关消息', { + contactCount: sourceContacts.length, + uniqueMessageCount: uniqueMessages.length, + passSummaries, + fallbackToAllMessages: false + }) + setAnalysisError(noResultMessage) + setStage('insufficient') + return + } + const analysisMessages = relevantMessages.length ? relevantMessages : uniqueMessages + addDebugEntry('检索消息匹配完成', { + contactCount: sourceContacts.length, + uniqueMessageCount: uniqueMessages.length, + contactNameMatchCount: contactNamesInQuery.length, + searchIntent: searchPlan.intent, + searchPlanSource: searchPlan.source, + queryKeywords, + fuzzyKeywordCount: fuzzySearchKeywords.length, + passSummaries, + queryAliasCount: Object.keys(querySenderDirectory.aliases).length, + senderMatchCount: matchedSenderIds.size, + senderMessageCount: senderMatchedMessages.length, + keywordMessageCount: keywordMatchedMessages.length, + relevantMessageCount: relevantMessages.length, + fallbackToAllMessages: relevantMessages.length === 0 + }) + const analysisContactIds = new Set(analysisMessages.map(({ contact }) => contact.md5)) + const analysisContacts = sourceContacts.filter((contact) => + analysisContactIds.has(contact.md5) + ) + const resolvedSenderNames = { + ...querySenderDirectory.displayNames, + ...( + await loadSenderDirectory( + analysisContacts.filter((contact) => !matchedContactIds.has(contact.md5)) + ) + ).displayNames + } + const primaryMessages = senderMatchedMessages.length + ? senderMatchedMessages + : keywordMatchedMessages + const primaryMessageIds = new Set(primaryMessages.map(evidenceIdentity)) + const selectedEvidenceItems = primaryMessages.length + ? selectEvenly(primaryMessages, 8) + : selectEvidenceByDate(analysisMessages, 8) + const contextItems = primaryMessages.length + ? [ + ...selectEvenly(primaryMessages, Math.min(160, primaryMessages.length)), + ...selectEvidenceByDate( + analysisMessages.filter((item) => !primaryMessageIds.has(evidenceIdentity(item))), + 80 + ) + ] + : selectEvidenceByDate(analysisMessages, 240) + const dateCounts = new Map() + const senderCounts = new Map() + analysisMessages.forEach(({ contact, message }) => { + const date = messageDateKey(message) + dateCounts.set(date, (dateCounts.get(date) || 0) + 1) + const name = senderName(message, contact, resolvedSenderNames) + senderCounts.set(name, (senderCounts.get(name) || 0) + 1) + }) + const dateSummary = Array.from(dateCounts.entries()) + .map(([date, count]) => `${formatMessageDate(date)} ${count} 条`) + .join('、') + const senderSummary = Array.from(senderCounts.entries()) + .sort(([, left], [, right]) => right - left) + .slice(0, 12) + .map(([name, count]) => `${name} ${count} 条`) + .join('、') + const asksConversationParticipants = + searchPlan.intent === 'participants' || searchPlan.intent === 'mixed' + const context = contextItems + .map( + ({ contact, message }) => + `[${formatMessageTime(message)}] ${contact.m_nsNickName} / ${senderName(message, contact, resolvedSenderNames)}: ${messageText(message)}` + ) + .join('\n') + const aiResult = await window.api.aiChat([ + { + role: 'system', + content: + '你是 WechatExplorer 的本地聊天记录分析助手。只能基于提供的消息回答,不得编造事实。请用中文回答,先给出简短摘要,再列出关键主题、结论和不确定性。对人物问题只能描述群聊中的发言主题和可能角色,不做人格或敏感属性判断。' + }, + { + role: 'user', + content: `检索范围:${sourceLabel},时间:${RANGE_LABELS[range]}\n用户问题:${normalizedQuery}\n检索意图:${searchPlan.intent}\n检索关键词:${queryKeywords.join('、') || '未提取到主题关键词'}\n检索变体:${fuzzySearchKeywords.join('、') || '无'}\n相关消息数:${analysisMessages.length}\n目标成员消息数:${senderMatchedMessages.length}\n检索范围消息总数:${uniqueMessages.length}\n覆盖日期:${new Set(analysisMessages.map((item) => messageDateKey(item.message))).size} 天\n按日期统计:${dateSummary}\n主要发言者统计:${senderSummary}\n${asksConversationParticipants ? '\n这是一个“我和谁聊过”的问题,请按聊天会话和联系人归纳,优先列出实际出现主题关键词的会话,不要根据全量消息猜测。' : ''}\n以下是按检索轮次命中的原始消息,优先级最高的消息排在前面,不代表全部消息:\n${context}` + } + ]) + if (!aiResult.success || !aiResult.data) { + setAnalysisError(aiResult.error || 'AI 分析失败,请稍后重试') + setStage('insufficient') + return + } + setAnswer(aiResult.data) + setEvidence(selectedEvidenceItems) + setSenderNames(resolvedSenderNames) + setMessageCount(uniqueMessages.length) + rememberQuery(normalizedQuery) + writeSearchCache({ + version: 1, + key: cacheKey, + createdAt: currentTimestamp(), + answer: aiResult.data, + evidence: selectedEvidenceItems.map(compactCacheItem), + senderNames: resolvedSenderNames, + messageCount: uniqueMessages.length + }) + setStage('result') + } catch (error) { + const errorMessage = error instanceof Error ? error.message : '读取聊天记录失败' + addDebugEntry('检索失败', { error: errorMessage }) + setAnalysisError(errorMessage) + setStage('insufficient') + } + } + + const copyAnswer = async (): Promise => { + if (!answer) return + const result = await window.api.copyText(answer) + onNotice(result.success ? 'AI 摘要已复制' : result.error || '复制失败') + } + + const renderIdle = (): React.ReactElement => ( +
+
+ +
+ LOCAL AI WORKSPACE +

把聊天记录变成可追问的答案

+

选择范围,用自然语言提问。AI 只读取本地聊天数据,并为每个结论保留证据。

+
+ {[ + '交友群"张三"最近聊了什么?', + '工作群"李四"今天发布了什么任务?', + '我和"老李"最近聊了什么话题?', + '全局搜一下 我和谁聊过 去健身?' + ].map((prompt) => ( + + ))} +
+
+ ) + + const renderLoading = (): React.ReactElement => ( +
+
+ AI 正在分析 +

正在读取本地消息并提取证据

+

+ 范围:{sourceLabel} · {RANGE_LABELS[range]} +

+
+ ● 建立本地数据范围 + ● 提取关键消息 + ○ 生成带证据的摘要 +
+
+ ) + + const renderResult = (): React.ReactElement => ( +
+
+
+ AI 深度检索结果 +

{query}

+

+ {sourceLabel} · {RANGE_LABELS[range]} · 基于 {messageCount} 条消息 · 证据采样{' '} + {evidence.length} 条{cachedAt ? ' · 已使用缓存' : ''} +

+
+
+ + +
+
+
+
+ + 摘要 +
+
{renderMarkdown(answer)}
+
+
+ ) + + const renderInsufficient = (): React.ReactElement => ( +
+
!
+ 检索反馈 +

{analysisError || '当前范围没有足够证据'}

+

可以扩大时间范围、切换群聊,或换一个更具体的问题。

+ +
+ ) + + return ( +
+
+
+ WechatExplorer · LOCAL INTELLIGENCE +

AI 智能检索

+

在本地聊天记录中提炼主题、结论和可追溯证据

+
+
+
+ + {modelLabel} + {!aiModelConfig.configured && ( + + )} +
+ {debugEnabled && ( + + )} +
+
+ {debugEnabled && debugPanelOpen && ( +
+
+
+ 检索诊断 + + {debugEnabled ? '已写入应用日志' : '仅显示本次会话,设置中可开启持久化日志'} + +
+
+ + +
+
+ {appLogPath && {appLogPath}} +
{debugEntries.length ? debugEntries.join('\n') : '等待下一次检索操作...'}
+
+ )} +
+ +
+
+ {stage === 'idle' && renderIdle()} + {stage === 'loading' && renderLoading()} + {stage === 'result' && renderResult()} + {stage === 'insufficient' && renderInsufficient()} +
+
void runAnalysis(event)}> +
+ 正在询问 + {sourceLabel} + {RANGE_LABELS[range]} +
+
+