feat: 优化足迹总结与自身消息强度分析
This commit is contained in:
1 parent
ca6c479496
commit
a67959dc2a
8 files changed
+503
-53
No files matched your search
+4
-1
@@ -3468,6 +3468,10 @@ function registerIpcHandlers() {
|
|||||||
return analyticsService.getTimeDistribution()
|
return analyticsService.getTimeDistribution()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('analytics:getSelfSentDailyDistribution', async (_, beginTimestamp?: number, endTimestamp?: number, force?: boolean) => {
|
||||||
|
return analyticsService.getSelfSentDailyDistribution(beginTimestamp, endTimestamp, force)
|
||||||
|
})
|
||||||
|
|
||||||
ipcMain.handle('analytics:getExcludedUsernames', async () => {
|
ipcMain.handle('analytics:getExcludedUsernames', async () => {
|
||||||
return analyticsService.getExcludedUsernames()
|
return analyticsService.getExcludedUsernames()
|
||||||
})
|
})
|
||||||
@@ -4388,4 +4392,3 @@ app.on('window-all-closed', () => {
|
|||||||
app.quit()
|
app.quit()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -394,6 +394,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
getContactRankings: (limit?: number, beginTimestamp?: number, endTimestamp?: number) =>
|
getContactRankings: (limit?: number, beginTimestamp?: number, endTimestamp?: number) =>
|
||||||
ipcRenderer.invoke('analytics:getContactRankings', limit, beginTimestamp, endTimestamp),
|
ipcRenderer.invoke('analytics:getContactRankings', limit, beginTimestamp, endTimestamp),
|
||||||
getTimeDistribution: () => ipcRenderer.invoke('analytics:getTimeDistribution'),
|
getTimeDistribution: () => ipcRenderer.invoke('analytics:getTimeDistribution'),
|
||||||
|
getSelfSentDailyDistribution: (beginTimestamp?: number, endTimestamp?: number, force?: boolean) =>
|
||||||
|
ipcRenderer.invoke('analytics:getSelfSentDailyDistribution', beginTimestamp, endTimestamp, force),
|
||||||
getExcludedUsernames: () => ipcRenderer.invoke('analytics:getExcludedUsernames'),
|
getExcludedUsernames: () => ipcRenderer.invoke('analytics:getExcludedUsernames'),
|
||||||
setExcludedUsernames: (usernames: string[]) => ipcRenderer.invoke('analytics:setExcludedUsernames', usernames),
|
setExcludedUsernames: (usernames: string[]) => ipcRenderer.invoke('analytics:setExcludedUsernames', usernames),
|
||||||
getExcludeCandidates: () => ipcRenderer.invoke('analytics:getExcludeCandidates'),
|
getExcludeCandidates: () => ipcRenderer.invoke('analytics:getExcludeCandidates'),
|
||||||
|
|||||||
@@ -27,6 +27,16 @@ export interface TimeDistribution {
|
|||||||
monthlyDistribution: Record<string, number>
|
monthlyDistribution: Record<string, number>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SelfSentDailyDistribution {
|
||||||
|
unit: 'day'
|
||||||
|
dailyDistribution: Record<string, number>
|
||||||
|
totalMessages: number
|
||||||
|
firstMessageTime: number | null
|
||||||
|
lastMessageTime: number | null
|
||||||
|
beginTimestamp: number
|
||||||
|
endTimestamp: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface ContactRanking {
|
export interface ContactRanking {
|
||||||
username: string
|
username: string
|
||||||
displayName: string
|
displayName: string
|
||||||
@@ -42,6 +52,7 @@ class AnalyticsService {
|
|||||||
private configService: ConfigService
|
private configService: ConfigService
|
||||||
private fallbackAggregateCache: { key: string; data: any; updatedAt: number } | null = null
|
private fallbackAggregateCache: { key: string; data: any; updatedAt: number } | null = null
|
||||||
private aggregateCache: { key: string; data: any; updatedAt: number } | null = null
|
private aggregateCache: { key: string; data: any; updatedAt: number } | null = null
|
||||||
|
private selfSentDailyCache: { key: string; data: SelfSentDailyDistribution; updatedAt: number } | null = null
|
||||||
private aggregatePromise: { key: string; promise: Promise<{ success: boolean; data?: any; source?: string; error?: string }> } | null = null
|
private aggregatePromise: { key: string; promise: Promise<{ success: boolean; data?: any; source?: string; error?: string }> } | null = null
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -190,15 +201,12 @@ class AnalyticsService {
|
|||||||
sessionId: string,
|
sessionId: string,
|
||||||
onRow: (row: Record<string, any>) => void,
|
onRow: (row: Record<string, any>) => void,
|
||||||
beginTimestamp = 0,
|
beginTimestamp = 0,
|
||||||
endTimestamp = 0
|
endTimestamp = 0,
|
||||||
|
lite = false
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const cursorResult = await wcdbService.openMessageCursor(
|
const cursorResult = lite
|
||||||
sessionId,
|
? await wcdbService.openMessageCursorLite(sessionId, 500, true, beginTimestamp, endTimestamp)
|
||||||
500,
|
: await wcdbService.openMessageCursor(sessionId, 500, true, beginTimestamp, endTimestamp)
|
||||||
true,
|
|
||||||
beginTimestamp,
|
|
||||||
endTimestamp
|
|
||||||
)
|
|
||||||
if (!cursorResult.success || !cursorResult.cursor) return
|
if (!cursorResult.success || !cursorResult.cursor) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -223,6 +231,76 @@ class AnalyticsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getRowCreateTime(row: Record<string, any>): number {
|
||||||
|
const raw = row.create_time ?? row.createTime ?? row.create_time_ms ?? '0'
|
||||||
|
const parsed = parseInt(String(raw), 10)
|
||||||
|
if (!Number.isFinite(parsed) || parsed <= 0) return 0
|
||||||
|
return parsed > 1e12 ? Math.floor(parsed / 1000) : parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
private isRowSentByMe(row: Record<string, any>, cleanedWxid: string): boolean {
|
||||||
|
const isSendRaw = row.computed_is_send ?? row.is_send ?? row.isSend
|
||||||
|
const normalized = String(isSendRaw).trim().toLowerCase()
|
||||||
|
let isSend = isSendRaw === 1 || isSendRaw === true || normalized === '1' || normalized === 'true'
|
||||||
|
|
||||||
|
if (isSendRaw === undefined || isSendRaw === null) {
|
||||||
|
const senderUsername = row.sender_username || row.senderUsername || row.sender
|
||||||
|
if (senderUsername && cleanedWxid) {
|
||||||
|
const senderLower = String(senderUsername).toLowerCase()
|
||||||
|
const myWxidLower = cleanedWxid.toLowerCase()
|
||||||
|
isSend = senderLower === myWxidLower || senderLower.startsWith(`${myWxidLower}_`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return isSend
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatDayKey(timestamp: number): string {
|
||||||
|
const date = new Date(timestamp * 1000)
|
||||||
|
const year = date.getFullYear()
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(date.getDate()).padStart(2, '0')
|
||||||
|
return `${year}-${month}-${day}`
|
||||||
|
}
|
||||||
|
|
||||||
|
private sortDailyDistribution(daily: Record<string, number>): Record<string, number> {
|
||||||
|
const sorted: Record<string, number> = {}
|
||||||
|
for (const key of Object.keys(daily).sort()) {
|
||||||
|
sorted[key] = daily[key]
|
||||||
|
}
|
||||||
|
return sorted
|
||||||
|
}
|
||||||
|
|
||||||
|
private completeDailyDistribution(
|
||||||
|
daily: Record<string, number>,
|
||||||
|
firstTimestamp: number,
|
||||||
|
lastTimestamp: number
|
||||||
|
): Record<string, number> {
|
||||||
|
if (!firstTimestamp || !lastTimestamp || lastTimestamp < firstTimestamp) {
|
||||||
|
return this.sortDailyDistribution(daily)
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = new Date(firstTimestamp * 1000)
|
||||||
|
const end = new Date(lastTimestamp * 1000)
|
||||||
|
start.setHours(0, 0, 0, 0)
|
||||||
|
end.setHours(0, 0, 0, 0)
|
||||||
|
|
||||||
|
const roughDays = Math.floor((end.getTime() - start.getTime()) / 86400000) + 1
|
||||||
|
if (roughDays <= 0 || roughDays > 5000) {
|
||||||
|
return this.sortDailyDistribution(daily)
|
||||||
|
}
|
||||||
|
|
||||||
|
const completed: Record<string, number> = {}
|
||||||
|
const cursor = new Date(start)
|
||||||
|
while (cursor.getTime() <= end.getTime()) {
|
||||||
|
const key = `${cursor.getFullYear()}-${String(cursor.getMonth() + 1).padStart(2, '0')}-${String(cursor.getDate()).padStart(2, '0')}`
|
||||||
|
completed[key] = daily[key] || 0
|
||||||
|
cursor.setDate(cursor.getDate() + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return completed
|
||||||
|
}
|
||||||
|
|
||||||
private setProgress(window: any, status: string, progress: number) {
|
private setProgress(window: any, status: string, progress: number) {
|
||||||
if (window && !window.isDestroyed()) {
|
if (window && !window.isDestroyed()) {
|
||||||
window.webContents.send('analytics:progress', { status, progress })
|
window.webContents.send('analytics:progress', { status, progress })
|
||||||
@@ -251,6 +329,7 @@ class AnalyticsService {
|
|||||||
hourly: {} as Record<number, number>,
|
hourly: {} as Record<number, number>,
|
||||||
weekday: {} as Record<number, number>,
|
weekday: {} as Record<number, number>,
|
||||||
daily: {} as Record<string, number>,
|
daily: {} as Record<string, number>,
|
||||||
|
sentDaily: {} as Record<string, number>,
|
||||||
monthly: {} as Record<string, number>,
|
monthly: {} as Record<string, number>,
|
||||||
sessions: {} as Record<string, { total: number; sent: number; received: number; lastTime: number }>,
|
sessions: {} as Record<string, { total: number; sent: number; received: number; lastTime: number }>,
|
||||||
idMap: {}
|
idMap: {}
|
||||||
@@ -259,27 +338,13 @@ class AnalyticsService {
|
|||||||
for (const sessionId of sessionIds) {
|
for (const sessionId of sessionIds) {
|
||||||
const sessionStat = { total: 0, sent: 0, received: 0, lastTime: 0 }
|
const sessionStat = { total: 0, sent: 0, received: 0, lastTime: 0 }
|
||||||
await this.iterateSessionMessages(sessionId, (row) => {
|
await this.iterateSessionMessages(sessionId, (row) => {
|
||||||
const createTime = parseInt(row.create_time || row.createTime || row.create_time_ms || '0', 10)
|
const createTime = this.getRowCreateTime(row)
|
||||||
if (!createTime) return
|
if (!createTime) return
|
||||||
if (beginTimestamp > 0 && createTime < beginTimestamp) return
|
if (beginTimestamp > 0 && createTime < beginTimestamp) return
|
||||||
if (endTimestamp > 0 && createTime > endTimestamp) return
|
if (endTimestamp > 0 && createTime > endTimestamp) return
|
||||||
|
|
||||||
const localType = parseInt(row.local_type || row.type || '1', 10)
|
const localType = parseInt(row.local_type || row.type || '1', 10)
|
||||||
const isSendRaw = row.computed_is_send ?? row.is_send ?? row.isSend
|
const isSend = this.isRowSentByMe(row, cleanedWxid)
|
||||||
let isSend = String(isSendRaw) === '1' || isSendRaw === 1 || isSendRaw === true
|
|
||||||
|
|
||||||
// 如果底层没有提供 is_send,则根据发送者用户名推断
|
|
||||||
const senderUsername = row.sender_username || row.senderUsername || row.sender
|
|
||||||
if (isSendRaw === undefined || isSendRaw === null) {
|
|
||||||
if (senderUsername && (cleanedWxid)) {
|
|
||||||
const senderLower = String(senderUsername).toLowerCase()
|
|
||||||
const myWxidLower = cleanedWxid.toLowerCase()
|
|
||||||
isSend = (
|
|
||||||
senderLower === myWxidLower ||
|
|
||||||
senderLower.startsWith(myWxidLower + '_')
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
aggregate.total += 1
|
aggregate.total += 1
|
||||||
sessionStat.total += 1
|
sessionStat.total += 1
|
||||||
@@ -314,6 +379,9 @@ class AnalyticsService {
|
|||||||
aggregate.weekday[weekday] = (aggregate.weekday[weekday] || 0) + 1
|
aggregate.weekday[weekday] = (aggregate.weekday[weekday] || 0) + 1
|
||||||
aggregate.monthly[monthKey] = (aggregate.monthly[monthKey] || 0) + 1
|
aggregate.monthly[monthKey] = (aggregate.monthly[monthKey] || 0) + 1
|
||||||
aggregate.daily[dayKey] = (aggregate.daily[dayKey] || 0) + 1
|
aggregate.daily[dayKey] = (aggregate.daily[dayKey] || 0) + 1
|
||||||
|
if (isSend) {
|
||||||
|
aggregate.sentDaily[dayKey] = (aggregate.sentDaily[dayKey] || 0) + 1
|
||||||
|
}
|
||||||
}, beginTimestamp, endTimestamp)
|
}, beginTimestamp, endTimestamp)
|
||||||
|
|
||||||
if (sessionStat.total > 0) {
|
if (sessionStat.total > 0) {
|
||||||
@@ -324,6 +392,49 @@ class AnalyticsService {
|
|||||||
return aggregate
|
return aggregate
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async computeSelfSentDailyDistribution(
|
||||||
|
sessionIds: string[],
|
||||||
|
cleanedWxid: string,
|
||||||
|
beginTimestamp = 0,
|
||||||
|
endTimestamp = 0
|
||||||
|
): Promise<SelfSentDailyDistribution> {
|
||||||
|
const dailyDistribution: Record<string, number> = {}
|
||||||
|
let totalMessages = 0
|
||||||
|
let firstMessageTime = 0
|
||||||
|
let lastMessageTime = 0
|
||||||
|
|
||||||
|
for (const sessionId of sessionIds) {
|
||||||
|
await this.iterateSessionMessages(sessionId, (row) => {
|
||||||
|
const createTime = this.getRowCreateTime(row)
|
||||||
|
if (!createTime) return
|
||||||
|
if (beginTimestamp > 0 && createTime < beginTimestamp) return
|
||||||
|
if (endTimestamp > 0 && createTime > endTimestamp) return
|
||||||
|
if (!this.isRowSentByMe(row, cleanedWxid)) return
|
||||||
|
|
||||||
|
const dayKey = this.formatDayKey(createTime)
|
||||||
|
dailyDistribution[dayKey] = (dailyDistribution[dayKey] || 0) + 1
|
||||||
|
totalMessages += 1
|
||||||
|
|
||||||
|
if (firstMessageTime === 0 || createTime < firstMessageTime) {
|
||||||
|
firstMessageTime = createTime
|
||||||
|
}
|
||||||
|
if (createTime > lastMessageTime) {
|
||||||
|
lastMessageTime = createTime
|
||||||
|
}
|
||||||
|
}, beginTimestamp, endTimestamp, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
unit: 'day',
|
||||||
|
dailyDistribution: this.completeDailyDistribution(dailyDistribution, firstMessageTime, lastMessageTime),
|
||||||
|
totalMessages,
|
||||||
|
firstMessageTime: firstMessageTime || null,
|
||||||
|
lastMessageTime: lastMessageTime || null,
|
||||||
|
beginTimestamp,
|
||||||
|
endTimestamp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async getAggregateWithFallback(
|
private async getAggregateWithFallback(
|
||||||
sessionIds: string[],
|
sessionIds: string[],
|
||||||
beginTimestamp = 0,
|
beginTimestamp = 0,
|
||||||
@@ -668,9 +779,47 @@ class AnalyticsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getSelfSentDailyDistribution(
|
||||||
|
beginTimestamp: number = 0,
|
||||||
|
endTimestamp: number = 0,
|
||||||
|
force = false
|
||||||
|
): Promise<{ success: boolean; data?: SelfSentDailyDistribution; error?: string }> {
|
||||||
|
try {
|
||||||
|
const conn = await this.ensureConnected()
|
||||||
|
if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error }
|
||||||
|
|
||||||
|
const sessionInfo = await this.getPrivateSessions(conn.cleanedWxid)
|
||||||
|
if (sessionInfo.usernames.length === 0) {
|
||||||
|
return { success: false, error: '未找到消息会话' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheKey = `self-sent-daily-${this.buildAggregateCacheKey(sessionInfo.usernames, beginTimestamp, endTimestamp)}`
|
||||||
|
if (force) this.selfSentDailyCache = null
|
||||||
|
|
||||||
|
if (!force && this.selfSentDailyCache && this.selfSentDailyCache.key === cacheKey) {
|
||||||
|
if (Date.now() - this.selfSentDailyCache.updatedAt < 5 * 60 * 1000) {
|
||||||
|
return { success: true, data: this.selfSentDailyCache.data }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await this.computeSelfSentDailyDistribution(
|
||||||
|
sessionInfo.usernames,
|
||||||
|
conn.cleanedWxid,
|
||||||
|
beginTimestamp,
|
||||||
|
endTimestamp
|
||||||
|
)
|
||||||
|
this.selfSentDailyCache = { key: cacheKey, data, updatedAt: Date.now() }
|
||||||
|
|
||||||
|
return { success: true, data }
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: String(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async clearCache(): Promise<{ success: boolean; error?: string }> {
|
async clearCache(): Promise<{ success: boolean; error?: string }> {
|
||||||
this.aggregateCache = null
|
this.aggregateCache = null
|
||||||
this.fallbackAggregateCache = null
|
this.fallbackAggregateCache = null
|
||||||
|
this.selfSentDailyCache = null
|
||||||
this.aggregatePromise = null
|
this.aggregatePromise = null
|
||||||
try {
|
try {
|
||||||
await rm(this.getCacheFilePath(), { force: true })
|
await rm(this.getCacheFilePath(), { force: true })
|
||||||
|
|||||||
@@ -41,6 +41,18 @@ const API_MAX_TOKENS_MIN = 1
|
|||||||
const API_MAX_TOKENS_MAX = 2_000_000
|
const API_MAX_TOKENS_MAX = 2_000_000
|
||||||
const API_TEMPERATURE = 0.7
|
const API_TEMPERATURE = 0.7
|
||||||
const INSIGHT_NOTIFICATION_AVATAR_URL = './assets/insight/AI_Insight.png'
|
const INSIGHT_NOTIFICATION_AVATAR_URL = './assets/insight/AI_Insight.png'
|
||||||
|
const MIMO_FOOTPRINT_MIN_TOKENS = 4096
|
||||||
|
const FOOTPRINT_API_TEMPERATURE = 0.2
|
||||||
|
|
||||||
|
const DEFAULT_FOOTPRINT_SYSTEM_PROMPT = `你是“我的微信足迹”模块的总结器,只能根据用户提供的统计数据生成最终复盘文案。
|
||||||
|
硬性输出规则:
|
||||||
|
1. 只输出最终总结正文,不输出思考过程、步骤、标题、列表、JSON、Markdown、代码块、引号或字段名。
|
||||||
|
2. 输出 2 句中文,总长度 60-160 字,最多 180 字。
|
||||||
|
3. 第 1 句概括联络活跃度、回复情况或 @我情况;第 2 句给出一个当天/当前范围内可执行的沟通建议。
|
||||||
|
4. 必须引用至少 2 个输入数字,例如人数、回复率、@我次数或群聊数。
|
||||||
|
5. 数据为 0 时如实说明,不臆测具体聊天内容、关系、情绪、诊断或原因。
|
||||||
|
6. 禁止出现“首先”“其次”“根据”“综上”“作为AI”“我认为”“以下是”等过程性表达。
|
||||||
|
输出格式:直接输出两句自然中文。`
|
||||||
|
|
||||||
/** 沉默天数阈值默认值 */
|
/** 沉默天数阈值默认值 */
|
||||||
const DEFAULT_SILENCE_DAYS = 3
|
const DEFAULT_SILENCE_DAYS = 3
|
||||||
@@ -81,6 +93,12 @@ interface SharedAiModelConfig {
|
|||||||
|
|
||||||
type InsightFilterMode = 'whitelist' | 'blacklist'
|
type InsightFilterMode = 'whitelist' | 'blacklist'
|
||||||
|
|
||||||
|
interface CallApiOptions {
|
||||||
|
temperature?: number
|
||||||
|
disableThinking?: boolean
|
||||||
|
useMaxCompletionTokens?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
// ─── 日志 ─────────────────────────────────────────────────────────────────────
|
// ─── 日志 ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
type InsightLogLevel = 'INFO' | 'WARN' | 'ERROR'
|
type InsightLogLevel = 'INFO' | 'WARN' | 'ERROR'
|
||||||
@@ -161,6 +179,54 @@ function normalizeSessionIdList(value: unknown): string[] {
|
|||||||
return Array.from(new Set(value.map((item) => String(item || '').trim()).filter(Boolean)))
|
return Array.from(new Set(value.map((item) => String(item || '').trim()).filter(Boolean)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isMimoModel(apiBaseUrl: string, model: string): boolean {
|
||||||
|
const target = `${apiBaseUrl} ${model}`.toLowerCase()
|
||||||
|
return target.includes('mimo') || target.includes('xiaomi')
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFootprintSystemPrompt(customPrompt: string): string {
|
||||||
|
const custom = String(customPrompt || '').trim()
|
||||||
|
if (!custom || custom === DEFAULT_FOOTPRINT_SYSTEM_PROMPT) {
|
||||||
|
return DEFAULT_FOOTPRINT_SYSTEM_PROMPT
|
||||||
|
}
|
||||||
|
return `${DEFAULT_FOOTPRINT_SYSTEM_PROMPT}
|
||||||
|
|
||||||
|
用户自定义补充要求如下,只能在不违反上述硬性输出规则时执行:
|
||||||
|
${custom}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeFootprintInsight(text: string): string {
|
||||||
|
let normalized = String(text || '').trim()
|
||||||
|
if (!normalized) return ''
|
||||||
|
|
||||||
|
if (normalized.startsWith('{') && normalized.endsWith('}')) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(normalized)
|
||||||
|
const value = parsed?.summary || parsed?.insight || parsed?.content || parsed?.text
|
||||||
|
if (typeof value === 'string' && value.trim()) {
|
||||||
|
normalized = value.trim()
|
||||||
|
}
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized = normalized
|
||||||
|
.replace(/^```(?:text|markdown|md|json)?/i, '')
|
||||||
|
.replace(/```$/i, '')
|
||||||
|
.replace(/^(足迹复盘|AI足迹总结|AI 足迹总结|总结|建议)[::]\s*/i, '')
|
||||||
|
.replace(/^\s*[-*•]\s*/gm, '')
|
||||||
|
.replace(/\s*\n+\s*/g, ' ')
|
||||||
|
.replace(/\s{2,}/g, ' ')
|
||||||
|
.trim()
|
||||||
|
|
||||||
|
if (normalized.length > 180) {
|
||||||
|
const sliced = normalized.slice(0, 180)
|
||||||
|
const lastStop = Math.max(sliced.lastIndexOf('。'), sliced.lastIndexOf('!'), sliced.lastIndexOf('?'))
|
||||||
|
normalized = lastStop >= 60 ? sliced.slice(0, lastStop + 1) : `${sliced.replace(/[,,;;、\s]+$/g, '')}。`
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 调用 OpenAI 兼容 API(非流式),返回模型第一条消息内容。
|
* 调用 OpenAI 兼容 API(非流式),返回模型第一条消息内容。
|
||||||
* 使用 Node 原生 https/http 模块,无需任何第三方 SDK。
|
* 使用 Node 原生 https/http 模块,无需任何第三方 SDK。
|
||||||
@@ -171,7 +237,8 @@ function callApi(
|
|||||||
model: string,
|
model: string,
|
||||||
messages: Array<{ role: string; content: string }>,
|
messages: Array<{ role: string; content: string }>,
|
||||||
timeoutMs: number = API_TIMEOUT_MS,
|
timeoutMs: number = API_TIMEOUT_MS,
|
||||||
maxTokens: number = API_MAX_TOKENS_DEFAULT
|
maxTokens: number = API_MAX_TOKENS_DEFAULT,
|
||||||
|
options: CallApiOptions = {}
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const endpoint = buildApiUrl(apiBaseUrl, '/chat/completions')
|
const endpoint = buildApiUrl(apiBaseUrl, '/chat/completions')
|
||||||
@@ -183,15 +250,26 @@ function callApi(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = JSON.stringify({
|
const normalizedMaxTokens = normalizeApiMaxTokens(maxTokens)
|
||||||
|
const requestBody: Record<string, unknown> = {
|
||||||
model,
|
model,
|
||||||
messages,
|
messages,
|
||||||
max_tokens: normalizeApiMaxTokens(maxTokens),
|
temperature: options.temperature ?? API_TEMPERATURE,
|
||||||
temperature: API_TEMPERATURE,
|
|
||||||
stream: false
|
stream: false
|
||||||
})
|
}
|
||||||
|
if (options.useMaxCompletionTokens) {
|
||||||
|
requestBody.max_completion_tokens = normalizedMaxTokens
|
||||||
|
} else {
|
||||||
|
requestBody.max_tokens = normalizedMaxTokens
|
||||||
|
}
|
||||||
|
if (options.disableThinking) {
|
||||||
|
requestBody.thinking = { type: 'disabled' }
|
||||||
|
requestBody.enable_thinking = false
|
||||||
|
}
|
||||||
|
|
||||||
const options = {
|
const body = JSON.stringify(requestBody)
|
||||||
|
|
||||||
|
const requestOptions = {
|
||||||
hostname: urlObj.hostname,
|
hostname: urlObj.hostname,
|
||||||
port: urlObj.port || (urlObj.protocol === 'https:' ? 443 : 80),
|
port: urlObj.port || (urlObj.protocol === 'https:' ? 443 : 80),
|
||||||
path: urlObj.pathname + urlObj.search,
|
path: urlObj.pathname + urlObj.search,
|
||||||
@@ -205,7 +283,7 @@ function callApi(
|
|||||||
|
|
||||||
const isHttps = urlObj.protocol === 'https:'
|
const isHttps = urlObj.protocol === 'https:'
|
||||||
const requestFn = isHttps ? https.request : http.request
|
const requestFn = isHttps ? https.request : http.request
|
||||||
const req = requestFn(options, (res) => {
|
const req = requestFn(requestOptions, (res) => {
|
||||||
let data = ''
|
let data = ''
|
||||||
res.on('data', (chunk) => { data += chunk })
|
res.on('data', (chunk) => { data += chunk })
|
||||||
res.on('end', () => {
|
res.on('end', () => {
|
||||||
@@ -215,7 +293,13 @@ function callApi(
|
|||||||
if (typeof content === 'string' && content.trim()) {
|
if (typeof content === 'string' && content.trim()) {
|
||||||
resolve(content.trim())
|
resolve(content.trim())
|
||||||
} else {
|
} else {
|
||||||
reject(new Error(`API 返回格式异常: ${data.slice(0, 200)}`))
|
const finishReason = parsed?.choices?.[0]?.finish_reason
|
||||||
|
const reasoningContent = parsed?.choices?.[0]?.message?.reasoning_content
|
||||||
|
if (typeof reasoningContent === 'string' && reasoningContent.trim()) {
|
||||||
|
reject(new Error(`API 仅返回推理内容未返回正文${finishReason ? `(finish_reason=${finishReason})` : ''},请增大最大输出 Token 或关闭思考模式`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reject(new Error(`API 返回格式异常${finishReason ? `(finish_reason=${finishReason})` : ''}: ${data.slice(0, 200)}`))
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
reject(new Error(`JSON 解析失败: ${data.slice(0, 200)}`))
|
reject(new Error(`JSON 解析失败: ${data.slice(0, 200)}`))
|
||||||
@@ -523,6 +607,7 @@ class InsightService {
|
|||||||
const rangeLabel = String(params?.rangeLabel || '').trim() || '当前范围'
|
const rangeLabel = String(params?.rangeLabel || '').trim() || '当前范围'
|
||||||
const privateSegments = Array.isArray(params?.privateSegments) ? params.privateSegments.slice(0, 6) : []
|
const privateSegments = Array.isArray(params?.privateSegments) ? params.privateSegments.slice(0, 6) : []
|
||||||
const mentionGroups = Array.isArray(params?.mentionGroups) ? params.mentionGroups.slice(0, 6) : []
|
const mentionGroups = Array.isArray(params?.mentionGroups) ? params.mentionGroups.slice(0, 6) : []
|
||||||
|
const mimoMode = isMimoModel(apiBaseUrl, model)
|
||||||
|
|
||||||
const topPrivateText = privateSegments.length > 0
|
const topPrivateText = privateSegments.length > 0
|
||||||
? privateSegments
|
? privateSegments
|
||||||
@@ -546,20 +631,31 @@ class InsightService {
|
|||||||
.join('\n')
|
.join('\n')
|
||||||
: '无'
|
: '无'
|
||||||
|
|
||||||
const defaultSystemPrompt = `你是用户的聊天足迹教练,负责基于统计数据给出一段简明复盘。
|
|
||||||
要求:
|
|
||||||
1. 输出 2-3 句,总长度不超过 180 字。
|
|
||||||
2. 必须包含:总体观察 + 一个可执行建议。
|
|
||||||
3. 语气务实,不夸张,不使用 Markdown。`
|
|
||||||
const customPrompt = String(this.config.get('aiFootprintSystemPrompt') || '').trim()
|
const customPrompt = String(this.config.get('aiFootprintSystemPrompt') || '').trim()
|
||||||
const systemPrompt = customPrompt || defaultSystemPrompt
|
const systemPrompt = buildFootprintSystemPrompt(customPrompt)
|
||||||
|
|
||||||
const userPromptBase = `统计范围:${rangeLabel}
|
const inboundPeople = Number(summary.private_inbound_people) || 0
|
||||||
有聊天的人数:${Number(summary.private_inbound_people) || 0}
|
const repliedPeople = Number(summary.private_replied_people) || 0
|
||||||
我有回复的人数:${Number(summary.private_outbound_people) || 0}
|
const outboundPeople = Number(summary.private_outbound_people) || 0
|
||||||
回复率:${(((Number(summary.private_reply_rate) || 0) * 100)).toFixed(1)}%
|
const replyRate = (((Number(summary.private_reply_rate) || 0) * 100)).toFixed(1)
|
||||||
@我次数:${Number(summary.mention_count) || 0}
|
const mentionCount = Number(summary.mention_count) || 0
|
||||||
涉及群聊:${Number(summary.mention_group_count) || 0}
|
const mentionGroupCount = Number(summary.mention_group_count) || 0
|
||||||
|
|
||||||
|
const userPromptBase = `任务:基于下面的“我的微信足迹”统计生成最终总结正文。
|
||||||
|
|
||||||
|
输出要求再强调一次:
|
||||||
|
- 只输出 2 句中文自然语言,不要输出分析过程。
|
||||||
|
- 不要输出 JSON / Markdown / 列表 / 标题 / 代码块。
|
||||||
|
- 第 1 句做总体观察,第 2 句给一个可执行建议。
|
||||||
|
- 必须引用至少 2 个统计数字。
|
||||||
|
|
||||||
|
统计范围:${rangeLabel}
|
||||||
|
有聊天的人数:${inboundPeople}
|
||||||
|
我有回复的人数:${outboundPeople}
|
||||||
|
实际回复了其中:${repliedPeople}
|
||||||
|
回复率:${replyRate}%
|
||||||
|
@我次数:${mentionCount}
|
||||||
|
涉及群聊:${mentionGroupCount}
|
||||||
|
|
||||||
私聊重点:
|
私聊重点:
|
||||||
${topPrivateText}
|
${topPrivateText}
|
||||||
@@ -567,7 +663,7 @@ ${topPrivateText}
|
|||||||
群聊@我重点:
|
群聊@我重点:
|
||||||
${topMentionText}
|
${topMentionText}
|
||||||
|
|
||||||
请给出足迹复盘(2-3句,含建议):`
|
现在直接输出最终总结正文:`
|
||||||
const userPrompt = appendPromptCurrentTime(userPromptBase)
|
const userPrompt = appendPromptCurrentTime(userPromptBase)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -580,9 +676,14 @@ ${topMentionText}
|
|||||||
{ role: 'user', content: userPrompt }
|
{ role: 'user', content: userPrompt }
|
||||||
],
|
],
|
||||||
25_000,
|
25_000,
|
||||||
maxTokens
|
mimoMode ? Math.max(maxTokens, MIMO_FOOTPRINT_MIN_TOKENS) : maxTokens,
|
||||||
|
{
|
||||||
|
temperature: FOOTPRINT_API_TEMPERATURE,
|
||||||
|
disableThinking: mimoMode,
|
||||||
|
useMaxCompletionTokens: mimoMode
|
||||||
|
}
|
||||||
)
|
)
|
||||||
const insight = result.trim()
|
const insight = normalizeFootprintInsight(result)
|
||||||
if (!insight) return { success: false, message: '模型返回为空' }
|
if (!insight) return { success: false, message: '模型返回为空' }
|
||||||
return { success: true, message: '生成成功', insight }
|
return { success: true, message: '生成成功', insight }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1329,5 +1430,3 @@ ${topMentionText}
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const insightService = new InsightService()
|
export const insightService = new InsightService()
|
||||||
|
|
||||||
|
|
||||||
@@ -184,6 +184,31 @@
|
|||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
margin: 0 0 12px;
|
margin: 0 0 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chart-title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-note {
|
||||||
|
margin: -4px 0 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rankings
|
// Rankings
|
||||||
|
|||||||
+147
-3
@@ -37,7 +37,19 @@ function AnalyticsPage() {
|
|||||||
const [draftExcluded, setDraftExcluded] = useState<Set<string>>(new Set())
|
const [draftExcluded, setDraftExcluded] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
const themeMode = useThemeStore((state) => state.themeMode)
|
const themeMode = useThemeStore((state) => state.themeMode)
|
||||||
const { statistics, rankings, timeDistribution, isLoaded, setStatistics, setRankings, setTimeDistribution, markLoaded, clearCache } = useAnalyticsStore()
|
const {
|
||||||
|
statistics,
|
||||||
|
rankings,
|
||||||
|
timeDistribution,
|
||||||
|
selfSentDailyDistribution,
|
||||||
|
isLoaded,
|
||||||
|
setStatistics,
|
||||||
|
setRankings,
|
||||||
|
setTimeDistribution,
|
||||||
|
setSelfSentDailyDistribution,
|
||||||
|
markLoaded,
|
||||||
|
clearCache
|
||||||
|
} = useAnalyticsStore()
|
||||||
|
|
||||||
const loadExcludedUsernames = useCallback(async () => {
|
const loadExcludedUsernames = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -54,7 +66,14 @@ function AnalyticsPage() {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const loadData = useCallback(async (forceRefresh = false) => {
|
const loadData = useCallback(async (forceRefresh = false) => {
|
||||||
if (isLoaded && !forceRefresh) return
|
const currentAnalyticsState = useAnalyticsStore.getState()
|
||||||
|
if (
|
||||||
|
currentAnalyticsState.isLoaded &&
|
||||||
|
!forceRefresh &&
|
||||||
|
currentAnalyticsState.statistics &&
|
||||||
|
currentAnalyticsState.timeDistribution &&
|
||||||
|
currentAnalyticsState.selfSentDailyDistribution
|
||||||
|
) return
|
||||||
const taskId = registerBackgroundTask({
|
const taskId = registerBackgroundTask({
|
||||||
sourcePage: 'analytics',
|
sourcePage: 'analytics',
|
||||||
title: forceRefresh ? '刷新分析看板' : '加载分析看板',
|
title: forceRefresh ? '刷新分析看板' : '加载分析看板',
|
||||||
@@ -128,6 +147,22 @@ function AnalyticsPage() {
|
|||||||
if (timeResult.success && timeResult.data) {
|
if (timeResult.success && timeResult.data) {
|
||||||
setTimeDistribution(timeResult.data)
|
setTimeDistribution(timeResult.data)
|
||||||
}
|
}
|
||||||
|
setLoadingStatus('正在统计每日发送分布...')
|
||||||
|
updateBackgroundTask(taskId, {
|
||||||
|
detail: '正在统计每日发送分布',
|
||||||
|
progressText: '每日发送'
|
||||||
|
})
|
||||||
|
const selfSentDailyResult = await window.electronAPI.analytics.getSelfSentDailyDistribution(0, 0, forceRefresh)
|
||||||
|
if (isBackgroundTaskCancelRequested(taskId)) {
|
||||||
|
finishBackgroundTask(taskId, 'canceled', {
|
||||||
|
detail: '已停止后续加载,每日发送分布结果未继续写入'
|
||||||
|
})
|
||||||
|
setIsLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (selfSentDailyResult.success && selfSentDailyResult.data) {
|
||||||
|
setSelfSentDailyDistribution(selfSentDailyResult.data)
|
||||||
|
}
|
||||||
markLoaded()
|
markLoaded()
|
||||||
finishBackgroundTask(taskId, 'completed', {
|
finishBackgroundTask(taskId, 'completed', {
|
||||||
detail: '分析看板数据加载完成',
|
detail: '分析看板数据加载完成',
|
||||||
@@ -142,7 +177,7 @@ function AnalyticsPage() {
|
|||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
if (removeListener) removeListener()
|
if (removeListener) removeListener()
|
||||||
}
|
}
|
||||||
}, [isLoaded, markLoaded, setRankings, setStatistics, setTimeDistribution])
|
}, [markLoaded, setRankings, setSelfSentDailyDistribution, setStatistics, setTimeDistribution])
|
||||||
|
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
|
|
||||||
@@ -417,6 +452,105 @@ function AnalyticsPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getSelfSentDailyRatioData = () => {
|
||||||
|
const entries = Object.entries(selfSentDailyDistribution?.dailyDistribution || {})
|
||||||
|
.sort(([a], [b]) => a.localeCompare(b))
|
||||||
|
const days = entries.map(([day]) => day)
|
||||||
|
const counts = entries.map(([, count]) => count)
|
||||||
|
const totalDays = Math.max(days.length, 1)
|
||||||
|
const total = counts.reduce((sum, count) => sum + count, 0)
|
||||||
|
const baseline = total > 0 ? total / totalDays : 0
|
||||||
|
const ratios = counts.map((count) => baseline > 0 ? Number((count / baseline * 100).toFixed(1)) : 0)
|
||||||
|
const movingAverage = ratios.map((_, index) => {
|
||||||
|
const start = Math.max(0, index - 6)
|
||||||
|
const windowValues = ratios.slice(start, index + 1)
|
||||||
|
const sum = windowValues.reduce((total, value) => total + value, 0)
|
||||||
|
return Number((sum / windowValues.length).toFixed(1))
|
||||||
|
})
|
||||||
|
return { days, counts, ratios, movingAverage, baseline, total }
|
||||||
|
}
|
||||||
|
|
||||||
|
const getSelfSentDailyRatioOption = () => {
|
||||||
|
if (!selfSentDailyDistribution) return {}
|
||||||
|
const { days, counts, ratios, movingAverage, baseline } = getSelfSentDailyRatioData()
|
||||||
|
const showZoom = days.length > 31
|
||||||
|
|
||||||
|
const zoomStart = showZoom ? Math.max(0, 100 - Math.min(100, 31 / days.length * 100)) : 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'axis',
|
||||||
|
formatter: (params: any) => {
|
||||||
|
const items = Array.isArray(params) ? params : [params]
|
||||||
|
const first = items[0]
|
||||||
|
const index = Number(first?.dataIndex || 0)
|
||||||
|
const lines = [
|
||||||
|
`${first?.axisValue || ''}`,
|
||||||
|
`当日发送:${formatNumber(counts[index] || 0)} 条`,
|
||||||
|
`相对日均:${formatNumber(ratios[index] || 0)}%`,
|
||||||
|
`7日均线:${formatNumber(movingAverage[index] || 0)}%`,
|
||||||
|
`全期日均:${baseline.toFixed(1)} 条/天`
|
||||||
|
]
|
||||||
|
return lines.join('<br/>')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
legend: { data: ['单日比例', '7日均线'], top: 0 },
|
||||||
|
grid: { left: 56, right: 24, top: 42, bottom: showZoom ? 58 : 32 },
|
||||||
|
xAxis: {
|
||||||
|
type: 'category',
|
||||||
|
data: days,
|
||||||
|
axisLabel: {
|
||||||
|
hideOverlap: true,
|
||||||
|
formatter: (value: string) => value.slice(5)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: 'value',
|
||||||
|
name: '相对日均',
|
||||||
|
axisLabel: {
|
||||||
|
formatter: '{value}%'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dataZoom: showZoom ? [
|
||||||
|
{ type: 'inside', start: zoomStart, end: 100 },
|
||||||
|
{ type: 'slider', height: 18, bottom: 16, start: zoomStart, end: 100 }
|
||||||
|
] : undefined,
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: '单日比例',
|
||||||
|
type: 'bar',
|
||||||
|
data: ratios,
|
||||||
|
itemStyle: {
|
||||||
|
color: (params: any) => {
|
||||||
|
const value = Number(params?.value || 0)
|
||||||
|
if (value >= 200) return '#ff4d4f'
|
||||||
|
if (value >= 150) return '#faad14'
|
||||||
|
return '#07c160'
|
||||||
|
},
|
||||||
|
borderRadius: [4, 4, 0, 0]
|
||||||
|
},
|
||||||
|
markLine: {
|
||||||
|
symbol: 'none',
|
||||||
|
data: [{ yAxis: 100, name: '日均基线' }],
|
||||||
|
label: { formatter: '日均基线' },
|
||||||
|
lineStyle: { type: 'dashed', color: '#8c8c8c' }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '7日均线',
|
||||||
|
type: 'line',
|
||||||
|
data: movingAverage,
|
||||||
|
smooth: true,
|
||||||
|
showSymbol: false,
|
||||||
|
lineStyle: { width: 2, color: '#1989fa' },
|
||||||
|
itemStyle: { color: '#1989fa' }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const selfSentDailyRatioData = getSelfSentDailyRatioData()
|
||||||
|
|
||||||
const renderPageShell = (content: ReactNode) => (
|
const renderPageShell = (content: ReactNode) => (
|
||||||
<div className="analytics-page-shell">
|
<div className="analytics-page-shell">
|
||||||
<ChatAnalysisHeader currentMode="private" />
|
<ChatAnalysisHeader currentMode="private" />
|
||||||
@@ -521,6 +655,16 @@ function AnalyticsPage() {
|
|||||||
<div className="chart-card"><h3>消息类型分布</h3><ReactECharts option={getTypeChartOption()} style={{ height: 300 }} /></div>
|
<div className="chart-card"><h3>消息类型分布</h3><ReactECharts option={getTypeChartOption()} style={{ height: 300 }} /></div>
|
||||||
<div className="chart-card"><h3>发送/接收比例</h3><ReactECharts option={getSendReceiveOption()} style={{ height: 300 }} /></div>
|
<div className="chart-card"><h3>发送/接收比例</h3><ReactECharts option={getSendReceiveOption()} style={{ height: 300 }} /></div>
|
||||||
<div className="chart-card wide"><h3>每小时消息分布</h3><ReactECharts option={getHourlyOption()} style={{ height: 250 }} /></div>
|
<div className="chart-card wide"><h3>每小时消息分布</h3><ReactECharts option={getHourlyOption()} style={{ height: 250 }} /></div>
|
||||||
|
<div className="chart-card wide">
|
||||||
|
<div className="chart-title-row">
|
||||||
|
<h3>每日自身发送强度比例</h3>
|
||||||
|
<span>范围:全部 · 基线:{selfSentDailyRatioData.baseline.toFixed(1)} 条/天 · 共 {formatNumber(selfSentDailyDistribution?.totalMessages || 0)} 条</span>
|
||||||
|
</div>
|
||||||
|
<div className="chart-note">
|
||||||
|
比例 = 当日自身发送量 ÷ 全期每日平均自身发送量。超过 100% 表示高于本人基线
|
||||||
|
</div>
|
||||||
|
<ReactECharts option={getSelfSentDailyRatioOption()} style={{ height: 320 }} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section className="page-section">
|
<section className="page-section">
|
||||||
|
|||||||
@@ -32,11 +32,22 @@ interface TimeDistribution {
|
|||||||
monthlyDistribution: Record<string, number>
|
monthlyDistribution: Record<string, number>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SelfSentDailyDistribution {
|
||||||
|
unit: 'day'
|
||||||
|
dailyDistribution: Record<string, number>
|
||||||
|
totalMessages: number
|
||||||
|
firstMessageTime: number | null
|
||||||
|
lastMessageTime: number | null
|
||||||
|
beginTimestamp: number
|
||||||
|
endTimestamp: number
|
||||||
|
}
|
||||||
|
|
||||||
interface AnalyticsState {
|
interface AnalyticsState {
|
||||||
// 数据
|
// 数据
|
||||||
statistics: ChatStatistics | null
|
statistics: ChatStatistics | null
|
||||||
rankings: ContactRanking[]
|
rankings: ContactRanking[]
|
||||||
timeDistribution: TimeDistribution | null
|
timeDistribution: TimeDistribution | null
|
||||||
|
selfSentDailyDistribution: SelfSentDailyDistribution | null
|
||||||
|
|
||||||
// 状态
|
// 状态
|
||||||
isLoaded: boolean
|
isLoaded: boolean
|
||||||
@@ -46,6 +57,7 @@ interface AnalyticsState {
|
|||||||
setStatistics: (data: ChatStatistics) => void
|
setStatistics: (data: ChatStatistics) => void
|
||||||
setRankings: (data: ContactRanking[]) => void
|
setRankings: (data: ContactRanking[]) => void
|
||||||
setTimeDistribution: (data: TimeDistribution) => void
|
setTimeDistribution: (data: TimeDistribution) => void
|
||||||
|
setSelfSentDailyDistribution: (data: SelfSentDailyDistribution) => void
|
||||||
markLoaded: () => void
|
markLoaded: () => void
|
||||||
clearCache: () => void
|
clearCache: () => void
|
||||||
}
|
}
|
||||||
@@ -56,17 +68,20 @@ export const useAnalyticsStore = create<AnalyticsState>()(
|
|||||||
statistics: null,
|
statistics: null,
|
||||||
rankings: [],
|
rankings: [],
|
||||||
timeDistribution: null,
|
timeDistribution: null,
|
||||||
|
selfSentDailyDistribution: null,
|
||||||
isLoaded: false,
|
isLoaded: false,
|
||||||
lastLoadTime: null,
|
lastLoadTime: null,
|
||||||
|
|
||||||
setStatistics: (data) => set({ statistics: data }),
|
setStatistics: (data) => set({ statistics: data }),
|
||||||
setRankings: (data) => set({ rankings: data }),
|
setRankings: (data) => set({ rankings: data }),
|
||||||
setTimeDistribution: (data) => set({ timeDistribution: data }),
|
setTimeDistribution: (data) => set({ timeDistribution: data }),
|
||||||
|
setSelfSentDailyDistribution: (data) => set({ selfSentDailyDistribution: data }),
|
||||||
markLoaded: () => set({ isLoaded: true, lastLoadTime: Date.now() }),
|
markLoaded: () => set({ isLoaded: true, lastLoadTime: Date.now() }),
|
||||||
clearCache: () => set({
|
clearCache: () => set({
|
||||||
statistics: null,
|
statistics: null,
|
||||||
rankings: [],
|
rankings: [],
|
||||||
timeDistribution: null,
|
timeDistribution: null,
|
||||||
|
selfSentDailyDistribution: null,
|
||||||
isLoaded: false,
|
isLoaded: false,
|
||||||
lastLoadTime: null
|
lastLoadTime: null
|
||||||
}),
|
}),
|
||||||
|
|||||||
Vendored
+13
@@ -785,6 +785,19 @@ export interface ElectronAPI {
|
|||||||
}
|
}
|
||||||
error?: string
|
error?: string
|
||||||
}>
|
}>
|
||||||
|
getSelfSentDailyDistribution: (beginTimestamp?: number, endTimestamp?: number, force?: boolean) => Promise<{
|
||||||
|
success: boolean
|
||||||
|
data?: {
|
||||||
|
unit: 'day'
|
||||||
|
dailyDistribution: Record<string, number>
|
||||||
|
totalMessages: number
|
||||||
|
firstMessageTime: number | null
|
||||||
|
lastMessageTime: number | null
|
||||||
|
beginTimestamp: number
|
||||||
|
endTimestamp: number
|
||||||
|
}
|
||||||
|
error?: string
|
||||||
|
}>
|
||||||
getExcludedUsernames: () => Promise<{
|
getExcludedUsernames: () => Promise<{
|
||||||
success: boolean
|
success: boolean
|
||||||
data?: string[]
|
data?: string[]
|
||||||
|
|||||||
Reference in new issue
Block a user