mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-17 03:27:00 +08:00
feat: 完善 AI 智能检索与定位
新增 AI 查询规划和主题变体多轮检索 修复无结果时回退全量消息导致的错误结论 优化目标成员优先级和大数据量消息匹配性能 新增检索诊断日志、任务中心和持久化缓存 支持证据按时间定位档案并闪烁提示
This commit is contained in:
+7
-2
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
Vendored
+4
@@ -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
|
||||
|
||||
+79
-59
@@ -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<AppPage>('archive')
|
||||
const [archiveJumpTime, setArchiveJumpTime] = useState<number | null>(null)
|
||||
const [settingsCategory, setSettingsCategory] = useState<SettingsCategoryId>('account-database')
|
||||
const [reportSourceContact, setReportSourceContact] = useState<Contact | null>(null)
|
||||
const [reportWorkspaceView, setReportWorkspaceView] = useState<ReportWorkspaceView>('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<import('../../shared/export').ExportResult> => {
|
||||
const handleStartExport = async (
|
||||
request: ExportRequest
|
||||
): Promise<import('../../shared/export').ExportResult> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<AppPage, 'archive' | 'report' | 'agent-hub'>
|
||||
): React.ReactElement => {
|
||||
const labels: Record<Exclude<AppPage, 'archive' | 'report' | 'agent-hub'>, string> = {
|
||||
search: '检索',
|
||||
export: '导出',
|
||||
api: 'API',
|
||||
settings: '设置'
|
||||
}
|
||||
return (
|
||||
<div className="app-page-placeholder">
|
||||
<div className="app-page-placeholder-eyebrow">WechatExplorer</div>
|
||||
<h2>{labels[page]}</h2>
|
||||
<p>这个工作区会在后续 UI 重构阶段接入真实功能。</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const renderArchiveWorkspace = (): React.ReactElement => (
|
||||
<div className="app-container">
|
||||
<Sidebar
|
||||
@@ -1372,6 +1378,7 @@ function App(): React.ReactElement {
|
||||
onLoadOlderMessages={handleLoadOlderMessages}
|
||||
onCreateGroupReport={handleOpenReportWorkspace}
|
||||
isAiLoading={reportGeneration.isGenerating}
|
||||
jumpToTime={archiveJumpTime}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
@@ -1489,7 +1496,20 @@ function App(): React.ReactElement {
|
||||
/>
|
||||
)
|
||||
case 'search':
|
||||
return renderPlaceholderPage(activePage)
|
||||
return (
|
||||
<AISearchWorkspace
|
||||
contacts={contacts}
|
||||
selectedContact={selectedContact}
|
||||
dbReady={isDatabaseConnected}
|
||||
aiModelConfig={aiModelConfig}
|
||||
onSelectContact={(contact) => void handleSelectContact(contact)}
|
||||
onOpenEvidence={(contact, createTime) =>
|
||||
void handleOpenSearchEvidence(contact, createTime)
|
||||
}
|
||||
onOpenAISettings={openModelSettings}
|
||||
onNotice={setReportNotice}
|
||||
/>
|
||||
)
|
||||
case 'export':
|
||||
return (
|
||||
<ExportWorkspace
|
||||
|
||||
@@ -18,6 +18,7 @@ interface ChatWindowProps {
|
||||
onLoadOlderMessages?: () => Promise<void>
|
||||
onCreateGroupReport?: () => void
|
||||
isAiLoading?: boolean
|
||||
jumpToTime?: number | null
|
||||
}
|
||||
|
||||
const ChatWindow: React.FC<ChatWindowProps> = ({
|
||||
@@ -31,7 +32,8 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
||||
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<ChatWindowProps> = ({
|
||||
}, [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<ChatWindowProps> = ({
|
||||
onScroll={handleMessageListScroll}
|
||||
onReachTop={onLoadOlderMessages}
|
||||
onImageClick={openImagePreview}
|
||||
jumpToTime={jumpToTime}
|
||||
/>
|
||||
<ChatStatusBar
|
||||
count={filteredMessages.length}
|
||||
|
||||
@@ -14,6 +14,7 @@ interface MessageBubbleProps {
|
||||
isMine: boolean
|
||||
showAvatarSpace: boolean
|
||||
onImageClick: (imageUrl: string) => 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 (
|
||||
<div className={`message-bubble-wrap ${showAvatarSpace ? '' : 'is-followup'}`}>
|
||||
<div
|
||||
className={`message-bubble-wrap ${showAvatarSpace ? '' : 'is-followup'} ${isJumpTarget ? 'archive-jump-message' : ''}`}
|
||||
>
|
||||
<div
|
||||
className={`message-bubble ${isVoice ? 'voice-bubble' : ''} ${
|
||||
isImage ? 'image-message-bubble' : ''
|
||||
|
||||
@@ -9,6 +9,7 @@ interface MessageGroupProps {
|
||||
isGroupChat: boolean
|
||||
showAvatar: boolean
|
||||
onImageClick: (imageUrl: string) => 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 && <div className="message-avatar-spacer" aria-hidden />}
|
||||
<div className="message-stack">
|
||||
{!isMine && isGroupChat && (
|
||||
<div className="message-sender-name">{displayName}</div>
|
||||
)}
|
||||
{!isMine && isGroupChat && <div className="message-sender-name">{displayName}</div>}
|
||||
{group.messages.map((message, index) => (
|
||||
<MessageBubble
|
||||
key={message.id}
|
||||
@@ -69,6 +69,7 @@ export function MessageGroup({
|
||||
isMine={isMine}
|
||||
showAvatarSpace={index === 0}
|
||||
onImageClick={onImageClick}
|
||||
isJumpTarget={message.id === jumpTargetMessageId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -16,6 +16,7 @@ interface MessageListProps {
|
||||
onScroll: (event: React.UIEvent<HTMLDivElement>) => void
|
||||
onReachTop?: () => Promise<void>
|
||||
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<HTMLDivElement>): 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)` }}
|
||||
>
|
||||
<MessageGroup
|
||||
@@ -126,6 +144,7 @@ export function MessageList({
|
||||
isGroupChat={isGroupChat}
|
||||
showAvatar={showAvatar}
|
||||
onImageClick={onImageClick}
|
||||
jumpTargetMessageId={jumpTarget?.messageId}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,7 @@ import { DatabaseKeyPage } from './pages/DatabaseKeyPage'
|
||||
import { ImageDecryptionPage } from './pages/ImageDecryptionPage'
|
||||
import { AIModelPage } from './pages/AIModelPage'
|
||||
import { RecallProtectionPage } from './pages/RecallProtectionPage'
|
||||
import { AdvancedPage } from './pages/AdvancedPage'
|
||||
import type { Contact } from '../../../../shared/types'
|
||||
import type { AIRuntimeModelConfig } from '../../../../shared/ai-provider'
|
||||
|
||||
@@ -50,8 +51,15 @@ export function SettingsWorkspace({
|
||||
dbReady={dbReady}
|
||||
onOpenSettings={onOpenSettings}
|
||||
/>
|
||||
<div className={`settings-page-panel ${selectedCategory === 'account-database' ? 'active' : ''}`}>
|
||||
<AccountDatabasePage dbKey={dbKey} dbReady={dbReady} selfInfo={selfInfo} onNotice={onNotice} />
|
||||
<div
|
||||
className={`settings-page-panel ${selectedCategory === 'account-database' ? 'active' : ''}`}
|
||||
>
|
||||
<AccountDatabasePage
|
||||
dbKey={dbKey}
|
||||
dbReady={dbReady}
|
||||
selfInfo={selfInfo}
|
||||
onNotice={onNotice}
|
||||
/>
|
||||
</div>
|
||||
<div className={`settings-page-panel ${selectedCategory === 'database-key' ? 'active' : ''}`}>
|
||||
<DatabaseKeyPage
|
||||
@@ -73,10 +81,22 @@ export function SettingsWorkspace({
|
||||
<div className={`settings-page-panel ${selectedCategory === 'ai-model' ? 'active' : ''}`}>
|
||||
<AIModelPage onRuntimeChange={onAIRuntimeChange} onNotice={onNotice} />
|
||||
</div>
|
||||
<div className={`settings-page-panel ${selectedCategory === 'recall-protection' ? 'active' : ''}`}>
|
||||
<div
|
||||
className={`settings-page-panel ${selectedCategory === 'recall-protection' ? 'active' : ''}`}
|
||||
>
|
||||
<RecallProtectionPage onNotice={onNotice} />
|
||||
</div>
|
||||
{!['account-database', 'database-key', 'image-key', 'ai-model', 'recall-protection'].includes(selectedCategory) && (
|
||||
<div className={`settings-page-panel ${selectedCategory === 'advanced' ? 'active' : ''}`}>
|
||||
<AdvancedPage onNotice={onNotice} />
|
||||
</div>
|
||||
{![
|
||||
'account-database',
|
||||
'database-key',
|
||||
'image-key',
|
||||
'ai-model',
|
||||
'recall-protection',
|
||||
'advanced'
|
||||
].includes(selectedCategory) && (
|
||||
<div className="settings-page-panel active">
|
||||
<SettingsEmptyState label={SETTINGS_CATEGORY_LABELS[selectedCategory]} />
|
||||
</div>
|
||||
|
||||
@@ -27,14 +27,12 @@ export function AccountDatabasePage({
|
||||
}): React.ReactElement {
|
||||
const controller = useAccountDatabaseController({ dbKey, dbReady, selfInfo, onNotice })
|
||||
const [autoLogin, setAutoLogin] = useState(false)
|
||||
const [recallProtectionEnabled, setRecallProtectionEnabled] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
void window.api.getSettings().then((result) => {
|
||||
if (!active) return
|
||||
setAutoLogin(result.settings.autoLogin)
|
||||
setRecallProtectionEnabled(result.settings.recallProtectionEnabled)
|
||||
})
|
||||
return () => {
|
||||
active = false
|
||||
@@ -50,11 +48,6 @@ export function AccountDatabasePage({
|
||||
onNotice(checked ? '已开启启动时自动连接' : '已关闭启动时自动连接')
|
||||
}
|
||||
|
||||
const changeRecallProtection = async (checked: boolean): Promise<void> => {
|
||||
const result = await window.api.setSettings({ recallProtectionEnabled: checked })
|
||||
setRecallProtectionEnabled(result.settings.recallProtectionEnabled)
|
||||
onNotice(checked ? '已开启防撤回' : '已关闭防撤回')
|
||||
}
|
||||
return (
|
||||
<div className="settings-page">
|
||||
<header className="settings-page-header">
|
||||
@@ -102,26 +95,6 @@ export function AccountDatabasePage({
|
||||
/>
|
||||
</label>
|
||||
</section>
|
||||
<h2 className="settings-section-heading">数据读取</h2>
|
||||
<section className="settings-card settings-recall-card">
|
||||
<div className="settings-recall-grid">
|
||||
<label className="settings-recall-option">
|
||||
<span>
|
||||
<b>开启防撤回</b>
|
||||
<small>尽量保留已撤回的聊天内容,方便后续查看。</small>
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={recallProtectionEnabled}
|
||||
onChange={(event) => void changeRecallProtection(event.target.checked)}
|
||||
/>
|
||||
</label>
|
||||
<aside className="settings-recall-notice">
|
||||
<strong>性能提示</strong>
|
||||
<span>开启后会为消息表增加监听,数据库较大或磁盘较慢时可能让加载变慢。</span>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
export function AdvancedPage({
|
||||
onNotice
|
||||
}: {
|
||||
onNotice: (message: string) => void
|
||||
}): React.ReactElement {
|
||||
const [debugEnabled, setDebugEnabled] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
void window.api.getSettings().then((result) => {
|
||||
if (active) setDebugEnabled(result.settings.debugEnabled)
|
||||
})
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
const changeDebugEnabled = async (checked: boolean): Promise<void> => {
|
||||
const result = await window.api.setSettings({ debugEnabled: checked })
|
||||
setDebugEnabled(result.settings.debugEnabled)
|
||||
onNotice(checked ? '已开启调试日志' : '已关闭调试日志')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="settings-page">
|
||||
<header className="settings-page-header">
|
||||
<div>
|
||||
<h1>高级</h1>
|
||||
<p>用于开发人员排查本地数据库和检索问题</p>
|
||||
</div>
|
||||
</header>
|
||||
<div className="settings-page-scroll">
|
||||
<div className="settings-page-content">
|
||||
<h2 className="settings-section-heading">诊断</h2>
|
||||
<section className="settings-card settings-debug-card">
|
||||
<label>
|
||||
<span>
|
||||
<b>显示诊断日志</b>
|
||||
<small>开启后,检索页显示诊断日志入口并记录匹配统计,不记录聊天正文。</small>
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={debugEnabled}
|
||||
onChange={(event) => void changeDebugEnabled(event.target.checked)}
|
||||
/>
|
||||
</label>
|
||||
<div className="settings-debug-actions">
|
||||
<button type="button" onClick={() => void window.api.revealAppLog()}>
|
||||
打开诊断日志
|
||||
</button>
|
||||
<small>关闭调试日志后,仍会保留错误和崩溃日志。</small>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,6 +3,9 @@ import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './styles/tokens.css'
|
||||
import './assets/main.css'
|
||||
import './styles/search.css'
|
||||
import './styles/archive.css'
|
||||
import './styles/settings-advanced.css'
|
||||
|
||||
window.addEventListener('error', (event) => {
|
||||
void window.api
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
.archive-jump-target-group {
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.archive-jump-message {
|
||||
z-index: 1;
|
||||
animation: archive-jump-flash 0.72s ease-in-out 2;
|
||||
}
|
||||
|
||||
@keyframes archive-jump-flash {
|
||||
0%,
|
||||
100% {
|
||||
filter: none;
|
||||
}
|
||||
|
||||
22%,
|
||||
58% {
|
||||
filter: drop-shadow(0 0 0.45rem rgba(38, 128, 103, 0.68));
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.archive-jump-message {
|
||||
outline: 3px solid rgba(38, 128, 103, 0.58);
|
||||
outline-offset: 5px;
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,46 @@
|
||||
.settings-debug-card {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.settings-debug-card > label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.settings-debug-card > label > span {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.settings-debug-card b {
|
||||
color: var(--wxex-text-primary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.settings-debug-card small {
|
||||
color: var(--wxex-text-secondary);
|
||||
font-size: 11px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.settings-debug-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--wxex-border);
|
||||
}
|
||||
|
||||
.settings-debug-actions button {
|
||||
padding: 7px 10px;
|
||||
border: 1px solid var(--wxex-border);
|
||||
border-radius: var(--wxex-radius-sm);
|
||||
background: var(--wxex-bg-elevated);
|
||||
color: var(--wxex-brand);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
}
|
||||
Reference in New Issue
Block a user