mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-04 03:01:42 +08:00
2129 lines
78 KiB
TypeScript
2129 lines
78 KiB
TypeScript
import React, { useState } from 'react'
|
|
import { Sidebar } from './components/Sidebar'
|
|
import ChatWindow from './components/ChatWindow'
|
|
import { AppShell } from './components/layout/AppShell'
|
|
import { ApiWorkspace } from './features/api-center/ApiWorkspace'
|
|
import { SettingsWorkspace } from './features/settings/SettingsWorkspace'
|
|
import { AgentHubWorkspace } from './features/agent-hub/AgentHubWorkspace'
|
|
import type { SettingsCategoryId } from './features/settings/model/types'
|
|
import type {
|
|
AIProviderSummary,
|
|
AIRuntimeModelConfig,
|
|
ReportModelChoice
|
|
} from '../../shared/ai-provider'
|
|
import { AppPage } from './components/layout/navigation'
|
|
import { AiReportWorkspace } from './components/reports/AiReportWorkspace'
|
|
import { ReportHistorySidebar } from './components/reports/ReportHistorySidebar'
|
|
import { ReportInfoPanel } from './components/reports/ReportInfoPanel'
|
|
import { ReportSourceSidebar } from './components/reports/ReportSourceSidebar'
|
|
import { ReportTaskStatusPanel } from './components/reports/ReportTaskStatusPanel'
|
|
import { ReportViewer } from './components/reports/ReportViewer'
|
|
import { contactDisplayName } from './components/reports/types'
|
|
import type { GeneratedReportRecord, ReportWorkspaceView } from './components/reports/types'
|
|
import { AiModelConfig, useGroupReportGeneration } from './hooks/useGroupReportGeneration'
|
|
import { SummaryDateRange, SummaryMessageType } from './utils/group-report'
|
|
import { Contact, Message } from '../../shared/types'
|
|
import { DatabaseConnectionMode, DatabaseConnectionPage } from './components/DatabaseConnectionPage'
|
|
import { FirstUseWelcome } from './components/FirstUseWelcome'
|
|
import { ExportWorkspace } from './components/export/ExportWorkspace'
|
|
import { AISearchWorkspace } from './components/search/AISearchWorkspace'
|
|
import type { ExportJobProgress, ExportRequest, ExportTaskRecord } from '../../shared/export'
|
|
import type { DatabaseKeyEnvironment, WechatAccountCandidate } from '../../shared/database-key'
|
|
import {
|
|
getMessageIdentity,
|
|
mergeMessagePages,
|
|
sortMessagesChronologically
|
|
} from './utils/message-pages'
|
|
import { enrichQuotedMessages } from './utils/quoted-messages'
|
|
import type { SelectableReportTemplateId } from '../../shared/report-templates'
|
|
import { switchGeneratedReportTemplate } from './utils/report-template-switch'
|
|
import { runtimePlatform } from './utils/runtime-environment'
|
|
|
|
const SIDEBAR_MIN_WIDTH = 260
|
|
const SIDEBAR_MAX_WIDTH = 380
|
|
const DATABASE_CONNECT_TIMEOUT_MS = 30_000
|
|
|
|
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
|
|
return new Promise<T>((resolve, reject) => {
|
|
const timer = window.setTimeout(() => reject(new Error(message)), timeoutMs)
|
|
promise.then(
|
|
(value) => {
|
|
window.clearTimeout(timer)
|
|
resolve(value)
|
|
},
|
|
(error) => {
|
|
window.clearTimeout(timer)
|
|
reject(error)
|
|
}
|
|
)
|
|
})
|
|
}
|
|
|
|
function getDevelopmentDatabaseKey(): string {
|
|
if (!import.meta.env.DEV) return ''
|
|
return String(import.meta.env.VITE_DB_KEY || '').trim()
|
|
}
|
|
|
|
interface SelfInfo {
|
|
wxid: string
|
|
nickname: string
|
|
avatar?: string
|
|
accountRoot: string
|
|
}
|
|
|
|
const MAC_KEY_FAQ_URL = 'https://github.com/Wxw-Gu/WechatExplorer/blob/main/docs/mac-disable-sip.md'
|
|
const FIRST_USE_WELCOME_SEEN_KEY = 'wxe_first_use_welcome_seen'
|
|
const MESSAGE_MONITOR_DEBOUNCE_MS = 8000
|
|
const INITIAL_MESSAGE_COUNT = 20
|
|
const MESSAGE_PAGE_SIZE = 100
|
|
const MESSAGE_PREFETCH_COUNT = INITIAL_MESSAGE_COUNT + MESSAGE_PAGE_SIZE
|
|
const EXPORT_PREVIEW_LIMIT = 20
|
|
const REPORT_TEXT_MODEL_STORAGE_KEY = 'group_report_text_model'
|
|
const REPORT_VISION_MODEL_STORAGE_KEY = 'group_report_vision_model'
|
|
|
|
const reportModelKey = (model: { providerId?: string; model: string }): string =>
|
|
model.providerId && model.model ? `${model.providerId}::${model.model}` : ''
|
|
|
|
const reportProviderConfigured = (provider: AIProviderSummary): boolean =>
|
|
Boolean(provider.hasApiKey || provider.type === 'ollama' || provider.auth.type === 'none')
|
|
|
|
const reportModelChoices = (
|
|
providers: AIProviderSummary[],
|
|
capability: 'chat' | 'vision'
|
|
): ReportModelChoice[] =>
|
|
providers.flatMap((provider) => {
|
|
if (!reportProviderConfigured(provider)) return []
|
|
return provider.models
|
|
.filter((model) =>
|
|
capability === 'chat'
|
|
? model.capabilities.chat
|
|
: model.capabilities.vision || model.capabilities.ocr
|
|
)
|
|
.map((model) => ({
|
|
providerId: provider.id,
|
|
providerName: provider.name,
|
|
model: model.id,
|
|
modelName: model.name || model.id,
|
|
configured: true as const,
|
|
status: provider.status,
|
|
timeoutMs: provider.advanced.timeoutMs
|
|
}))
|
|
})
|
|
|
|
const selectReportModel = (
|
|
choices: ReportModelChoice[],
|
|
storageKey: string,
|
|
fallback: { providerId?: string; model: string }
|
|
): ReportModelChoice | undefined => {
|
|
const savedKey = localStorage.getItem(storageKey) || ''
|
|
const fallbackKey = reportModelKey(fallback)
|
|
return (
|
|
choices.find((choice) => reportModelKey(choice) === savedKey) ||
|
|
choices.find((choice) => reportModelKey(choice) === fallbackKey) ||
|
|
choices[0]
|
|
)
|
|
}
|
|
const areMessagesEquivalent = (left: Message[], right: Message[]): boolean => {
|
|
if (left === right) return true
|
|
if (left.length !== right.length) return false
|
|
for (let index = 0; index < left.length; index += 1) {
|
|
if (getMessageIdentity(left[index]) !== getMessageIdentity(right[index])) return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
const filterContactList = (list: Contact[], keyword: string): Contact[] => {
|
|
const lower = keyword.trim().toLowerCase()
|
|
if (!lower) return list
|
|
return list.filter((contact) =>
|
|
[contact.m_nsNickName, contact.m_nsUsrName, contact.remark, contact.wechatNickname].some(
|
|
(value) => value?.toLowerCase().includes(lower)
|
|
)
|
|
)
|
|
}
|
|
|
|
type GroupSnapshot = {
|
|
roomId: string
|
|
memberCount: number
|
|
members: {
|
|
wxid: string
|
|
nickname: string
|
|
groupNickname: string
|
|
wechatNickname: string
|
|
remark: string
|
|
avatar: string
|
|
}[]
|
|
}
|
|
|
|
type GroupMemberMeta = { nickname: string; avatar: string }
|
|
type StartupProgress = {
|
|
title: string
|
|
subtitle: string
|
|
detail?: string
|
|
percent?: number
|
|
}
|
|
|
|
const formatGroupMemberName = (member: GroupSnapshot['members'][number]): string =>
|
|
member.groupNickname || member.nickname || member.remark || member.wechatNickname || member.wxid
|
|
|
|
const buildSyntheticGroupMessages = (
|
|
previous: GroupSnapshot | null,
|
|
next: GroupSnapshot | null,
|
|
referenceMessages: Message[]
|
|
): Message[] => {
|
|
if (!previous || !next || previous.roomId !== next.roomId) return []
|
|
|
|
const previousMap = new Map(previous.members.map((member) => [member.wxid, member]))
|
|
const nextMap = new Map(next.members.map((member) => [member.wxid, member]))
|
|
const latestMessageTime = referenceMessages.reduce(
|
|
(max, message) => Math.max(max, message.createTime || 0),
|
|
0
|
|
)
|
|
const fallbackNow = Math.floor(Date.now() / 1000)
|
|
const events: Message[] = []
|
|
|
|
let offset = 1
|
|
for (const [wxid, member] of previousMap.entries()) {
|
|
if (nextMap.has(wxid)) continue
|
|
const name = formatGroupMemberName(member)
|
|
const eventTime = Math.max(latestMessageTime + offset, fallbackNow)
|
|
const eventDate = new Date(eventTime * 1000)
|
|
offset += 1
|
|
events.push({
|
|
id: `synthetic-leave:${next.roomId}:${wxid}:${eventTime}`,
|
|
from: 'system',
|
|
type: '系统消息',
|
|
datetime: eventDate.toLocaleString('zh-CN', { hour12: false }),
|
|
content: `${name} 退出了群聊`,
|
|
isSender: false,
|
|
createTime: eventTime
|
|
})
|
|
}
|
|
|
|
if (events.length) {
|
|
console.log(
|
|
`[GroupMonitor] synthetic leave detected roomId=${next.roomId} events=${events
|
|
.map((event) => event.content)
|
|
.join(' | ')}`
|
|
)
|
|
}
|
|
|
|
return events
|
|
}
|
|
|
|
void buildSyntheticGroupMessages
|
|
|
|
function App(): React.ReactElement {
|
|
const [isAuthenticated, setIsAuthenticated] = useState(false)
|
|
const [isDatabaseConnected, setIsDatabaseConnected] = useState(false)
|
|
const [isDatabaseConnecting, setIsDatabaseConnecting] = useState(false)
|
|
const [dbKey, setDbKey] = useState(getDevelopmentDatabaseKey)
|
|
const [contacts, setContacts] = useState<Contact[]>([])
|
|
const [selectedContact, setSelectedContact] = useState<Contact | null>(null)
|
|
const [messages, setMessages] = useState<Message[]>([])
|
|
const [isMessagesLoading, setIsMessagesLoading] = useState(false)
|
|
const [messageHistoryStatus, setMessageHistoryStatus] = useState<'idle' | 'end' | 'error'>('idle')
|
|
const [filteredContacts, setFilteredContacts] = useState<Contact[]>([])
|
|
const [contentFilter, setContentFilter] = useState('')
|
|
const [isFetchingDbKey, setIsFetchingDbKey] = useState(false)
|
|
const [dbKeyStatus, setDbKeyStatus] = useState('')
|
|
const [dbKeyStatusKind, setDbKeyStatusKind] = useState<'normal' | 'success' | 'error'>('normal')
|
|
const [showDbKey, setShowDbKey] = useState(false)
|
|
const [dbRootInput, setDbRootInput] = useState('')
|
|
const [discoveredAccounts, setDiscoveredAccounts] = useState<WechatAccountCandidate[]>([])
|
|
const [selectedAccountId, setSelectedAccountId] = useState('')
|
|
const selectedAccount = discoveredAccounts.find((account) => account.id === selectedAccountId)
|
|
const [showMacKeyFaq, setShowMacKeyFaq] = useState(false)
|
|
const [databaseConnectionMode, setDatabaseConnectionMode] = useState<DatabaseConnectionMode>(
|
|
getDevelopmentDatabaseKey() ? 'manual' : 'automatic'
|
|
)
|
|
const [connectionGuideStep, setConnectionGuideStep] = useState<1 | 2 | 3 | 4 | 5 | 6>(1)
|
|
const [databaseEnvironment, setDatabaseEnvironment] = useState<DatabaseKeyEnvironment>()
|
|
const connectionOperationRef = React.useRef(0)
|
|
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')
|
|
const [generatedReports, setGeneratedReports] = useState<GeneratedReportRecord[]>([])
|
|
const [selectedReportId, setSelectedReportId] = useState<string | null>(null)
|
|
const [latestGeneratedReportId, setLatestGeneratedReportId] = useState<string | null>(null)
|
|
const [isSavingGeneratedReport, setIsSavingGeneratedReport] = useState(false)
|
|
const [reportNotice, setReportNotice] = useState('')
|
|
const [summaryDateRange, setSummaryDateRange] = useState<SummaryDateRange>('today')
|
|
const [summaryMessageTypes, setSummaryMessageTypes] = useState<SummaryMessageType[]>(['text'])
|
|
const [aiModelConfig, setAiModelConfig] = useState<AiModelConfig>({
|
|
providerName: '尚未配置',
|
|
model: '',
|
|
modelName: '尚未选择模型',
|
|
configured: false,
|
|
status: 'untested'
|
|
})
|
|
const [reportTextModelConfig, setReportTextModelConfig] = useState<AiModelConfig>({
|
|
providerName: '尚未配置',
|
|
model: '',
|
|
modelName: '尚未选择模型',
|
|
configured: false,
|
|
status: 'untested'
|
|
})
|
|
const [aiVisionModelConfig, setAiVisionModelConfig] = useState<ReportModelChoice>()
|
|
const [reportTextModelOptions, setReportTextModelOptions] = useState<ReportModelChoice[]>([])
|
|
const [reportVisionModelOptions, setReportVisionModelOptions] = useState<ReportModelChoice[]>([])
|
|
const [selfInfo, setSelfInfo] = useState<SelfInfo | null>(null)
|
|
const [isNativeMonitorActive, setIsNativeMonitorActive] = useState(false)
|
|
const [exportTasks, setExportTasks] = useState<ExportTaskRecord[]>(() => {
|
|
try {
|
|
const stored = JSON.parse(localStorage.getItem('wxe_export_tasks') || '[]')
|
|
if (!Array.isArray(stored)) return []
|
|
return stored.slice(0, 20).map(
|
|
(
|
|
value: Partial<ExportTaskRecord> & {
|
|
contactId?: string
|
|
contactName?: string
|
|
}
|
|
) => {
|
|
const targetIds = Array.isArray(value.targetIds)
|
|
? value.targetIds
|
|
: value.contactId
|
|
? [value.contactId]
|
|
: []
|
|
const targetNames = Array.isArray(value.targetNames)
|
|
? value.targetNames
|
|
: value.contactName
|
|
? [value.contactName]
|
|
: []
|
|
return {
|
|
...value,
|
|
targetIds,
|
|
targetNames,
|
|
targetLabel: value.targetLabel || targetNames.join('、') || '聊天导出'
|
|
} as ExportTaskRecord
|
|
}
|
|
)
|
|
} catch {
|
|
return []
|
|
}
|
|
})
|
|
const [bootState, setBootState] = useState<'loading' | 'connecting' | 'login'>('loading')
|
|
const [autoConnectSource, setAutoConnectSource] = useState<'env' | 'saved' | null>(null)
|
|
const [startupProgress, setStartupProgress] = useState<StartupProgress | null>(null)
|
|
const [showFirstUseWelcome, setShowFirstUseWelcome] = useState(false)
|
|
const [appearanceSettings, setAppearanceSettings] = React.useState<{
|
|
theme: 'system' | 'light' | 'dark'
|
|
compactMode: boolean
|
|
showStartupProgress: boolean
|
|
}>({ theme: 'system', compactMode: false, showStartupProgress: true })
|
|
const handleAppearanceChange = React.useCallback(
|
|
(settings: { theme: 'system' | 'light' | 'dark'; compactMode: boolean }) => {
|
|
setAppearanceSettings((current) => ({ ...current, ...settings }))
|
|
},
|
|
[]
|
|
)
|
|
const currentGroupSnapshotRef = React.useRef<GroupSnapshot | null>(null)
|
|
const syntheticGroupMessagesRef = React.useRef<Record<string, Message[]>>({})
|
|
const groupMemberMetaRef = React.useRef<Record<string, Map<string, GroupMemberMeta>>>({})
|
|
const messageHistoryRef = React.useRef<Message[]>([])
|
|
const messagesRef = React.useRef<Message[]>([])
|
|
const messagePrefetchRef = React.useRef<Promise<void> | null>(null)
|
|
messagesRef.current = messages
|
|
React.useEffect(() => {
|
|
if (!reportNotice) return
|
|
const timer = window.setTimeout(() => setReportNotice(''), 3200)
|
|
return () => window.clearTimeout(timer)
|
|
}, [reportNotice])
|
|
React.useEffect(() => {
|
|
const openVoiceRecognitionSettings = (): void => {
|
|
setSettingsCategory('voice-recognition')
|
|
setActivePage('settings')
|
|
}
|
|
window.addEventListener('wxe:open-voice-recognition-settings', openVoiceRecognitionSettings)
|
|
return () =>
|
|
window.removeEventListener(
|
|
'wxe:open-voice-recognition-settings',
|
|
openVoiceRecognitionSettings
|
|
)
|
|
}, [])
|
|
React.useEffect(() => {
|
|
void window.api.getSettings().then((result) => {
|
|
setAppearanceSettings({
|
|
theme: result.settings.appearanceTheme,
|
|
compactMode: result.settings.compactMode,
|
|
showStartupProgress: result.settings.showStartupProgress
|
|
})
|
|
})
|
|
}, [])
|
|
|
|
const refreshConnectionEnvironment = React.useCallback(async (): Promise<void> => {
|
|
const root = dbRootInput.trim()
|
|
try {
|
|
if (root) {
|
|
const discovery = await window.api.discoverAccounts(root)
|
|
if (!discovery.success) throw new Error(discovery.error || '账号目录识别失败')
|
|
setDiscoveredAccounts(discovery.accounts)
|
|
setSelectedAccountId(discovery.preselectedAccountId || '')
|
|
}
|
|
const environment = await window.api.getDatabaseKeyEnvironment()
|
|
setDatabaseEnvironment(environment)
|
|
setDbKeyStatus('环境检查已更新')
|
|
setDbKeyStatusKind('normal')
|
|
} catch (error) {
|
|
setDbKeyStatus(
|
|
error instanceof Error ? `环境检查失败:${error.message}` : '环境检查失败,请重试'
|
|
)
|
|
setDbKeyStatusKind('error')
|
|
}
|
|
}, [dbRootInput])
|
|
|
|
React.useEffect(() => {
|
|
void window.api
|
|
.getDatabaseKeyEnvironment()
|
|
.then(setDatabaseEnvironment)
|
|
.catch(() => undefined)
|
|
}, [])
|
|
React.useEffect(() => {
|
|
const loadAIConfig = async (): Promise<void> => {
|
|
try {
|
|
const legacy = {
|
|
apiKey: localStorage.getItem('ai_api_key') || undefined,
|
|
baseUrl: localStorage.getItem('ai_base_url') || undefined,
|
|
model: localStorage.getItem('ai_model') || undefined
|
|
}
|
|
if (legacy.apiKey || legacy.baseUrl || legacy.model) {
|
|
const migrated = await window.api.migrateLegacyAIConfig(legacy)
|
|
if (migrated.success) {
|
|
localStorage.removeItem('ai_api_key')
|
|
localStorage.removeItem('ai_base_url')
|
|
localStorage.removeItem('ai_model')
|
|
}
|
|
}
|
|
const [runtime, visionRuntime, providerList] = await Promise.all([
|
|
window.api.getAIRuntimeConfig(),
|
|
window.api.getAIVisionRuntimeConfig(),
|
|
window.api.listAIProviders()
|
|
])
|
|
const providers = providerList.success ? providerList.providers : []
|
|
const textChoices = reportModelChoices(providers, 'chat')
|
|
const visionChoices = reportModelChoices(providers, 'vision')
|
|
const selectedText = selectReportModel(textChoices, REPORT_TEXT_MODEL_STORAGE_KEY, runtime)
|
|
const selectedVision = selectReportModel(
|
|
visionChoices,
|
|
REPORT_VISION_MODEL_STORAGE_KEY,
|
|
visionRuntime
|
|
)
|
|
setReportTextModelOptions(textChoices)
|
|
setReportVisionModelOptions(visionChoices)
|
|
setAiModelConfig(runtime)
|
|
setReportTextModelConfig(selectedText || runtime)
|
|
setAiVisionModelConfig(selectedVision)
|
|
} catch (error) {
|
|
console.warn('[AI Provider] 配置加载失败:', error)
|
|
}
|
|
}
|
|
void loadAIConfig()
|
|
}, [])
|
|
const selectedContactMd5Ref = React.useRef<string>('')
|
|
const contactAvatarHydrationRunRef = React.useRef(0)
|
|
const reportGeneration = useGroupReportGeneration({
|
|
sourceContact: reportSourceContact,
|
|
summaryDateRange,
|
|
summaryMessageTypes,
|
|
modelConfig: reportTextModelConfig,
|
|
visionModelConfig: aiVisionModelConfig
|
|
})
|
|
const lastCapturedReportKeyRef = React.useRef('')
|
|
|
|
React.useEffect(() => {
|
|
const unsubscribe = window.api.onExportProgress((progress: ExportJobProgress) => {
|
|
setExportTasks((current) =>
|
|
current.map((task) => {
|
|
if (task.jobId !== progress.jobId) return task
|
|
const status =
|
|
progress.phase === 'completed'
|
|
? 'completed'
|
|
: progress.phase === 'cancelled'
|
|
? 'cancelled'
|
|
: progress.phase === 'failed'
|
|
? 'failed'
|
|
: 'running'
|
|
return { ...task, status, progress }
|
|
})
|
|
)
|
|
})
|
|
return unsubscribe
|
|
}, [])
|
|
|
|
React.useEffect(() => {
|
|
localStorage.setItem('wxe_export_tasks', JSON.stringify(exportTasks.slice(0, 20)))
|
|
}, [exportTasks])
|
|
|
|
const handleStartExport = async (
|
|
request: ExportRequest
|
|
): Promise<import('../../shared/export').ExportResult> => {
|
|
const exportAll = request.scope === 'all'
|
|
const targetCount = request.targets.length
|
|
const targetNames = exportAll ? [] : request.targets.map((target) => target.name)
|
|
const targetLabel = exportAll
|
|
? `全部 ${targetCount} 个聊天`
|
|
: targetNames.length > 1
|
|
? `${targetNames[0]} 等 ${targetNames.length} 个聊天`
|
|
: targetNames[0]
|
|
const task: ExportTaskRecord = {
|
|
jobId: request.jobId,
|
|
scope: request.scope,
|
|
allContactTypes: request.allContactTypes,
|
|
targetIds: exportAll ? [] : request.targets.map((target) => target.userMd5),
|
|
targetNames,
|
|
targetLabel,
|
|
format: request.format,
|
|
includeVoiceTranscripts: request.includeVoiceTranscripts,
|
|
zip: request.zip,
|
|
status: 'running',
|
|
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)
|
|
)
|
|
const result = await window.api.startExport(request)
|
|
setExportTasks((current) =>
|
|
current.map((item) =>
|
|
item.jobId === request.jobId
|
|
? result.success
|
|
? {
|
|
...item,
|
|
status: 'completed',
|
|
progress: {
|
|
...item.progress,
|
|
phase: 'completed',
|
|
processed: result.messageCount ?? item.progress.processed,
|
|
total: result.messageCount ?? item.progress.total,
|
|
percent: 100,
|
|
outputPath: result.outputPath
|
|
}
|
|
}
|
|
: {
|
|
...item,
|
|
status: 'failed',
|
|
progress: { ...item.progress, phase: 'failed', error: result.error }
|
|
}
|
|
: item
|
|
)
|
|
)
|
|
return result
|
|
}
|
|
|
|
const handleCancelExport = async (jobId: string): Promise<void> => {
|
|
await window.api.cancelExport(jobId)
|
|
setExportTasks((current) =>
|
|
current.map((task) =>
|
|
task.jobId === jobId
|
|
? { ...task, status: 'cancelled', progress: { ...task.progress, phase: 'cancelled' } }
|
|
: task
|
|
)
|
|
)
|
|
}
|
|
|
|
const loadGeneratedReports = React.useCallback(async (): Promise<void> => {
|
|
try {
|
|
const result = await window.api.listGeneratedReports()
|
|
if (!result.success) {
|
|
setReportNotice(result.error || '日报历史加载失败')
|
|
return
|
|
}
|
|
const reports = result.reports || []
|
|
setGeneratedReports(reports)
|
|
setSelectedReportId((current) =>
|
|
current && reports.some((report) => report.id === current) ? current : null
|
|
)
|
|
} catch (error) {
|
|
setReportNotice(error instanceof Error ? error.message : String(error))
|
|
}
|
|
}, [])
|
|
|
|
const waitForPaint = (): Promise<void> => new Promise((resolve) => window.setTimeout(resolve, 80))
|
|
|
|
const refreshSelfInfo = async (attempts = 1): Promise<SelfInfo | null> => {
|
|
let lastError: unknown
|
|
for (let attempt = 0; attempt < Math.max(1, attempts); attempt += 1) {
|
|
try {
|
|
const result = await window.api.getSelf()
|
|
if (result.ready && result.info) {
|
|
setSelfInfo(result.info)
|
|
return result.info
|
|
}
|
|
} catch (error) {
|
|
lastError = error
|
|
}
|
|
if (attempt + 1 < attempts) {
|
|
await new Promise((resolve) => window.setTimeout(resolve, 180))
|
|
}
|
|
}
|
|
if (lastError) console.warn('[SelfInfo] 加载失败:', lastError)
|
|
setSelfInfo(null)
|
|
return null
|
|
}
|
|
|
|
const loadBootstrapCache = async (): Promise<boolean> => {
|
|
try {
|
|
const cache = await window.api.getBootstrapCache()
|
|
if (!cache) return false
|
|
if (cache.contacts.length) {
|
|
setContacts(cache.contacts)
|
|
setFilteredContacts(cache.contacts)
|
|
}
|
|
if (cache.self) setSelfInfo(cache.self)
|
|
return Boolean(cache.contacts.length || cache.self)
|
|
} catch (error) {
|
|
console.warn('[BootstrapCache] 加载失败:', error)
|
|
return false
|
|
}
|
|
}
|
|
|
|
const loadContacts = async (options?: {
|
|
waitForAvatars?: boolean
|
|
onProgress?: (message: string, percent?: number) => void
|
|
filterKeyword?: string
|
|
}): Promise<void> => {
|
|
options?.onProgress?.('正在加载联系人...', 35)
|
|
const list = await window.api.getContacts()
|
|
setContacts(list)
|
|
setFilteredContacts(filterContactList(list, options?.filterKeyword || ''))
|
|
const runId = ++contactAvatarHydrationRunRef.current
|
|
const hydrate = (): Promise<void> => hydrateContactAvatars(list, runId, options?.onProgress)
|
|
if (options?.waitForAvatars) {
|
|
await hydrate()
|
|
} else {
|
|
window.setTimeout(() => {
|
|
void hydrate()
|
|
}, 1500)
|
|
}
|
|
}
|
|
|
|
const hydrateContactAvatars = async (
|
|
list: Contact[],
|
|
runId: number,
|
|
onProgress?: (message: string, percent?: number) => void
|
|
): Promise<void> => {
|
|
const usernames = Array.from(
|
|
new Set(
|
|
list
|
|
.map((contact) => contact.m_nsUsrName)
|
|
.filter((username, index) => {
|
|
const contact = list[index]
|
|
return (
|
|
username &&
|
|
!contact.avatar &&
|
|
!username.startsWith('Group_') &&
|
|
!username.startsWith('Unknown_')
|
|
)
|
|
})
|
|
)
|
|
)
|
|
onProgress?.(
|
|
usernames.length ? `正在加载头像 0/${usernames.length}...` : '头像缓存已就绪',
|
|
usernames.length ? 55 : 90
|
|
)
|
|
let loadedCount = 0
|
|
const chunkSize = 128
|
|
for (let index = 0; index < usernames.length; index += chunkSize) {
|
|
if (runId !== contactAvatarHydrationRunRef.current) return
|
|
const chunk = usernames.slice(index, index + chunkSize)
|
|
if (chunk.length === 0) continue
|
|
try {
|
|
const avatars = await window.api.getContactAvatars(chunk)
|
|
if (runId !== contactAvatarHydrationRunRef.current) return
|
|
loadedCount += chunk.length
|
|
onProgress?.(
|
|
`正在加载头像 ${Math.min(loadedCount, usernames.length)}/${usernames.length}...`,
|
|
55 + Math.round((Math.min(loadedCount, usernames.length) / usernames.length) * 35)
|
|
)
|
|
if (Object.keys(avatars).length === 0) continue
|
|
setContacts((current) =>
|
|
current.map((contact) =>
|
|
avatars[contact.m_nsUsrName]
|
|
? { ...contact, avatar: avatars[contact.m_nsUsrName] }
|
|
: contact
|
|
)
|
|
)
|
|
setFilteredContacts((current) =>
|
|
current.map((contact) =>
|
|
avatars[contact.m_nsUsrName]
|
|
? { ...contact, avatar: avatars[contact.m_nsUsrName] }
|
|
: contact
|
|
)
|
|
)
|
|
} catch (error) {
|
|
console.warn('[Contacts] avatar hydrate failed:', error)
|
|
}
|
|
await new Promise((resolve) => window.setTimeout(resolve, 20))
|
|
}
|
|
if (usernames.length) onProgress?.('头像加载完成', 90)
|
|
}
|
|
|
|
React.useEffect(() => {
|
|
let active = true
|
|
const attemptAutoConnect = async (): Promise<void> => {
|
|
const settingsResult = await window.api.getSettings()
|
|
// 预填已保存的微信聊天文件路径
|
|
if (active && settingsResult.settings.dbRoot) {
|
|
setDbRootInput(settingsResult.settings.dbRoot)
|
|
}
|
|
const discovery = settingsResult.settings.dbRoot
|
|
? await window.api.discoverAccounts(settingsResult.settings.dbRoot)
|
|
: { success: false, accounts: [] }
|
|
if (active && discovery.success) {
|
|
setDiscoveredAccounts(discovery.accounts)
|
|
setSelectedAccountId(discovery.preselectedAccountId || '')
|
|
}
|
|
const startupAccountRoot = discovery.success
|
|
? discovery.accounts.find((account) => account.id === discovery.preselectedAccountId)
|
|
?.accountRoot
|
|
: undefined
|
|
const autoLoginEnabled = settingsResult.settings.autoLogin
|
|
// 开发环境允许使用 VITE_DB_KEY;生产安装包只能读取目标电脑自己的 safeStorage。
|
|
const envKey = getDevelopmentDatabaseKey()
|
|
// 生产环境以及未配置开发密钥时,读取上一次保存到 safeStorage 的密钥。
|
|
let savedKey = ''
|
|
if (!envKey) {
|
|
const result = startupAccountRoot
|
|
? await window.api.getSavedDbKey(startupAccountRoot)
|
|
: { success: true, saved: false, encryptionAvailable: true }
|
|
if (result.success && result.key) savedKey = result.key
|
|
}
|
|
const key = envKey || savedKey
|
|
if (!key) {
|
|
if (active) {
|
|
setDatabaseConnectionMode('automatic')
|
|
setBootState('login')
|
|
}
|
|
return
|
|
}
|
|
if (active) {
|
|
setDbKey(key)
|
|
setDatabaseConnectionMode('manual')
|
|
setAutoConnectSource(envKey ? 'env' : 'saved')
|
|
setDbKeyStatus(
|
|
autoLoginEnabled
|
|
? envKey
|
|
? '检测到环境变量中的密钥,正在自动连接...'
|
|
: '已加载安全保存的密钥,正在自动连接...'
|
|
: envKey
|
|
? '已加载环境变量中的密钥,请手动点击 Connect'
|
|
: '已加载安全保存的密钥,请手动点击 Connect'
|
|
)
|
|
setDbKeyStatusKind('normal')
|
|
setBootState(autoLoginEnabled ? 'connecting' : 'login')
|
|
}
|
|
if (!autoLoginEnabled) return
|
|
try {
|
|
const startupCacheReady = await loadStartupCache()
|
|
setIsDatabaseConnecting(true)
|
|
if (!startupAccountRoot) {
|
|
setBootState('login')
|
|
return
|
|
}
|
|
const initPromise = window.api.initDb(key, startupAccountRoot)
|
|
if (startupCacheReady) {
|
|
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}` : ''}`)
|
|
setDbKeyStatusKind('error')
|
|
return
|
|
}
|
|
setIsNativeMonitorActive(typeof result !== 'boolean' && result.monitoring === true)
|
|
setIsDatabaseConnected(true)
|
|
setDbKeyStatus('已连接数据库')
|
|
// The cached list paints first. Refresh lightweight session flags and
|
|
// missing avatars after the database is connected.
|
|
void loadContacts({ waitForAvatars: false })
|
|
.then(() => refreshSelfInfo(3))
|
|
.catch((error) => {
|
|
console.warn('[Startup] background contact refresh failed:', error)
|
|
})
|
|
})
|
|
.catch((error) => {
|
|
console.warn('[Startup] background database init failed:', error)
|
|
setDbKeyStatusKind('error')
|
|
})
|
|
.finally(() => {
|
|
if (active) setIsDatabaseConnecting(false)
|
|
})
|
|
return
|
|
}
|
|
const result = await initPromise
|
|
if (!active) return
|
|
const success = typeof result === 'boolean' ? result : result.success
|
|
if (success) {
|
|
if (!settingsResult.settings.autoLoginPreferenceSet) {
|
|
void window.api.setSettings({ autoLogin: true })
|
|
}
|
|
setIsNativeMonitorActive(typeof result !== 'boolean' && result.monitoring === true)
|
|
setIsDatabaseConnected(true)
|
|
setDbKeyStatus('已自动连接')
|
|
setDbKeyStatusKind('success')
|
|
const hasBootstrap = await loadBootstrapCache()
|
|
if (hasBootstrap) {
|
|
setIsAuthenticated(true)
|
|
} else {
|
|
await loadContacts()
|
|
await refreshSelfInfo(3)
|
|
setIsAuthenticated(true)
|
|
}
|
|
} else {
|
|
const error = typeof result === 'boolean' ? '' : result.error
|
|
setDbKeyStatus(`自动连接失败,请重新输入${error ? `: ${error}` : ''}`)
|
|
setDbKeyStatusKind('error')
|
|
setBootState('login')
|
|
}
|
|
} catch (error: unknown) {
|
|
if (!active) return
|
|
const message = error instanceof Error ? error.message : String(error)
|
|
setDbKeyStatus(`自动连接失败: ${message}`)
|
|
setDbKeyStatusKind('error')
|
|
setBootState('login')
|
|
} finally {
|
|
if (active) setIsDatabaseConnecting(false)
|
|
}
|
|
}
|
|
void attemptAutoConnect()
|
|
const unsubscribe = window.api.onDbKeyStatus(({ message }) => {
|
|
if (!active) return
|
|
setDbKeyStatus(message)
|
|
setDbKeyStatusKind('normal')
|
|
})
|
|
return () => {
|
|
active = false
|
|
unsubscribe()
|
|
}
|
|
// 自动连接只在应用启动时执行一次,避免联系人加载刷新触发重复连接。
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [])
|
|
|
|
React.useEffect(() => {
|
|
if (!isAuthenticated) return
|
|
void loadGeneratedReports()
|
|
}, [isAuthenticated, loadGeneratedReports])
|
|
|
|
const handleLogin = async (keyInput?: string, accountRootInput?: string): Promise<void> => {
|
|
const keyToUse = keyInput || dbKey
|
|
let accountRoot = accountRootInput || selectedAccount?.accountRoot
|
|
if (!keyToUse || isDatabaseConnecting) return
|
|
if (!accountRoot) {
|
|
const discovery = await window.api.discoverAccounts(dbRootInput.trim())
|
|
if (!discovery.success) {
|
|
setDbKeyStatus(discovery.error || '微信数据目录不可用')
|
|
setDbKeyStatusKind('error')
|
|
return
|
|
}
|
|
if (!discovery.preselectedAccountId) {
|
|
setDiscoveredAccounts(discovery.accounts)
|
|
setDbKeyStatus('请选择要连接的微信账号')
|
|
setDbKeyStatusKind('error')
|
|
return
|
|
}
|
|
const account = discovery.accounts.find(
|
|
(candidate) => candidate.id === discovery.preselectedAccountId
|
|
)
|
|
if (!account) return
|
|
setDiscoveredAccounts(discovery.accounts)
|
|
setSelectedAccountId(account.id)
|
|
accountRoot = account.accountRoot
|
|
}
|
|
const operationId = ++connectionOperationRef.current
|
|
if (databaseConnectionMode === 'automatic') setConnectionGuideStep(6)
|
|
setIsDatabaseConnecting(true)
|
|
// 持久化用户手动指定的微信聊天文件路径,供 db:init 读取 settings.dbRoot
|
|
const trimmedRoot = accountRoot
|
|
if (trimmedRoot) {
|
|
try {
|
|
await window.api.setSettings({ dbRoot: trimmedRoot })
|
|
} catch {
|
|
// 忽略保存失败,仍尝试用默认路径连接
|
|
}
|
|
}
|
|
setStartupProgress({
|
|
title: '正在连接数据库...',
|
|
subtitle: '正在初始化微信数据',
|
|
detail: '请稍候',
|
|
percent: 8
|
|
})
|
|
try {
|
|
setStartupProgress({
|
|
title: '正在连接数据库...',
|
|
subtitle: '正在初始化微信数据',
|
|
detail: '正在打开 WCDB 数据库',
|
|
percent: 15
|
|
})
|
|
const result = await withTimeout(
|
|
window.api.initDb(keyToUse, trimmedRoot),
|
|
DATABASE_CONNECT_TIMEOUT_MS,
|
|
'数据库连接超时,请检查数据目录后重试'
|
|
)
|
|
if (operationId !== connectionOperationRef.current) return
|
|
const success = typeof result === 'boolean' ? result : result.success
|
|
if (success) {
|
|
setIsNativeMonitorActive(typeof result !== 'boolean' && result.monitoring === true)
|
|
setStartupProgress({
|
|
title: '正在读取缓存...',
|
|
subtitle: '正在恢复上次联系人和头像',
|
|
detail: '正在读取本地缓存',
|
|
percent: 25
|
|
})
|
|
const hasBootstrap = await loadBootstrapCache()
|
|
if (operationId !== connectionOperationRef.current) return
|
|
// 持久化手动输入的密钥,供下次启动继续使用
|
|
void window.api.saveDbKey(trimmedRoot, keyToUse).catch(() => undefined)
|
|
void window.api.getSettings().then((current) => {
|
|
if (!current.settings.autoLoginPreferenceSet) {
|
|
void window.api.setSettings({ autoLogin: true })
|
|
}
|
|
})
|
|
setStartupProgress({
|
|
title: '正在加载账号信息...',
|
|
subtitle: '即将进入 TraceMemo',
|
|
detail: '正在读取联系人和当前账号',
|
|
percent: 70
|
|
})
|
|
// 账号识别依赖联系人数据就绪。返回登录后数据已被清空,如果先查账号,
|
|
// 会出现“数据库已连接,但账号未连接”的分离状态。手动连接与启动自动连接保持同一顺序。
|
|
if (hasBootstrap) {
|
|
// Cached contacts are sufficient for the first paint. Refresh native data in the background.
|
|
setIsAuthenticated(true)
|
|
void loadContacts({ waitForAvatars: false })
|
|
.then(() => refreshSelfInfo(3))
|
|
.catch((error) => {
|
|
console.warn('[Startup] background refresh failed:', error)
|
|
})
|
|
} else {
|
|
await loadContacts({ waitForAvatars: false })
|
|
await refreshSelfInfo(3)
|
|
setIsAuthenticated(true)
|
|
}
|
|
setStartupProgress({
|
|
title: '加载完成',
|
|
subtitle: '正在进入主页面',
|
|
detail: '联系人和头像已准备好',
|
|
percent: 100
|
|
})
|
|
setIsDatabaseConnected(true)
|
|
setBootState('login')
|
|
maybeShowFirstUseWelcome()
|
|
window.setTimeout(() => {
|
|
setStartupProgress(null)
|
|
}, 500)
|
|
} else {
|
|
const error = typeof result === 'boolean' ? '' : result.error
|
|
setDbKeyStatus(error || '数据库连接失败,请检查密钥和数据目录后重试')
|
|
setDbKeyStatusKind('error')
|
|
if (databaseConnectionMode === 'automatic') setConnectionGuideStep(5)
|
|
setBootState('login')
|
|
setStartupProgress(null)
|
|
}
|
|
} catch (error) {
|
|
console.error(error)
|
|
setDbKeyStatus(
|
|
error instanceof Error ? `数据库连接失败:${error.message}` : '数据库连接失败,请重试'
|
|
)
|
|
setDbKeyStatusKind('error')
|
|
if (databaseConnectionMode === 'automatic') setConnectionGuideStep(5)
|
|
setBootState('login')
|
|
setStartupProgress(null)
|
|
} finally {
|
|
if (operationId === connectionOperationRef.current) setIsDatabaseConnecting(false)
|
|
}
|
|
}
|
|
|
|
const logGroupSnapshot = React.useCallback(
|
|
async (contact: Contact | null, reason: string): Promise<GroupSnapshot | null> => {
|
|
if (!contact || contact.type !== 'group') return null
|
|
try {
|
|
const snapshot = (await window.api.getGroupSnapshot(contact.md5)) as GroupSnapshot | null
|
|
if (!snapshot) {
|
|
return null
|
|
}
|
|
return snapshot
|
|
} catch (error) {
|
|
console.warn(`[GroupSnapshot] reason=${reason} name=${contact.m_nsNickName} failed:`, error)
|
|
return null
|
|
}
|
|
},
|
|
[]
|
|
)
|
|
|
|
const mergeSyntheticMessages = React.useCallback(
|
|
(contact: Contact | null, baseMessages: Message[], roomId?: string): Message[] => {
|
|
if (!contact || contact.type !== 'group') return baseMessages
|
|
const resolvedRoomId = roomId || currentGroupSnapshotRef.current?.roomId
|
|
if (!resolvedRoomId) return baseMessages
|
|
const synthetic = syntheticGroupMessagesRef.current[resolvedRoomId] || []
|
|
return synthetic.length
|
|
? sortMessagesChronologically([...baseMessages, ...synthetic])
|
|
: baseMessages
|
|
},
|
|
[]
|
|
)
|
|
|
|
const applyGroupMemberMeta = React.useCallback(
|
|
(contact: Contact | null, baseMessages: Message[]): Message[] => {
|
|
if (!contact || contact.type !== 'group') return baseMessages
|
|
const memberMap = groupMemberMetaRef.current[contact.md5]
|
|
const enrichedMessages = enrichQuotedMessages(
|
|
baseMessages,
|
|
[...messageHistoryRef.current, ...baseMessages],
|
|
(senderId) => memberMap?.get(senderId)?.nickname
|
|
)
|
|
if (!memberMap || memberMap.size === 0) return enrichedMessages
|
|
|
|
return enrichedMessages.map((message) => {
|
|
const senderId = String(message.senderId || message.name || '').trim()
|
|
if (!senderId) return message
|
|
const member = memberMap.get(senderId)
|
|
if (!member) return message
|
|
const rawNickname = String(member.nickname || '').trim()
|
|
const nickname =
|
|
rawNickname &&
|
|
rawNickname !== senderId &&
|
|
!rawNickname.startsWith('wxid_') &&
|
|
!/^[a-z]{2,}\d{4,}$/i.test(rawNickname)
|
|
? rawNickname
|
|
: senderId
|
|
return {
|
|
...message,
|
|
name: nickname,
|
|
img: message.img || member.avatar
|
|
}
|
|
})
|
|
},
|
|
[]
|
|
)
|
|
|
|
const storeGroupMemberMeta = React.useCallback(
|
|
(contact: Contact, snapshot: GroupSnapshot): void => {
|
|
currentGroupSnapshotRef.current = snapshot
|
|
groupMemberMetaRef.current[contact.md5] = new Map(
|
|
snapshot.members.map((member) => [
|
|
member.wxid,
|
|
{ nickname: formatGroupMemberName(member), avatar: member.avatar || '' }
|
|
])
|
|
)
|
|
},
|
|
[]
|
|
)
|
|
|
|
const loadGroupMemberMeta = React.useCallback(
|
|
async (contact: Contact | null): Promise<GroupSnapshot | null> => {
|
|
if (!contact || contact.type !== 'group') return null
|
|
const snapshot = await logGroupSnapshot(contact, 'load-member-meta')
|
|
if (!snapshot) return null
|
|
storeGroupMemberMeta(contact, snapshot)
|
|
return snapshot
|
|
},
|
|
[logGroupSnapshot, storeGroupMemberMeta]
|
|
)
|
|
|
|
const handleReloadCurrentAvatars = React.useCallback(async (): Promise<void> => {
|
|
const contact = selectedContact
|
|
if (!contact) return
|
|
|
|
try {
|
|
const avatars = await window.api.getContactAvatars([contact.m_nsUsrName])
|
|
const avatar = avatars[contact.m_nsUsrName]
|
|
if (avatar) {
|
|
const updateContact = (item: Contact): Contact =>
|
|
item.md5 === contact.md5 ? { ...item, avatar } : item
|
|
setContacts((current) => current.map(updateContact))
|
|
setFilteredContacts((current) => current.map(updateContact))
|
|
setSelectedContact((current) =>
|
|
current?.md5 === contact.md5 ? updateContact(current) : current
|
|
)
|
|
}
|
|
} catch (error) {
|
|
console.warn('[Contacts] current avatar reload failed:', error)
|
|
}
|
|
|
|
const snapshot = await loadGroupMemberMeta(contact)
|
|
if (!snapshot || selectedContactMd5Ref.current !== contact.md5) return
|
|
setMessages((current) =>
|
|
applyGroupMemberMeta(contact, mergeSyntheticMessages(contact, current, snapshot.roomId))
|
|
)
|
|
}, [applyGroupMemberMeta, loadGroupMemberMeta, mergeSyntheticMessages, selectedContact])
|
|
|
|
const handleAutoGetDbKey = async (): Promise<void> => {
|
|
if (isFetchingDbKey) return
|
|
const operationId = ++connectionOperationRef.current
|
|
setConnectionGuideStep(4)
|
|
setIsFetchingDbKey(true)
|
|
setDbKeyStatus('正在准备获取密钥...')
|
|
setDbKeyStatusKind('normal')
|
|
setShowMacKeyFaq(false)
|
|
try {
|
|
if (!selectedAccount) throw new Error('请先选择微信账号')
|
|
const result = await window.api.autoGetDbKey(selectedAccount.accountRoot)
|
|
if (operationId !== connectionOperationRef.current) return
|
|
if (!result.success || !result.key) {
|
|
setShowMacKeyFaq(result.code === 'SCAN_FAILED')
|
|
throw new Error(result.error || '获取密钥失败')
|
|
}
|
|
setDbKey(result.key)
|
|
setConnectionGuideStep(5)
|
|
setDbKeyStatus(result.saved ? '密钥已获取并安全保存' : result.warning || '密钥已获取')
|
|
setDbKeyStatusKind(result.saved ? 'success' : 'normal')
|
|
} catch (error) {
|
|
if (operationId !== connectionOperationRef.current) return
|
|
setDbKeyStatus(error instanceof Error ? error.message : String(error))
|
|
setDbKeyStatusKind('error')
|
|
setConnectionGuideStep(3)
|
|
} finally {
|
|
if (operationId === connectionOperationRef.current) setIsFetchingDbKey(false)
|
|
}
|
|
}
|
|
|
|
const handlePasteAndSaveDbKey = async (): Promise<void> => {
|
|
setShowMacKeyFaq(false)
|
|
if (!selectedAccount) {
|
|
setDbKeyStatus('请先选择微信账号')
|
|
setDbKeyStatusKind('error')
|
|
return
|
|
}
|
|
const result = await window.api.pasteAndSaveDbKey(selectedAccount.accountRoot)
|
|
if (result.success && result.key) {
|
|
setDbKey(result.key)
|
|
setDatabaseConnectionMode('manual')
|
|
setDbKeyStatus('已从剪贴板粘贴并安全保存')
|
|
setDbKeyStatusKind('success')
|
|
} else {
|
|
setDbKeyStatus(result.error || '粘贴并保存失败')
|
|
setDbKeyStatusKind('error')
|
|
}
|
|
}
|
|
|
|
const handleClearSavedDbKey = async (): Promise<void> => {
|
|
setShowMacKeyFaq(false)
|
|
if (!selectedAccount) return
|
|
const result = await window.api.clearSavedDbKey(selectedAccount.accountRoot)
|
|
if (!result.success) {
|
|
setDbKeyStatus(result.error || '清除密钥失败')
|
|
setDbKeyStatusKind('error')
|
|
return
|
|
}
|
|
setDbKey('')
|
|
setDatabaseConnectionMode('automatic')
|
|
setDbKeyStatus('已清除保存的密钥')
|
|
setDbKeyStatusKind('normal')
|
|
}
|
|
|
|
const handleReturnToLogin = (): void => {
|
|
setIsAuthenticated(false)
|
|
setIsDatabaseConnected(false)
|
|
setIsDatabaseConnecting(false)
|
|
setBootState('login')
|
|
setDatabaseConnectionMode(dbKey ? 'manual' : 'automatic')
|
|
setActivePage('archive')
|
|
setSettingsCategory('database-key')
|
|
setSelectedContact(null)
|
|
setMessages([])
|
|
setContacts([])
|
|
setFilteredContacts([])
|
|
setSelfInfo(null)
|
|
setIsMessagesLoading(false)
|
|
setIsNativeMonitorActive(false)
|
|
setReportNotice('')
|
|
setDbKeyStatus('已断开当前连接,可重新输入或获取数据库密钥')
|
|
setDbKeyStatusKind('normal')
|
|
setStartupProgress(null)
|
|
}
|
|
|
|
const handleSwitchAccount = async (account: WechatAccountCandidate): Promise<void> => {
|
|
connectionOperationRef.current += 1
|
|
await window.api.disconnectDb({ closeNative: true })
|
|
setIsAuthenticated(false)
|
|
setIsDatabaseConnected(false)
|
|
setSelectedContact(null)
|
|
setMessages([])
|
|
setContacts([])
|
|
setFilteredContacts([])
|
|
setContentFilter('')
|
|
setSelfInfo(null)
|
|
setReportSourceContact(null)
|
|
setExportTasks([])
|
|
messageHistoryRef.current = []
|
|
messagesRef.current = []
|
|
selectedContactMd5Ref.current = ''
|
|
currentGroupSnapshotRef.current = null
|
|
groupMemberMetaRef.current = {}
|
|
syntheticGroupMessagesRef.current = {}
|
|
setDiscoveredAccounts((current) =>
|
|
current.some((item) => item.id === account.id) ? current : [account]
|
|
)
|
|
setSelectedAccountId(account.id)
|
|
setDbRootInput(account.accountRoot)
|
|
await window.api.setSettings({ dbRoot: account.accountRoot, imageKeyRoot: account.accountRoot })
|
|
const saved = await window.api.getSavedDbKey(account.accountRoot)
|
|
if (!saved.success || !saved.key) {
|
|
setDbKey('')
|
|
setDatabaseConnectionMode('automatic')
|
|
setBootState('login')
|
|
setConnectionGuideStep(3)
|
|
setDbKeyStatus('该账号尚无可用密钥,请为所选账号获取密钥')
|
|
setDbKeyStatusKind('normal')
|
|
return
|
|
}
|
|
setDbKey(saved.key)
|
|
setDatabaseConnectionMode('manual')
|
|
setBootState('login')
|
|
await handleLogin(saved.key, account.accountRoot)
|
|
}
|
|
|
|
const handleSelectContact = async (contact: Contact, forceLive = false): Promise<void> => {
|
|
setArchiveJumpTime(null)
|
|
setSelectedContact(contact)
|
|
selectedContactMd5Ref.current = contact.md5
|
|
currentGroupSnapshotRef.current = null
|
|
setIsMessagesLoading(true)
|
|
setMessageHistoryStatus('idle')
|
|
const cachedPage = await window.api.getCachedMessagePage(contact.md5)
|
|
const cachedMsgs = cachedPage.messages
|
|
if (selectedContactMd5Ref.current !== contact.md5) return
|
|
if (cachedPage.groupSnapshot) {
|
|
storeGroupMemberMeta(contact, cachedPage.groupSnapshot)
|
|
}
|
|
messageHistoryRef.current = cachedMsgs
|
|
setMessages(
|
|
applyGroupMemberMeta(
|
|
contact,
|
|
mergeSyntheticMessages(contact, cachedMsgs.slice(-INITIAL_MESSAGE_COUNT))
|
|
)
|
|
)
|
|
const needsLivePage = forceLive || isDatabaseConnected
|
|
if (!needsLivePage) {
|
|
setIsMessagesLoading(false)
|
|
if (contact.type === 'group' && cachedMsgs.length > 0 && !cachedPage.groupSnapshot) {
|
|
window.setTimeout(() => {
|
|
void loadGroupMemberMeta(contact).then((snapshot) => {
|
|
if (!snapshot || selectedContactMd5Ref.current !== contact.md5) return
|
|
setMessages((current) =>
|
|
applyGroupMemberMeta(
|
|
contact,
|
|
mergeSyntheticMessages(contact, current, snapshot.roomId)
|
|
)
|
|
)
|
|
})
|
|
}, 0)
|
|
}
|
|
return
|
|
}
|
|
if (cachedMsgs.length >= INITIAL_MESSAGE_COUNT) setIsMessagesLoading(false)
|
|
await waitForPaint()
|
|
const loadLivePage = async (): Promise<void> => {
|
|
const msgs = await window.api.getMessages(contact.md5, undefined, undefined, {
|
|
limit: MESSAGE_PREFETCH_COUNT
|
|
})
|
|
if (selectedContactMd5Ref.current !== contact.md5) return
|
|
messageHistoryRef.current = msgs
|
|
const visibleMessages = applyGroupMemberMeta(
|
|
contact,
|
|
mergeSyntheticMessages(contact, msgs.slice(-INITIAL_MESSAGE_COUNT))
|
|
)
|
|
setMessages(visibleMessages)
|
|
if (contact.type === 'group' && visibleMessages.length > 0) {
|
|
window.setTimeout(() => {
|
|
void loadGroupMemberMeta(contact).then((snapshot) => {
|
|
if (selectedContactMd5Ref.current !== contact.md5) return
|
|
if (!snapshot) return
|
|
setMessages((current) =>
|
|
applyGroupMemberMeta(
|
|
contact,
|
|
mergeSyntheticMessages(contact, current, snapshot.roomId)
|
|
)
|
|
)
|
|
})
|
|
}, 120)
|
|
}
|
|
}
|
|
const prefetchPromise = loadLivePage()
|
|
messagePrefetchRef.current = prefetchPromise
|
|
try {
|
|
await prefetchPromise
|
|
} finally {
|
|
if (messagePrefetchRef.current === prefetchPromise) messagePrefetchRef.current = null
|
|
if (selectedContactMd5Ref.current === contact.md5) setIsMessagesLoading(false)
|
|
}
|
|
}
|
|
|
|
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)
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [isDatabaseConnected])
|
|
|
|
const loadStartupCache = async (): Promise<boolean> => {
|
|
try {
|
|
const cache = await window.api.getStartupCache()
|
|
if (!cache?.contacts.length) return false
|
|
setContacts(cache.contacts)
|
|
setFilteredContacts(cache.contacts)
|
|
if (cache.self) setSelfInfo(cache.self)
|
|
return true
|
|
} catch (error) {
|
|
console.warn('[StartupCache] load failed:', error)
|
|
return false
|
|
}
|
|
}
|
|
|
|
const handleLoadOlderMessages = async (): Promise<void> => {
|
|
const contact = selectedContact
|
|
if (!contact || messagesRef.current.length === 0 || messageHistoryStatus === 'end') return
|
|
if (messagePrefetchRef.current) await messagePrefetchRef.current
|
|
if (selectedContactMd5Ref.current !== contact.md5) return
|
|
const currentMessages = messagesRef.current
|
|
const historyMessages = messageHistoryRef.current
|
|
const firstVisibleIndex = historyMessages.findIndex(
|
|
(message) => getMessageIdentity(message) === getMessageIdentity(currentMessages[0])
|
|
)
|
|
if (firstVisibleIndex > 0) {
|
|
const inMemoryOlder = historyMessages.slice(
|
|
Math.max(0, firstVisibleIndex - MESSAGE_PAGE_SIZE),
|
|
firstVisibleIndex
|
|
)
|
|
setMessages((current) =>
|
|
applyGroupMemberMeta(
|
|
contact,
|
|
mergeSyntheticMessages(contact, mergeMessagePages(inMemoryOlder, current))
|
|
)
|
|
)
|
|
return
|
|
}
|
|
const oldestTime = currentMessages[0]?.createTime
|
|
if (!oldestTime) return
|
|
|
|
setIsMessagesLoading(true)
|
|
try {
|
|
const cachedPage = await window.api.getCachedMessagePage(
|
|
contact.md5,
|
|
undefined,
|
|
oldestTime - 1
|
|
)
|
|
const olderMessages = cachedPage.hit
|
|
? cachedPage.messages
|
|
: await window.api.getMessages(contact.md5, undefined, oldestTime - 1, {
|
|
limit: MESSAGE_PAGE_SIZE
|
|
})
|
|
if (selectedContactMd5Ref.current !== contact.md5) return
|
|
setMessageHistoryStatus(olderMessages.length === 0 ? 'end' : 'idle')
|
|
messageHistoryRef.current = mergeMessagePages(olderMessages, historyMessages)
|
|
setMessages((current) =>
|
|
applyGroupMemberMeta(
|
|
contact,
|
|
mergeSyntheticMessages(contact, mergeMessagePages(olderMessages, current))
|
|
)
|
|
)
|
|
} catch (error) {
|
|
console.warn('[Messages] older page load failed:', error)
|
|
if (selectedContactMd5Ref.current === contact.md5) setMessageHistoryStatus('error')
|
|
} finally {
|
|
if (selectedContactMd5Ref.current === contact.md5) setIsMessagesLoading(false)
|
|
}
|
|
}
|
|
|
|
const loadExportPreviewMessages = async (contact: Contact): Promise<Message[]> => {
|
|
try {
|
|
return await window.api.getMessages(contact.md5, undefined, undefined, {
|
|
limit: EXPORT_PREVIEW_LIMIT
|
|
})
|
|
} catch (error) {
|
|
console.warn('[Export] preview load failed:', error)
|
|
return []
|
|
}
|
|
}
|
|
|
|
React.useEffect(() => {
|
|
if (!isAuthenticated || !selectedContact || !isNativeMonitorActive) return
|
|
|
|
let disposed = false
|
|
let refreshTimer: number | null = null
|
|
let refreshInFlight = false
|
|
let refreshQueued = false
|
|
const contactMd5 = selectedContact.md5
|
|
|
|
const refreshCurrentConversation = async (): Promise<void> => {
|
|
if (refreshInFlight) {
|
|
refreshQueued = true
|
|
return
|
|
}
|
|
refreshInFlight = true
|
|
try {
|
|
const latestMessages = await window.api.getMessages(contactMd5, undefined, undefined, {
|
|
limit: INITIAL_MESSAGE_COUNT
|
|
})
|
|
const nextMessages = applyGroupMemberMeta(
|
|
selectedContact,
|
|
mergeSyntheticMessages(selectedContact, latestMessages)
|
|
)
|
|
if (!disposed) {
|
|
setMessages((current) =>
|
|
areMessagesEquivalent(current, nextMessages) ? current : nextMessages
|
|
)
|
|
}
|
|
} catch (error) {
|
|
console.warn('[MessageMonitor] 刷新当前会话失败:', error)
|
|
}
|
|
refreshInFlight = false
|
|
if (refreshQueued && !disposed) {
|
|
refreshQueued = false
|
|
if (refreshTimer) window.clearTimeout(refreshTimer)
|
|
refreshTimer = window.setTimeout(() => {
|
|
refreshTimer = null
|
|
void refreshCurrentConversation()
|
|
}, MESSAGE_MONITOR_DEBOUNCE_MS)
|
|
}
|
|
}
|
|
|
|
const unsubscribe = window.api.onWcdbChange(({ json }) => {
|
|
const eventText = String(json || '').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(() => {
|
|
refreshTimer = null
|
|
void refreshCurrentConversation()
|
|
}, MESSAGE_MONITOR_DEBOUNCE_MS)
|
|
})
|
|
|
|
return () => {
|
|
disposed = true
|
|
if (refreshTimer) window.clearTimeout(refreshTimer)
|
|
unsubscribe()
|
|
}
|
|
}, [
|
|
isAuthenticated,
|
|
isNativeMonitorActive,
|
|
selectedContact,
|
|
logGroupSnapshot,
|
|
applyGroupMemberMeta,
|
|
mergeSyntheticMessages
|
|
])
|
|
|
|
const handleSearchContacts = (keyword: string): void => {
|
|
setFilteredContacts(filterContactList(contacts, keyword))
|
|
}
|
|
|
|
const handleRefreshContacts = async (filterKeyword: string): Promise<void> => {
|
|
try {
|
|
await loadContacts({ waitForAvatars: false, filterKeyword })
|
|
setReportNotice('会话列表已刷新')
|
|
} catch (error) {
|
|
console.warn('[Contacts] refresh failed:', error)
|
|
setReportNotice('会话列表刷新失败,请稍后重试')
|
|
}
|
|
}
|
|
|
|
const isGroupContact = (contact: Contact | null): boolean =>
|
|
Boolean(contact?.type === 'group' || contact?.m_nsUsrName?.endsWith('@chatroom'))
|
|
|
|
const handlePageChange = (page: AppPage): void => {
|
|
setActivePage(page)
|
|
if (page === 'archive' && selectedContact) void handleSelectContact(selectedContact)
|
|
if (page === 'settings') setSettingsCategory('account-database')
|
|
if (page === 'report' && isGroupContact(selectedContact) && !reportSourceContact) {
|
|
setReportSourceContact(selectedContact)
|
|
}
|
|
if (page === 'report') {
|
|
setReportWorkspaceView('result')
|
|
setSelectedReportId(null)
|
|
}
|
|
}
|
|
|
|
const openSettings = (): void => {
|
|
setSettingsCategory('account-database')
|
|
setActivePage('settings')
|
|
}
|
|
|
|
const openModelSettings = (): void => {
|
|
setSettingsCategory('ai-model')
|
|
setActivePage('settings')
|
|
}
|
|
|
|
const openTextToSpeechSettings = (): void => {
|
|
setSettingsCategory('text-to-speech')
|
|
setActivePage('settings')
|
|
}
|
|
|
|
const dismissFirstUseWelcome = (): void => {
|
|
try {
|
|
localStorage.setItem(FIRST_USE_WELCOME_SEEN_KEY, '1')
|
|
} catch {
|
|
// The welcome prompt is optional and should not interrupt normal use.
|
|
}
|
|
setShowFirstUseWelcome(false)
|
|
}
|
|
|
|
const maybeShowFirstUseWelcome = (): void => {
|
|
try {
|
|
if (localStorage.getItem(FIRST_USE_WELCOME_SEEN_KEY) === '1') return
|
|
} catch {
|
|
// If localStorage is unavailable, still show the one-time prompt for this session.
|
|
}
|
|
setShowFirstUseWelcome(true)
|
|
}
|
|
|
|
const openFirstUseSearch = (): void => {
|
|
dismissFirstUseWelcome()
|
|
setActivePage('search')
|
|
}
|
|
|
|
const openFirstUseReport = (): void => {
|
|
dismissFirstUseWelcome()
|
|
setReportWorkspaceView('configure')
|
|
setSelectedReportId(null)
|
|
setActivePage('report')
|
|
}
|
|
|
|
const openFirstUseAISettings = (): void => {
|
|
dismissFirstUseWelcome()
|
|
openModelSettings()
|
|
}
|
|
|
|
const openFirstUseGuide = (): void => {
|
|
setShowFirstUseWelcome(true)
|
|
}
|
|
|
|
const openReport = (reportId: string): void => {
|
|
setSelectedReportId(reportId)
|
|
setReportWorkspaceView('result')
|
|
setActivePage('report')
|
|
}
|
|
|
|
const handleOpenReportWorkspace = (): void => {
|
|
if (!selectedContact) {
|
|
setReportNotice('请先选择一个群聊')
|
|
return
|
|
}
|
|
if (!isGroupContact(selectedContact)) {
|
|
setReportNotice('AI 群聊日报仅支持群聊')
|
|
return
|
|
}
|
|
setReportNotice('')
|
|
setReportSourceContact(selectedContact)
|
|
setReportWorkspaceView('configure')
|
|
setActivePage('report')
|
|
}
|
|
|
|
const handleSelectReportSource = (contact: Contact): void => {
|
|
setReportSourceContact(contact)
|
|
if (selectedContact?.md5 !== contact.md5) {
|
|
void handleSelectContact(contact)
|
|
}
|
|
}
|
|
|
|
React.useEffect(() => {
|
|
if (
|
|
reportGeneration.phase !== 'success' ||
|
|
!reportSourceContact ||
|
|
!reportGeneration.generatedImage ||
|
|
!reportGeneration.reportPaths ||
|
|
!reportGeneration.reportSnapshot ||
|
|
!reportGeneration.reportMetadata
|
|
) {
|
|
return
|
|
}
|
|
|
|
const recordKey = `${reportGeneration.reportPaths.pngPath}:${reportGeneration.reportPaths.htmlPath}`
|
|
if (lastCapturedReportKeyRef.current === recordKey) return
|
|
lastCapturedReportKeyRef.current = recordKey
|
|
setLatestGeneratedReportId(null)
|
|
setIsSavingGeneratedReport(true)
|
|
const reportSnapshot = reportGeneration.reportSnapshot
|
|
const reportMetadata = reportGeneration.reportMetadata
|
|
|
|
const saveReport = async (): Promise<void> => {
|
|
const result = await window.api.saveGeneratedReport({
|
|
contactId: reportSourceContact.md5,
|
|
contactName: contactDisplayName(reportSourceContact),
|
|
contactAvatar: reportSourceContact.avatar || undefined,
|
|
dateRange:
|
|
summaryDateRange === 'yesterday'
|
|
? '昨日'
|
|
: summaryDateRange === '7days'
|
|
? '近 7 天'
|
|
: '今天',
|
|
messageCount: reportGeneration.reportMessages.length,
|
|
generatedAt: new Date().toISOString(),
|
|
generatedImage: reportGeneration.generatedImage || undefined,
|
|
htmlPath: reportGeneration.reportPaths?.htmlPath,
|
|
pngPath: reportGeneration.reportPaths?.pngPath,
|
|
duration: reportGeneration.generationMetadata.durationMs,
|
|
textModelName:
|
|
reportGeneration.generationMetadata.modelName || reportTextModelConfig.modelName,
|
|
imageModelName: aiVisionModelConfig?.modelName || aiVisionModelConfig?.model,
|
|
modelName: reportGeneration.generationMetadata.modelName || reportTextModelConfig.modelName,
|
|
tokenUsage: reportGeneration.generationMetadata.tokenUsage,
|
|
generationLogs: reportGeneration.generationMetadata.generationLogs,
|
|
reportSnapshot,
|
|
reportMetadata,
|
|
templateId: reportGeneration.templateId
|
|
})
|
|
|
|
if (!result.success || !result.record) {
|
|
setIsSavingGeneratedReport(false)
|
|
setReportNotice(result.error || '日报保存失败')
|
|
return
|
|
}
|
|
|
|
setGeneratedReports((current) => [
|
|
result.record as GeneratedReportRecord,
|
|
...current.filter((report) => report.id !== result.record?.id)
|
|
])
|
|
setLatestGeneratedReportId(result.record.id)
|
|
setIsSavingGeneratedReport(false)
|
|
openReport(result.record.id)
|
|
}
|
|
|
|
void saveReport()
|
|
}, [
|
|
aiVisionModelConfig?.model,
|
|
aiVisionModelConfig?.modelName,
|
|
reportTextModelConfig.model,
|
|
reportTextModelConfig.modelName,
|
|
reportGeneration.generatedImage,
|
|
reportGeneration.generationMetadata,
|
|
reportGeneration.phase,
|
|
reportGeneration.reportMessages.length,
|
|
reportGeneration.reportMetadata,
|
|
reportGeneration.reportPaths,
|
|
reportGeneration.reportSnapshot,
|
|
reportGeneration.templateId,
|
|
reportSourceContact,
|
|
summaryDateRange
|
|
])
|
|
|
|
const selectedReport = generatedReports.find((report) => report.id === selectedReportId) || null
|
|
const selectedReportContact = selectedReport
|
|
? contacts.find((contact) => contact.md5 === selectedReport.contactId) || null
|
|
: null
|
|
|
|
const openReportResult = (): void => {
|
|
if (isSavingGeneratedReport) {
|
|
setReportNotice('日报正在保存,请稍候')
|
|
return
|
|
}
|
|
const targetReportId = latestGeneratedReportId || selectedReportId
|
|
if (targetReportId) {
|
|
openReport(targetReportId)
|
|
return
|
|
}
|
|
setReportWorkspaceView('result')
|
|
}
|
|
|
|
const openReportConfigure = (): void => {
|
|
setReportWorkspaceView('configure')
|
|
}
|
|
|
|
const handleRegenerateReport = (): void => {
|
|
if (selectedReport) {
|
|
const source = contacts.find((contact) => contact.md5 === selectedReport.contactId)
|
|
if (source) setReportSourceContact(source)
|
|
}
|
|
setReportWorkspaceView('configure')
|
|
}
|
|
|
|
const handleCopyReportImage = async (
|
|
report: GeneratedReportRecord
|
|
): Promise<{ success: boolean; error?: string }> => {
|
|
if (!report.generatedImage) return { success: false, error: '没有可复制的日报图片' }
|
|
return window.api.copyImage(report.generatedImage)
|
|
}
|
|
|
|
const handleRevealReport = async (
|
|
report: GeneratedReportRecord
|
|
): Promise<{ success: boolean; error?: string }> => {
|
|
const filePath = report.pngPath || report.htmlPath
|
|
if (!filePath) return { success: false, error: '当前报告缺少文件路径' }
|
|
return window.api.revealGroupReport(filePath)
|
|
}
|
|
|
|
const handleSwitchReportTemplate = async (
|
|
report: GeneratedReportRecord,
|
|
templateId: SelectableReportTemplateId
|
|
): Promise<{ success: boolean; error?: string }> => {
|
|
try {
|
|
const updated = await withTimeout(
|
|
switchGeneratedReportTemplate(report, templateId, window.api),
|
|
120_000,
|
|
'模板切换超时,请稍后重试'
|
|
)
|
|
if (!updated.success || !updated.record) {
|
|
return { success: false, error: updated.error || '日报模板更新失败' }
|
|
}
|
|
|
|
setGeneratedReports((current) =>
|
|
current.map((item) => (item.id === report.id ? updated.record! : item))
|
|
)
|
|
reportGeneration.setTemplateId(templateId)
|
|
return { success: true }
|
|
} catch (error) {
|
|
return { success: false, error: error instanceof Error ? error.message : String(error) }
|
|
}
|
|
}
|
|
|
|
const handleDeleteReport = async (
|
|
reportId: string
|
|
): Promise<{ success: boolean; error?: string }> => {
|
|
const result = await window.api.deleteGeneratedReport(reportId)
|
|
if (!result.success) return { success: false, error: result.error || '删除日报失败' }
|
|
|
|
setGeneratedReports((current) => current.filter((report) => report.id !== reportId))
|
|
if (selectedReportId === reportId) {
|
|
setSelectedReportId(null)
|
|
}
|
|
if (latestGeneratedReportId === reportId) setLatestGeneratedReportId(null)
|
|
return { success: true }
|
|
}
|
|
|
|
const renderArchiveWorkspace = (): React.ReactElement => (
|
|
<div className="app-container">
|
|
<Sidebar
|
|
contacts={filteredContacts}
|
|
selectedContact={selectedContact}
|
|
onSelectContact={handleSelectContact}
|
|
onSearch={handleSearchContacts}
|
|
onContentFilter={setContentFilter}
|
|
width={sidebarWidth}
|
|
selfInfo={selfInfo}
|
|
dbReady={isDatabaseConnected}
|
|
dbConnecting={isDatabaseConnecting}
|
|
onOpenSettings={openSettings}
|
|
onRefresh={handleRefreshContacts}
|
|
/>
|
|
<div className="resizer" onMouseDown={startResizing} />
|
|
<ChatWindow
|
|
key={selectedContact?.md5}
|
|
contact={selectedContact}
|
|
messages={messages}
|
|
isLoadingMessages={isMessagesLoading}
|
|
messageHistoryStatus={messageHistoryStatus}
|
|
contentFilter={contentFilter}
|
|
onContentFilterChange={setContentFilter}
|
|
onRefresh={() => selectedContact && handleSelectContact(selectedContact, true)}
|
|
onRefreshData={loadContacts}
|
|
onReloadAvatars={handleReloadCurrentAvatars}
|
|
onLoadOlderMessages={handleLoadOlderMessages}
|
|
onCreateGroupReport={handleOpenReportWorkspace}
|
|
onOpenTextToSpeechSettings={openTextToSpeechSettings}
|
|
isAiLoading={reportGeneration.isGenerating}
|
|
jumpToTime={archiveJumpTime}
|
|
/>
|
|
</div>
|
|
)
|
|
|
|
const renderReportWorkspace = (): React.ReactElement =>
|
|
reportWorkspaceView === 'result' ? (
|
|
<div className="report-center-page">
|
|
<ReportHistorySidebar
|
|
reports={generatedReports}
|
|
selectedReportId={selectedReportId}
|
|
selfInfo={selfInfo}
|
|
dbReady={isDatabaseConnected}
|
|
dbConnecting={isDatabaseConnecting}
|
|
onSelectReport={openReport}
|
|
onCreateReport={openReportConfigure}
|
|
onDeleteReport={handleDeleteReport}
|
|
onOpenSettings={openSettings}
|
|
/>
|
|
<ReportViewer
|
|
report={selectedReport}
|
|
hasReports={generatedReports.length > 0}
|
|
onBackToConfigure={openReportConfigure}
|
|
onRegenerate={handleRegenerateReport}
|
|
onCopyImage={handleCopyReportImage}
|
|
onReveal={handleRevealReport}
|
|
onSwitchTemplate={handleSwitchReportTemplate}
|
|
sendTarget={selectedReportContact}
|
|
/>
|
|
<ReportInfoPanel report={selectedReport} onReveal={handleRevealReport} />
|
|
</div>
|
|
) : (
|
|
<div className="report-page">
|
|
<ReportSourceSidebar
|
|
contacts={contacts}
|
|
selectedContact={reportSourceContact}
|
|
selfInfo={selfInfo}
|
|
dbReady={isDatabaseConnected}
|
|
dbConnecting={isDatabaseConnecting}
|
|
onSelectContact={handleSelectReportSource}
|
|
onOpenSettings={openSettings}
|
|
/>
|
|
<AiReportWorkspace
|
|
sourceContact={reportSourceContact}
|
|
summaryDateRange={summaryDateRange}
|
|
summaryMessageTypes={summaryMessageTypes}
|
|
modelConfig={reportTextModelConfig}
|
|
visionModelConfig={aiVisionModelConfig}
|
|
textModelOptions={reportTextModelOptions}
|
|
visionModelOptions={reportVisionModelOptions}
|
|
rangeMessageCount={reportGeneration.rangeMessages.length}
|
|
reportMessageCount={reportGeneration.reportMessages.length}
|
|
messageTypeCounts={reportGeneration.messageTypeCounts}
|
|
rangeState={reportGeneration.rangeState}
|
|
phase={reportGeneration.phase}
|
|
error={reportGeneration.error}
|
|
generatedImage={reportGeneration.generatedImage}
|
|
reportPaths={reportGeneration.reportPaths}
|
|
isGenerating={reportGeneration.isGenerating}
|
|
onSummaryDateRangeChange={setSummaryDateRange}
|
|
onSummaryMessageTypesChange={setSummaryMessageTypes}
|
|
onOpenModelSettings={openModelSettings}
|
|
onTextModelChange={(model) => {
|
|
localStorage.setItem(REPORT_TEXT_MODEL_STORAGE_KEY, reportModelKey(model))
|
|
setReportTextModelConfig(model)
|
|
}}
|
|
onVisionModelChange={(model) => {
|
|
localStorage.setItem(REPORT_VISION_MODEL_STORAGE_KEY, reportModelKey(model))
|
|
setAiVisionModelConfig(model)
|
|
}}
|
|
onGenerate={() => {
|
|
reportGeneration.resetGenerationStatus()
|
|
void reportGeneration.generate()
|
|
}}
|
|
onCloseResult={reportGeneration.closeResult}
|
|
onCopyImage={reportGeneration.copyImage}
|
|
onRevealReport={reportGeneration.revealReport}
|
|
onViewResult={openReportResult}
|
|
hasReportResult={generatedReports.length > 0}
|
|
templateId={reportGeneration.templateId}
|
|
onTemplateIdChange={reportGeneration.setTemplateId}
|
|
memberNamePreference={reportGeneration.memberNamePreference}
|
|
onMemberNamePreferenceChange={reportGeneration.setMemberNamePreference}
|
|
reportTimeoutSeconds={reportGeneration.reportTimeoutSeconds}
|
|
onReportTimeoutSecondsChange={reportGeneration.setReportTimeoutSeconds}
|
|
/>
|
|
<ReportTaskStatusPanel
|
|
phase={reportGeneration.phase}
|
|
error={reportGeneration.error}
|
|
voiceTranscriptionProgress={reportGeneration.voiceTranscriptionProgress}
|
|
voiceTranscriptionEnabled={summaryMessageTypes.includes('voice')}
|
|
preparationProgress={reportGeneration.preparationProgress}
|
|
imageInsightSummary={reportGeneration.imageInsightSummary}
|
|
canRetryModelStep={reportGeneration.canRetryModelStep}
|
|
currentModel={reportTextModelConfig}
|
|
onRetry={(model) => void reportGeneration.retry(model)}
|
|
onContinueAfterImageFailures={() => void reportGeneration.continueAfterImageFailures()}
|
|
onCancelAfterImageFailures={reportGeneration.cancelAfterImageFailures}
|
|
/>
|
|
</div>
|
|
)
|
|
|
|
const renderCurrentWorkspace = (): React.ReactElement => {
|
|
switch (activePage) {
|
|
case 'archive':
|
|
return renderArchiveWorkspace()
|
|
case 'report':
|
|
return renderReportWorkspace()
|
|
case 'agent-hub':
|
|
return <AgentHubWorkspace />
|
|
case 'api':
|
|
return (
|
|
<ApiWorkspace
|
|
selectedContact={selectedContact}
|
|
dbReady={isDatabaseConnected}
|
|
onOpenSettings={openSettings}
|
|
/>
|
|
)
|
|
case 'settings':
|
|
return (
|
|
<SettingsWorkspace
|
|
selectedCategory={settingsCategory}
|
|
onCategoryChange={setSettingsCategory}
|
|
selfInfo={selfInfo}
|
|
dbReady={isDatabaseConnected}
|
|
dbConnecting={isDatabaseConnecting}
|
|
dbKey={dbKey}
|
|
onDbKeyChange={setDbKey}
|
|
onDatabaseConnectionChange={setIsDatabaseConnected}
|
|
onSelfInfoChange={setSelfInfo}
|
|
onContactsChange={setContacts}
|
|
onFilteredContactsChange={setFilteredContacts}
|
|
onReturnToLogin={handleReturnToLogin}
|
|
onAIRuntimeChange={(config: AIRuntimeModelConfig) => {
|
|
setAiModelConfig(config)
|
|
void Promise.all([
|
|
window.api.getAIVisionRuntimeConfig(),
|
|
window.api.listAIProviders()
|
|
])
|
|
.then(([visionRuntime, providerList]) => {
|
|
const providers = providerList.success ? providerList.providers : []
|
|
const textChoices = reportModelChoices(providers, 'chat')
|
|
const visionChoices = reportModelChoices(providers, 'vision')
|
|
setReportTextModelOptions(textChoices)
|
|
setReportVisionModelOptions(visionChoices)
|
|
setReportTextModelConfig(
|
|
(current) =>
|
|
selectReportModel(
|
|
textChoices,
|
|
REPORT_TEXT_MODEL_STORAGE_KEY,
|
|
current.configured ? current : config
|
|
) || config
|
|
)
|
|
setAiVisionModelConfig((current) =>
|
|
selectReportModel(
|
|
visionChoices,
|
|
REPORT_VISION_MODEL_STORAGE_KEY,
|
|
current || visionRuntime
|
|
)
|
|
)
|
|
})
|
|
.catch(() => undefined)
|
|
}}
|
|
onNotice={setReportNotice}
|
|
onOpenSettings={openSettings}
|
|
onAppearanceChange={handleAppearanceChange}
|
|
onSwitchAccount={handleSwitchAccount}
|
|
/>
|
|
)
|
|
case 'search':
|
|
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
|
|
contacts={contacts}
|
|
initialContact={selectedContact}
|
|
selfInfo={selfInfo}
|
|
dbReady={isDatabaseConnected}
|
|
loadPreviewMessages={loadExportPreviewMessages}
|
|
onOpenSettings={openSettings}
|
|
exportTasks={exportTasks}
|
|
onStartExport={handleStartExport}
|
|
onCancelExport={handleCancelExport}
|
|
/>
|
|
)
|
|
}
|
|
}
|
|
|
|
const [sidebarWidth, setSidebarWidth] = useState(300)
|
|
const [isResizing, setIsResizing] = useState(false)
|
|
const sidebarResizeStartRef = React.useRef({ x: 0, width: 300 })
|
|
|
|
const startResizing = React.useCallback(
|
|
(mouseDownEvent: React.MouseEvent<HTMLDivElement>) => {
|
|
sidebarResizeStartRef.current = {
|
|
x: mouseDownEvent.clientX,
|
|
width: sidebarWidth
|
|
}
|
|
setIsResizing(true)
|
|
},
|
|
[sidebarWidth]
|
|
)
|
|
|
|
const stopResizing = React.useCallback(() => {
|
|
setIsResizing(false)
|
|
}, [])
|
|
|
|
const resize = React.useCallback(
|
|
(mouseMoveEvent: MouseEvent) => {
|
|
if (isResizing) {
|
|
const { x, width } = sidebarResizeStartRef.current
|
|
const nextWidth = width + mouseMoveEvent.clientX - x
|
|
setSidebarWidth(Math.min(SIDEBAR_MAX_WIDTH, Math.max(SIDEBAR_MIN_WIDTH, nextWidth)))
|
|
}
|
|
},
|
|
[isResizing]
|
|
)
|
|
|
|
React.useEffect(() => {
|
|
window.addEventListener('mousemove', resize)
|
|
window.addEventListener('mouseup', stopResizing)
|
|
return () => {
|
|
window.removeEventListener('mousemove', resize)
|
|
window.removeEventListener('mouseup', stopResizing)
|
|
}
|
|
}, [resize, stopResizing])
|
|
|
|
if (!isAuthenticated && bootState !== 'login') {
|
|
const title =
|
|
startupProgress?.title ||
|
|
(bootState === 'connecting' ? '正在自动连接数据库...' : '正在准备...')
|
|
const subtitle =
|
|
startupProgress?.subtitle ||
|
|
(bootState === 'connecting'
|
|
? autoConnectSource === 'env'
|
|
? '检测到环境变量中的密钥'
|
|
: '使用上次安全保存的密钥'
|
|
: 'TraceMemo')
|
|
return (
|
|
<div className={`boot-splash ${appearanceSettings.showStartupProgress ? '' : 'is-quiet'}`}>
|
|
<div className="boot-splash-spinner" aria-hidden />
|
|
<div className="boot-splash-title">{title}</div>
|
|
<div className="boot-splash-subtitle">{subtitle}</div>
|
|
{startupProgress?.detail && (
|
|
<div className="boot-splash-detail">{startupProgress.detail}</div>
|
|
)}
|
|
{typeof startupProgress?.percent === 'number' && (
|
|
<div className="boot-splash-progress" aria-label="加载进度">
|
|
<div
|
|
className="boot-splash-progress-bar"
|
|
style={{ width: `${Math.max(0, Math.min(100, startupProgress.percent))}%` }}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (!isAuthenticated) {
|
|
return (
|
|
<DatabaseConnectionPage
|
|
platform={runtimePlatform}
|
|
mode={databaseConnectionMode}
|
|
dbKey={dbKey}
|
|
dbRoot={dbRootInput}
|
|
showDbKey={showDbKey}
|
|
isFetching={isFetchingDbKey}
|
|
isConnecting={isDatabaseConnecting}
|
|
guideStep={connectionGuideStep}
|
|
environment={databaseEnvironment}
|
|
accounts={discoveredAccounts}
|
|
selectedAccountId={selectedAccountId}
|
|
status={dbKeyStatus}
|
|
statusKind={dbKeyStatusKind}
|
|
showMacKeyFaq={showMacKeyFaq}
|
|
macKeyFaqUrl={MAC_KEY_FAQ_URL}
|
|
onModeChange={setDatabaseConnectionMode}
|
|
onDbKeyChange={setDbKey}
|
|
onDbRootChange={(value) => {
|
|
setDbRootInput(value)
|
|
setDiscoveredAccounts([])
|
|
setSelectedAccountId('')
|
|
}}
|
|
onSelectAccount={(account) => {
|
|
setSelectedAccountId(account.id)
|
|
setDbKey('')
|
|
void window.api.getSavedDbKey(account.accountRoot).then((result) => {
|
|
if (result.success && result.key) setDbKey(result.key)
|
|
})
|
|
}}
|
|
onSelectDbRoot={() => {
|
|
void window.api.selectDbRoot().then((result) => {
|
|
if (!result.canceled && result.path) {
|
|
setDbRootInput(result.path)
|
|
void window.api.discoverAccounts(result.path).then((discovery) => {
|
|
if (!discovery.success) {
|
|
setDiscoveredAccounts([])
|
|
setSelectedAccountId('')
|
|
setDbKeyStatus(discovery.error || '账号目录识别失败')
|
|
setDbKeyStatusKind('error')
|
|
return
|
|
}
|
|
setDiscoveredAccounts(discovery.accounts)
|
|
setSelectedAccountId(discovery.preselectedAccountId || '')
|
|
})
|
|
}
|
|
})
|
|
}}
|
|
onToggleDbKey={() => setShowDbKey((visible) => !visible)}
|
|
onAutoGetKey={handleAutoGetDbKey}
|
|
onRefreshEnvironment={() => void refreshConnectionEnvironment()}
|
|
onGuideNext={() =>
|
|
setConnectionGuideStep((current) => (current === 1 ? 2 : current === 2 ? 3 : current))
|
|
}
|
|
onGuideBack={() =>
|
|
setConnectionGuideStep((current) =>
|
|
current === 5 ? 3 : current > 1 ? ((current - 1) as 1 | 2 | 3 | 4 | 5 | 6) : 1
|
|
)
|
|
}
|
|
onGuideCancel={() => {
|
|
connectionOperationRef.current += 1
|
|
setIsFetchingDbKey(false)
|
|
setIsDatabaseConnecting(false)
|
|
setBootState('login')
|
|
setStartupProgress(null)
|
|
setConnectionGuideStep(1)
|
|
setDbKeyStatus('已取消,可以重新检查环境')
|
|
setDbKeyStatusKind('normal')
|
|
}}
|
|
onValidateConnection={() => void handleLogin()}
|
|
onCopyDiagnostics={() => {
|
|
if (!databaseEnvironment?.diagnosticSummary) {
|
|
setDbKeyStatus('诊断信息尚未准备好,请先重新检查环境')
|
|
setDbKeyStatusKind('error')
|
|
return
|
|
}
|
|
void window.api.copyText(databaseEnvironment.diagnosticSummary).then(() => {
|
|
setDbKeyStatus('脱敏诊断摘要已复制')
|
|
setDbKeyStatusKind('success')
|
|
})
|
|
}}
|
|
onManualConnect={() => handleLogin()}
|
|
onPasteKey={handlePasteAndSaveDbKey}
|
|
onClearKey={handleClearSavedDbKey}
|
|
/>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<AppShell
|
|
activePage={activePage}
|
|
selfInfo={selfInfo}
|
|
dbReady={isDatabaseConnected}
|
|
dbConnecting={isDatabaseConnecting}
|
|
onPageChange={handlePageChange}
|
|
onOpenSettings={openSettings}
|
|
onOpenGuide={openFirstUseGuide}
|
|
appearanceTheme={appearanceSettings.theme}
|
|
compactMode={appearanceSettings.compactMode}
|
|
>
|
|
{reportNotice && <div className="app-toast">{reportNotice}</div>}
|
|
{showFirstUseWelcome && (
|
|
<FirstUseWelcome
|
|
onDismiss={dismissFirstUseWelcome}
|
|
onOpenSearch={openFirstUseSearch}
|
|
onOpenReport={openFirstUseReport}
|
|
onOpenAISettings={openFirstUseAISettings}
|
|
/>
|
|
)}
|
|
{renderCurrentWorkspace()}
|
|
</AppShell>
|
|
)
|
|
}
|
|
|
|
export default App
|