merge: 同步 dev 并解决 AI 见解冲突

This commit is contained in:
clearyss committed 2026-05-29 14:59:38 +08:00
commit ba905a1a48
41 files changed
+6741 -403

No files matched your search

+37
View File
@@ -8,8 +8,10 @@ import {
Info,
Loader2,
Mic,
Newspaper,
RefreshCw,
Search,
Sparkles,
Users
} from 'lucide-react'
import { Avatar } from '../../components/Avatar'
@@ -21,9 +23,11 @@ export interface ChatHeaderProps {
isGroupChat: boolean
standaloneSessionWindow: boolean
showGroupMembersPanel: boolean
showGroupSummaryPanel: boolean
showJumpPopover: boolean
showInSessionSearch: boolean
showDetailPanel: boolean
aiGroupSummaryEnabled: boolean
shouldHideStandaloneDetailButton: boolean
isPrivateSnsSupported: boolean
isExportActionBusy: boolean
@@ -32,10 +36,13 @@ export interface ChatHeaderProps {
isBatchTranscribing: boolean
runningBatchVoiceTaskType?: BatchVoiceTaskType
isBatchDecrypting: boolean
isTriggeringSessionInsight: boolean
isRefreshingMessages: boolean
isLoadingMessages: boolean
currentSessionId?: string | null
jumpCalendarWrapRef: React.RefObject<HTMLDivElement | null>
onTriggerSessionInsight: () => void
onToggleGroupSummaryPanel: () => void
onGroupAnalytics: () => void
onToggleGroupMembersPanel: () => void
onExportCurrentSession: () => void
@@ -53,9 +60,11 @@ function ChatHeader({
isGroupChat,
standaloneSessionWindow,
showGroupMembersPanel,
showGroupSummaryPanel,
showJumpPopover,
showInSessionSearch,
showDetailPanel,
aiGroupSummaryEnabled,
shouldHideStandaloneDetailButton,
isPrivateSnsSupported,
isExportActionBusy,
@@ -64,10 +73,13 @@ function ChatHeader({
isBatchTranscribing,
runningBatchVoiceTaskType,
isBatchDecrypting,
isTriggeringSessionInsight,
isRefreshingMessages,
isLoadingMessages,
currentSessionId,
jumpCalendarWrapRef,
onTriggerSessionInsight,
onToggleGroupSummaryPanel,
onGroupAnalytics,
onToggleGroupMembersPanel,
onExportCurrentSession,
@@ -102,6 +114,26 @@ function ChatHeader({
{isGroupChat && <div className="header-subtitle">群聊</div>}
</div>
<div className="header-actions">
<button
className={`icon-btn session-insight-btn${isTriggeringSessionInsight ? ' triggering' : ''}`}
onClick={onTriggerSessionInsight}
disabled={!currentSessionId || isTriggeringSessionInsight}
title={isTriggeringSessionInsight ? '正在生成 AI 见解' : '立即触发当前聊天 AI 见解'}
aria-label="立即触发当前聊天 AI 见解"
>
{isTriggeringSessionInsight ? <Loader2 size={18} className="spin" /> : <Sparkles size={18} />}
</button>
{isGroupChat && aiGroupSummaryEnabled && (
<button
className={`icon-btn group-summary-btn ${showGroupSummaryPanel ? 'active' : ''}`}
onClick={onToggleGroupSummaryPanel}
disabled={!currentSessionId}
title="AI 群聊总结"
aria-label="AI 群聊总结"
>
<Newspaper size={18} />
</button>
)}
{!standaloneSessionWindow && isGroupChat && (
<button className="icon-btn group-analytics-btn" onClick={onGroupAnalytics} title="群聊分析">
<BarChart3 size={18} />
@@ -203,9 +235,11 @@ function areEqual(prev: ChatHeaderProps, next: ChatHeaderProps) {
prev.isGroupChat === next.isGroupChat &&
prev.standaloneSessionWindow === next.standaloneSessionWindow &&
prev.showGroupMembersPanel === next.showGroupMembersPanel &&
prev.showGroupSummaryPanel === next.showGroupSummaryPanel &&
prev.showJumpPopover === next.showJumpPopover &&
prev.showInSessionSearch === next.showInSessionSearch &&
prev.showDetailPanel === next.showDetailPanel &&
prev.aiGroupSummaryEnabled === next.aiGroupSummaryEnabled &&
prev.shouldHideStandaloneDetailButton === next.shouldHideStandaloneDetailButton &&
prev.isPrivateSnsSupported === next.isPrivateSnsSupported &&
prev.isExportActionBusy === next.isExportActionBusy &&
@@ -214,10 +248,13 @@ function areEqual(prev: ChatHeaderProps, next: ChatHeaderProps) {
prev.isBatchTranscribing === next.isBatchTranscribing &&
prev.runningBatchVoiceTaskType === next.runningBatchVoiceTaskType &&
prev.isBatchDecrypting === next.isBatchDecrypting &&
prev.isTriggeringSessionInsight === next.isTriggeringSessionInsight &&
prev.isRefreshingMessages === next.isRefreshingMessages &&
prev.isLoadingMessages === next.isLoadingMessages &&
prev.currentSessionId === next.currentSessionId &&
prev.jumpCalendarWrapRef === next.jumpCalendarWrapRef &&
prev.onTriggerSessionInsight === next.onTriggerSessionInsight &&
prev.onToggleGroupSummaryPanel === next.onToggleGroupSummaryPanel &&
prev.onGroupAnalytics === next.onGroupAnalytics &&
prev.onToggleGroupMembersPanel === next.onToggleGroupMembersPanel &&
prev.onExportCurrentSession === next.onExportCurrentSession &&
+13 -2
View File
@@ -24,6 +24,7 @@ export interface ChatMessageBubbleProps {
isSelected?: boolean
onContextMenu?: (event: React.MouseEvent, message: Message) => void
onToggleSelection?: (messageKey: string, isShiftKey?: boolean) => void
actionNode?: React.ReactNode
children: React.ReactNode
portal?: React.ReactNode
}
@@ -57,6 +58,7 @@ function ChatMessageBubble({
isSelected,
onContextMenu,
onToggleSelection,
actionNode,
children,
portal
}: ChatMessageBubbleProps) {
@@ -92,12 +94,20 @@ function ChatMessageBubble({
</div>
<div className="bubble-body">
{isGroupChat && !isSent && (
<div className="sender-name">
{resolvedSenderName || '群成员'}
<div className="sender-line">
<div className="sender-name">
{resolvedSenderName || '群成员'}
</div>
{actionNode}
</div>
)}
{children}
</div>
{!isGroupChat && !isSent && actionNode ? (
<div className="message-action-inline">
{actionNode}
</div>
) : null}
</div>
{isSelectionMode && isSent && <SelectionCheckbox checked={isSelected} side="right" />}
@@ -131,6 +141,7 @@ function areEqual(prev: ChatMessageBubbleProps, next: ChatMessageBubbleProps) {
prev.isSelected === next.isSelected &&
prev.onContextMenu === next.onContextMenu &&
prev.onToggleSelection === next.onToggleSelection &&
prev.actionNode === next.actionNode &&
prev.children === next.children &&
prev.portal === next.portal
)
+531
View File
@@ -1747,6 +1747,29 @@
}
}
.session-insight-hint {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 20px;
font-size: 12px;
color: var(--text-secondary);
border-bottom: 1px solid var(--border-color);
background: var(--bg-tertiary);
&.success {
color: var(--text-secondary);
}
&.error {
color: var(--danger);
}
.spin {
animation: spin 1s linear infinite;
}
}
.message-list {
flex: 1;
overflow-y: auto;
@@ -1922,6 +1945,10 @@
.message-wrapper.new-message {
animation: messagePop 0.35s ease-out;
.message-bubble:not(.system) .bubble-content {
box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary) 45%, transparent);
}
}
@keyframes messagePop {
@@ -3542,6 +3569,271 @@
}
}
.group-summary-panel {
width: clamp(320px, 30vw, 420px);
min-width: 320px;
max-width: 420px;
.group-summary-controls {
padding: 12px;
border-bottom: 1px solid var(--border-color);
display: flex;
flex-direction: column;
gap: 10px;
background: var(--bg-primary);
}
.group-summary-date-row {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 8px;
label {
font-size: 12px;
color: var(--text-secondary);
font-weight: 600;
}
input,
.group-summary-date-trigger {
height: 32px;
min-width: 0;
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--card-bg);
color: var(--text-primary);
padding: 0 9px;
font-size: 12px;
}
}
.group-summary-date-picker {
position: relative;
min-width: 0;
}
.group-summary-date-trigger {
width: 100%;
display: inline-flex;
align-items: center;
justify-content: space-between;
gap: 8px;
cursor: pointer;
transition: all 0.16s ease;
svg {
color: var(--text-secondary);
flex-shrink: 0;
}
&:hover,
&.open {
border-color: color-mix(in srgb, var(--primary) 36%, var(--border-color));
background: var(--bg-hover);
}
}
.group-summary-calendar-popover {
right: auto;
left: 0;
top: calc(100% + 8px);
width: min(312px, calc(100vw - 32px));
border-radius: 10px;
}
.group-summary-icon-btn,
.group-summary-code-btn {
width: 30px;
height: 30px;
border: 1px solid color-mix(in srgb, var(--border-color) 78%, transparent);
border-radius: 8px;
background: var(--card-bg);
color: var(--text-secondary);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.16s ease;
&:hover {
color: var(--primary);
background: var(--bg-hover);
border-color: color-mix(in srgb, var(--primary) 34%, var(--border-color));
}
.spin {
animation: spin 1s linear infinite;
}
}
.group-summary-range-tabs {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 6px;
button {
height: 30px;
border: 1px solid color-mix(in srgb, var(--border-color) 78%, transparent);
border-radius: 8px;
background: var(--card-bg);
color: var(--text-secondary);
font-size: 12px;
cursor: pointer;
transition: all 0.16s ease;
&.active {
color: var(--primary);
border-color: color-mix(in srgb, var(--primary) 42%, var(--border-color));
background: color-mix(in srgb, var(--primary) 10%, var(--card-bg));
font-weight: 600;
}
}
}
.group-summary-generate-btn {
height: 34px;
border: none;
border-radius: 8px;
background: var(--primary);
color: #fff;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: transform 0.16s ease, opacity 0.16s ease;
&:disabled {
opacity: 0.7;
cursor: not-allowed;
}
&:hover:not(:disabled) {
transform: translateY(-1px);
}
.spin {
animation: spin 1s linear infinite;
}
}
.group-summary-rule-hint,
.group-summary-count {
font-size: 12px;
color: var(--text-tertiary);
}
.group-summary-list {
flex: 1;
overflow-y: auto;
padding: 12px;
display: flex;
flex-direction: column;
gap: 10px;
}
.group-summary-record {
border: 1px solid color-mix(in srgb, var(--border-color) 74%, transparent);
background: var(--bg-secondary);
border-radius: 10px;
padding: 10px;
display: flex;
flex-direction: column;
gap: 10px;
}
.group-summary-record-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
}
.group-summary-period {
display: block;
color: var(--text-primary);
font-size: 12px;
font-weight: 600;
line-height: 1.35;
}
.group-summary-meta {
display: block;
margin-top: 3px;
color: var(--text-tertiary);
font-size: 12px;
}
.group-summary-topic-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.group-summary-topic {
border-radius: 8px;
border: 1px solid color-mix(in srgb, var(--border-color) 70%, transparent);
background: color-mix(in srgb, var(--card-bg) 88%, transparent);
padding: 9px;
h5 {
margin: 0 0 7px;
font-size: 13px;
color: var(--text-primary);
line-height: 1.35;
}
}
.group-summary-topic-row {
display: grid;
grid-template-columns: 72px 1fr;
gap: 8px;
padding: 5px 0;
border-top: 1px dashed color-mix(in srgb, var(--border-color) 72%, transparent);
span {
color: var(--text-tertiary);
font-size: 12px;
line-height: 1.45;
}
p {
margin: 0;
color: var(--text-secondary);
font-size: 12px;
line-height: 1.5;
word-break: break-word;
}
}
}
.group-summary-log-modal {
width: min(860px, calc(100vw - 32px));
max-height: min(760px, calc(100vh - 32px));
.detail-content {
max-height: calc(100vh - 120px);
}
}
.group-summary-log-pre {
margin: 0;
max-height: 260px;
overflow: auto;
padding: 10px;
border-radius: 8px;
background: color-mix(in srgb, var(--card-bg) 86%, transparent);
border: 1px solid color-mix(in srgb, var(--border-color) 72%, transparent);
color: var(--text-secondary);
font-size: 12px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
}
@keyframes detailCardEnter {
from {
opacity: 0;
@@ -5828,6 +6120,245 @@
margin-bottom: 5px;
}
.sender-line {
display: flex;
align-items: center;
gap: 8px;
max-width: 100%;
min-height: 18px;
margin-bottom: 5px;
.sender-name {
min-width: 0;
margin-bottom: 0;
}
}
.message-action-inline {
width: 24px;
height: 24px;
margin: 6px 0 0 4px;
display: flex;
align-items: center;
justify-content: flex-start;
flex-shrink: 0;
}
.message-insight-trigger {
height: 18px;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--text-tertiary);
display: inline-flex;
align-items: center;
gap: 4px;
padding: 0 5px;
font-size: 12px;
line-height: 18px;
opacity: 0;
transform: translateX(3px);
cursor: pointer;
transition: opacity 0.16s ease, color 0.16s ease, background 0.16s ease;
-webkit-app-region: no-drag;
svg {
flex-shrink: 0;
}
&:hover {
color: var(--primary);
background: color-mix(in srgb, var(--primary) 10%, transparent);
}
&.compact {
width: 24px;
height: 24px;
padding: 0;
justify-content: center;
border-radius: 50%;
color: var(--primary);
background: transparent;
transform: none;
svg {
width: 14px;
height: 14px;
}
}
}
.message-wrapper-with-selection:hover .message-insight-trigger,
.message-insight-trigger:focus-visible {
opacity: 0.78;
}
.message-wrapper-with-selection:hover .message-insight-trigger.compact,
.message-insight-trigger.compact:focus-visible {
opacity: 0.72;
}
.message-insight-trigger.compact:hover {
opacity: 1;
background: color-mix(in srgb, var(--primary) 10%, transparent);
}
.message-insight-trigger:focus-visible {
outline: 2px solid color-mix(in srgb, var(--primary) 42%, transparent);
outline-offset: 2px;
}
.message-insight-backdrop {
position: fixed;
inset: 0;
z-index: 4100;
border: 0;
background: transparent;
cursor: default;
}
.message-insight-card {
position: fixed;
z-index: 4101;
width: min(336px, calc(100vw - 16px));
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-secondary);
color: var(--text-primary);
box-shadow: 0 16px 42px rgba(0, 0, 0, 0.18);
overflow: hidden;
animation: messageInsightPop 0.14s ease-out;
-webkit-app-region: no-drag;
}
.message-insight-card-header {
height: 38px;
padding: 0 10px 0 12px;
display: flex;
align-items: center;
gap: 7px;
border-bottom: 1px solid var(--border-color);
background: var(--bg-tertiary);
font-size: 13px;
font-weight: 700;
svg {
color: var(--primary);
}
}
.message-insight-refresh {
margin-left: auto;
width: 26px;
height: 26px;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--text-secondary);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
&:hover:not(:disabled) {
color: var(--primary);
background: color-mix(in srgb, var(--primary) 10%, transparent);
}
&:disabled {
cursor: default;
opacity: 0.62;
}
}
.message-insight-card-body {
min-height: 132px;
padding: 13px 14px 14px;
}
.message-insight-loading,
.message-insight-error {
min-height: 104px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
color: var(--text-secondary);
font-size: 13px;
}
.message-insight-error {
flex-direction: column;
text-align: center;
button {
border: 1px solid var(--border-color);
border-radius: 6px;
background: var(--bg-primary);
color: var(--primary);
padding: 5px 10px;
cursor: pointer;
}
}
.message-insight-text {
margin: 0;
color: var(--text-primary);
font-size: 14px;
line-height: 1.62;
white-space: pre-wrap;
word-break: break-word;
}
.message-insight-divider {
height: 1px;
margin: 12px 0;
background: var(--border-color);
}
.message-insight-tags {
display: flex;
flex-wrap: wrap;
gap: 7px;
}
.message-insight-tag {
max-width: 100%;
border-radius: 6px;
background: var(--bg-tertiary);
color: var(--text-secondary);
padding: 4px 7px;
font-size: 12px;
line-height: 1.3;
word-break: break-word;
&.mood {
color: #8a5a00;
background: rgba(245, 158, 11, 0.13);
}
&.intent {
color: #225f5c;
background: rgba(91, 147, 144, 0.14);
}
}
.spin {
animation: spin 1s linear infinite;
}
@keyframes messageInsightPop {
from {
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
// Ambient Reply dark mode / alternate adjustments handled via CSS variables
.link-message,
+823 -4
View File
@@ -1,5 +1,5 @@
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, ChevronLeft, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon, Mic, CheckCircle, Copy, Check, CheckSquare, Download, BarChart3, Edit2, Trash2, BellOff, Users, FolderClosed, UserCheck, Crown, Aperture, Newspaper } from 'lucide-react'
import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, ChevronLeft, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon, Mic, CheckCircle, Copy, Check, CheckSquare, Download, BarChart3, Edit2, Trash2, BellOff, Users, FolderClosed, UserCheck, Crown, Aperture, Newspaper, Star, Sparkles, Code2 } from 'lucide-react'
import { useNavigate, useLocation } from 'react-router-dom'
import { createPortal } from 'react-dom'
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'
@@ -8,6 +8,7 @@ import { useChatStore } from '../stores/chatStore'
import { useBatchTranscribeStore, type BatchVoiceTaskType } from '../stores/batchTranscribeStore'
import { useBatchImageDecryptStore } from '../stores/batchImageDecryptStore'
import type { ChatRecordItem, ChatSession, Message } from '../types/models'
import type { GroupSummaryRecord, GroupSummaryRecordSummary } from '../types/electron'
import { getEmojiPath } from 'wechat-emojis'
import { VoiceTranscribeDialog } from '../components/VoiceTranscribeDialog'
import { LivePhotoIcon } from '../components/LivePhotoIcon'
@@ -68,6 +69,7 @@ interface QuotedMessageJumpTarget {
type GlobalMsgSearchPhase = 'idle' | 'seed' | 'backfill' | 'done'
type GlobalMsgSearchResult = Message & { sessionId: string }
type GroupSummaryRangeMode = 1 | 2 | 4 | 8 | 12 | 24
interface GlobalMsgPrefixCacheEntry {
keyword: string
@@ -762,6 +764,17 @@ function formatYmdHmDateTime(timestamp?: number): string {
return `${y}-${m}-${day} ${h}:${min}`
}
function formatDateInputLocal(date: Date): string {
const y = date.getFullYear()
const m = `${date.getMonth() + 1}`.padStart(2, '0')
const day = `${date.getDate()}`.padStart(2, '0')
return `${y}-${m}-${day}`
}
function formatSummaryPeriod(start: number, end: number): string {
return `${formatYmdHmDateTime(start * 1000)} - ${formatYmdHmDateTime(end * 1000)}`
}
interface ChatPageProps {
standaloneSessionWindow?: boolean
initialSessionId?: string | null
@@ -1476,6 +1489,7 @@ function ChatPage(props: ChatPageProps) {
const sessionListRef = useRef<HTMLDivElement>(null)
const jumpCalendarWrapRef = useRef<HTMLDivElement>(null)
const jumpPopoverPortalRef = useRef<HTMLDivElement>(null)
const groupSummaryDateWrapRef = useRef<HTMLDivElement>(null)
const [currentOffset, setCurrentOffset] = useState(0)
const [jumpStartTime, setJumpStartTime] = useState(0)
const [jumpEndTime, setJumpEndTime] = useState(0)
@@ -1537,6 +1551,18 @@ function ChatPage(props: ChatPageProps) {
const [contextMenu, setContextMenu] = useState<{ x: number, y: number, message: Message } | null>(null)
const [showMessageInfo, setShowMessageInfo] = useState<Message | null>(null)
const [editingMessage, setEditingMessage] = useState<{ message: Message, content: string } | null>(null)
const [aiGroupSummaryEnabled, setAiGroupSummaryEnabled] = useState(false)
const [showGroupSummaryPanel, setShowGroupSummaryPanel] = useState(false)
const [groupSummaryRecords, setGroupSummaryRecords] = useState<GroupSummaryRecordSummary[]>([])
const [groupSummaryTotal, setGroupSummaryTotal] = useState(0)
const [groupSummaryLoading, setGroupSummaryLoading] = useState(false)
const [groupSummaryError, setGroupSummaryError] = useState<string | null>(null)
const [groupSummaryDateFilter, setGroupSummaryDateFilter] = useState(() => formatDateInputLocal(new Date()))
const [groupSummaryRangeMode, setGroupSummaryRangeMode] = useState<GroupSummaryRangeMode>(4)
const [showGroupSummaryDatePopover, setShowGroupSummaryDatePopover] = useState(false)
const [isTriggeringGroupSummary, setIsTriggeringGroupSummary] = useState(false)
const [groupSummaryHint, setGroupSummaryHint] = useState<{ success: boolean; message: string } | null>(null)
const [groupSummaryLogRecord, setGroupSummaryLogRecord] = useState<GroupSummaryRecord | null>(null)
// 多选模式
const [isSelectionMode, setIsSelectionMode] = useState(false)
@@ -1637,6 +1663,11 @@ function ChatPage(props: ChatPageProps) {
const highlightedMessageSet = useMemo(() => new Set(highlightedMessageKeys), [highlightedMessageKeys])
const [aiMessageInsightEnabled, setAiMessageInsightEnabled] = useState(false)
const [aiMessageInsightContextCount, setAiMessageInsightContextCount] = useState(50)
const [isTriggeringSessionInsight, setIsTriggeringSessionInsight] = useState(false)
const [sessionInsightHint, setSessionInsightHint] = useState<{ success: boolean; message: string } | null>(null)
const sessionInsightHintTimerRef = useRef<number | null>(null)
const messageKeySetRef = useRef<Set<string>>(new Set())
const lastMessageTimeRef = useRef(0)
const isMessageListAtBottomRef = useRef(true)
@@ -1856,6 +1887,130 @@ function ChatPage(props: ChatPageProps) {
})
}, [])
const getGroupSummaryDateRangeSeconds = useCallback((dateValue = groupSummaryDateFilter) => {
const date = dateValue || formatDateInputLocal(new Date())
const start = new Date(`${date}T00:00:00`)
if (!Number.isFinite(start.getTime())) {
const fallback = new Date()
fallback.setHours(0, 0, 0, 0)
const fallbackEnd = new Date(fallback)
fallbackEnd.setHours(23, 59, 59, 999)
return { startTime: Math.floor(fallback.getTime() / 1000), endTime: Math.floor(fallbackEnd.getTime() / 1000) }
}
const end = new Date(start)
end.setHours(23, 59, 59, 999)
return { startTime: Math.floor(start.getTime() / 1000), endTime: Math.floor(end.getTime() / 1000) }
}, [groupSummaryDateFilter])
const isGroupSummaryToday = useMemo(() => {
return (groupSummaryDateFilter || formatDateInputLocal(new Date())) === formatDateInputLocal(new Date())
}, [groupSummaryDateFilter])
const loadGroupSummaryRecords = useCallback(async (sessionId?: string) => {
const targetSessionId = String(sessionId || currentSessionRef.current || '').trim()
if (!targetSessionId || !targetSessionId.endsWith('@chatroom')) return
const { startTime, endTime } = getGroupSummaryDateRangeSeconds()
setGroupSummaryLoading(true)
setGroupSummaryError(null)
try {
const result = await window.electronAPI.groupSummary.listRecords({
sessionId: targetSessionId,
startTime,
endTime,
limit: 100
})
if (currentSessionRef.current !== targetSessionId) return
if (!result.success) {
setGroupSummaryRecords([])
setGroupSummaryTotal(0)
setGroupSummaryError(result.error || '读取群聊总结失败')
return
}
setGroupSummaryRecords(result.records || [])
setGroupSummaryTotal(result.total || 0)
} catch (error) {
if (currentSessionRef.current !== targetSessionId) return
setGroupSummaryRecords([])
setGroupSummaryTotal(0)
setGroupSummaryError((error as Error).message || String(error))
} finally {
if (currentSessionRef.current === targetSessionId) {
setGroupSummaryLoading(false)
}
}
}, [getGroupSummaryDateRangeSeconds])
const resolveTodayGroupSummaryManualRange = useCallback(() => {
const nowSeconds = Math.floor(Date.now() / 1000)
const hours = Number(groupSummaryRangeMode)
return { startTime: nowSeconds - hours * 60 * 60, endTime: nowSeconds }
}, [groupSummaryRangeMode])
const triggerManualGroupSummary = useCallback(async () => {
const sessionId = String(currentSessionId || '').trim()
if (!sessionId || !sessionId.endsWith('@chatroom')) return
const sessionInfo = sessionMapRef.current.get(sessionId)
const selectedDate = groupSummaryDateFilter || formatDateInputLocal(new Date())
const today = formatDateInputLocal(new Date())
setIsTriggeringGroupSummary(true)
setGroupSummaryHint({ success: true, message: '正在生成群聊总结...' })
try {
if (selectedDate === today) {
const { startTime, endTime } = resolveTodayGroupSummaryManualRange()
if (startTime <= 0 || endTime <= startTime) {
setGroupSummaryHint({ success: false, message: '请选择有效的总结时段' })
return
}
const result = await window.electronAPI.groupSummary.triggerManual({
sessionId,
displayName: sessionInfo?.displayName || sessionId,
avatarUrl: sessionInfo?.avatarUrl,
startTime,
endTime
})
if (result.success) {
setGroupSummaryHint({ success: true, message: result.message || '群聊总结已生成' })
if (!result.skipped) {
await loadGroupSummaryRecords(sessionId)
}
} else {
setGroupSummaryHint({ success: false, message: result.message || '群聊总结生成失败' })
}
} else {
const result = await window.electronAPI.groupSummary.triggerDay({
sessionId,
displayName: sessionInfo?.displayName || sessionId,
avatarUrl: sessionInfo?.avatarUrl,
date: selectedDate
})
if (result.success) {
setGroupSummaryHint({ success: true, message: result.message || '群聊总结已生成' })
await loadGroupSummaryRecords(sessionId)
} else {
setGroupSummaryHint({ success: false, message: result.message || '群聊总结生成失败' })
}
}
} catch (error) {
setGroupSummaryHint({ success: false, message: (error as Error).message || String(error) })
} finally {
setIsTriggeringGroupSummary(false)
}
}, [currentSessionId, groupSummaryDateFilter, loadGroupSummaryRecords, resolveTodayGroupSummaryManualRange])
const openGroupSummaryLog = useCallback(async (recordId: string) => {
try {
const result = await window.electronAPI.groupSummary.getRecord(recordId)
if (!result.success || !result.record) {
setGroupSummaryHint({ success: false, message: result.error || '读取总结日志失败' })
return
}
setGroupSummaryLogRecord(result.record)
} catch (error) {
setGroupSummaryHint({ success: false, message: (error as Error).message || String(error) })
}
}, [])
const handleToggleJumpPopover = useCallback(() => {
if (!currentSessionId) return
if (showJumpPopover) {
@@ -2898,9 +3053,21 @@ function ChatPage(props: ChatPageProps) {
return
}
setShowDetailPanel(false)
setShowGroupSummaryPanel(false)
setShowGroupMembersPanel(true)
}, [currentSessionId, showGroupMembersPanel, isGroupChatSession])
const toggleGroupSummaryPanel = useCallback(() => {
if (!currentSessionId || !isGroupChatSession(currentSessionId) || !aiGroupSummaryEnabled) return
if (showGroupSummaryPanel) {
setShowGroupSummaryPanel(false)
return
}
setShowDetailPanel(false)
setShowGroupMembersPanel(false)
setShowGroupSummaryPanel(true)
}, [aiGroupSummaryEnabled, currentSessionId, showGroupSummaryPanel, isGroupChatSession])
// 切换详情面板
const toggleDetailPanel = useCallback(() => {
if (showDetailPanel) {
@@ -2908,6 +3075,7 @@ function ChatPage(props: ChatPageProps) {
return
}
setShowGroupMembersPanel(false)
setShowGroupSummaryPanel(false)
setShowDetailPanel(true)
if (currentSessionId) {
void loadSessionDetail(currentSessionId)
@@ -2924,6 +3092,15 @@ function ChatPage(props: ChatPageProps) {
void loadGroupMembersPanel(currentSessionId)
}, [showGroupMembersPanel, currentSessionId, loadGroupMembersPanel, isGroupChatSession])
useEffect(() => {
if (!showGroupSummaryPanel) return
if (!currentSessionId || !isGroupChatSession(currentSessionId) || !aiGroupSummaryEnabled) {
setShowGroupSummaryPanel(false)
return
}
void loadGroupSummaryRecords(currentSessionId)
}, [aiGroupSummaryEnabled, currentSessionId, groupSummaryDateFilter, loadGroupSummaryRecords, showGroupSummaryPanel, isGroupChatSession])
useEffect(() => {
const chatroomId = String(sessionDetail?.wxid || '').trim()
if (!chatroomId || !chatroomId.includes('@chatroom')) return
@@ -3005,6 +3182,10 @@ function ChatPage(props: ChatPageProps) {
setIsLoadingRelationStats(false)
setShowDetailPanel(false)
setShowGroupMembersPanel(false)
setShowGroupSummaryPanel(false)
setGroupSummaryRecords([])
setGroupSummaryError(null)
setGroupSummaryHint(null)
setGroupPanelMembers([])
setGroupMembersError(null)
setGroupMembersLoadingHint('')
@@ -3095,6 +3276,36 @@ function ChatPage(props: ChatPageProps) {
}
}, [])
useEffect(() => {
let canceled = false
const loadMessageInsightConfig = () => {
void Promise.all([
configService.getAiMessageInsightEnabled(),
configService.getAiMessageInsightContextCount()
])
.then(([enabled, contextCount]) => {
if (canceled) return
setAiMessageInsightEnabled(enabled)
setAiMessageInsightContextCount(contextCount)
})
.catch((error) => {
console.warn('加载消息解析配置失败:', error)
if (canceled) return
setAiMessageInsightEnabled(false)
setAiMessageInsightContextCount(50)
})
}
loadMessageInsightConfig()
const handleFocus = () => loadMessageInsightConfig()
window.addEventListener('focus', handleFocus)
return () => {
canceled = true
window.removeEventListener('focus', handleFocus)
}
}, [])
useEffect(() => {
let cancelled = false
void (async () => {
@@ -3111,6 +3322,13 @@ function ChatPage(props: ChatPageProps) {
// 同步 currentSessionId 到 ref
useEffect(() => {
currentSessionRef.current = currentSessionId
messageInsightMemoryCache.clear()
setSessionInsightHint(null)
setIsTriggeringSessionInsight(false)
if (sessionInsightHintTimerRef.current !== null) {
window.clearTimeout(sessionInsightHintTimerRef.current)
sessionInsightHintTimerRef.current = null
}
isMessageListAtBottomRef.current = true
topRangeLoadLockRef.current = false
bottomRangeLoadLockRef.current = false
@@ -4363,6 +4581,9 @@ function ChatPage(props: ChatPageProps) {
setShowJumpPopover(false)
setShowDetailPanel(false)
setShowGroupMembersPanel(false)
setShowGroupSummaryPanel(false)
setGroupSummaryError(null)
setGroupSummaryHint(null)
setGroupMemberSearchKeyword('')
setGroupMembersError(null)
setGroupMembersLoadingHint('')
@@ -5470,6 +5691,20 @@ function ChatPage(props: ChatPageProps) {
}
}, [showJumpPopover])
useEffect(() => {
if (!showGroupSummaryDatePopover) return
const handleGlobalPointerDown = (event: MouseEvent) => {
const target = event.target as Node | null
if (!target) return
if (groupSummaryDateWrapRef.current?.contains(target)) return
setShowGroupSummaryDatePopover(false)
}
document.addEventListener('mousedown', handleGlobalPointerDown)
return () => {
document.removeEventListener('mousedown', handleGlobalPointerDown)
}
}, [showGroupSummaryDatePopover])
useEffect(() => {
if (!showJumpPopover) return
const syncPosition = () => {
@@ -5807,6 +6042,50 @@ function ChatPage(props: ChatPageProps) {
})
}, [currentSession, isCurrentSessionPrivateSnsSupported])
const showSessionInsightHint = useCallback((hint: { success: boolean; message: string }) => {
if (sessionInsightHintTimerRef.current !== null) {
window.clearTimeout(sessionInsightHintTimerRef.current)
sessionInsightHintTimerRef.current = null
}
setSessionInsightHint(hint)
sessionInsightHintTimerRef.current = window.setTimeout(() => {
setSessionInsightHint(null)
sessionInsightHintTimerRef.current = null
}, 5000)
}, [])
useEffect(() => {
return () => {
if (sessionInsightHintTimerRef.current !== null) {
window.clearTimeout(sessionInsightHintTimerRef.current)
sessionInsightHintTimerRef.current = null
}
}
}, [])
useEffect(() => {
let canceled = false
const loadGroupSummaryConfig = () => {
void configService.getAiGroupSummaryEnabled()
.then((enabled) => {
if (!canceled) setAiGroupSummaryEnabled(enabled)
})
.catch((error) => {
console.warn('加载群聊总结配置失败:', error)
if (!canceled) setAiGroupSummaryEnabled(false)
})
}
loadGroupSummaryConfig()
const handleFocus = () => loadGroupSummaryConfig()
window.addEventListener('focus', handleFocus)
return () => {
canceled = true
window.removeEventListener('focus', handleFocus)
}
}, [])
useEffect(() => {
if (!standaloneSessionWindow) return
setStandaloneInitialLoadRequested(false)
@@ -5868,7 +6147,7 @@ function ChatPage(props: ChatPageProps) {
selectSessionById
])
// 监听 URL 参数中的会话/锚点(通知跳转 + 足迹锚点定位)
// 监听 URL 参数中的会话/锚点(通知跳转 + 足迹/深度解析锚点定位)
useEffect(() => {
if (standaloneSessionWindow) return // standalone模式由上面的useEffect处理
const params = new URLSearchParams(location.search)
@@ -5883,6 +6162,11 @@ function ChatPage(props: ChatPageProps) {
&& jumpLocalId > 0
&& Number.isFinite(jumpCreateTime)
&& jumpCreateTime > 0
const hasMessageAnalysisAnchor = jumpSource === 'messageAnalysis'
&& Number.isFinite(jumpLocalId)
&& jumpLocalId > 0
&& Number.isFinite(jumpCreateTime)
&& jumpCreateTime > 0
if (hasFootprintAnchor) {
pendingFootprintJumpRef.current = {
@@ -5912,6 +6196,34 @@ function ChatPage(props: ChatPageProps) {
return
}
if (hasMessageAnalysisAnchor) {
pendingFootprintJumpRef.current = {
sessionId: urlSessionId,
localId: jumpLocalId,
createTime: jumpCreateTime
}
if (currentSessionId !== urlSessionId) {
selectSessionById(urlSessionId)
return
}
const messageStub: Message = {
messageKey: `footprint:${urlSessionId}:${jumpCreateTime}:${jumpLocalId}`,
localId: jumpLocalId,
serverId: 0,
localType: 0,
createTime: jumpCreateTime,
sortSeq: jumpCreateTime,
isSend: null,
senderUsername: null,
parsedContent: '',
rawContent: ''
}
handleInSessionResultJump(messageStub)
pendingFootprintJumpRef.current = null
navigate('/chat', { replace: true })
return
}
pendingFootprintJumpRef.current = null
if (currentSessionId !== urlSessionId) {
selectSessionById(urlSessionId)
@@ -6115,6 +6427,41 @@ function ChatPage(props: ChatPageProps) {
})
}, [currentSession, currentSessionId, inProgressExportSessionIds, isPreparingExportDialog])
const handleTriggerSessionInsight = useCallback(async () => {
const session = currentSession
const sessionId = String(session?.username || currentSessionId || '').trim()
if (!sessionId || isTriggeringSessionInsight) return
setIsTriggeringSessionInsight(true)
if (sessionInsightHintTimerRef.current !== null) {
window.clearTimeout(sessionInsightHintTimerRef.current)
sessionInsightHintTimerRef.current = null
}
setSessionInsightHint({ success: true, message: '正在生成当前聊天的 AI 见解...' })
try {
const result = await window.electronAPI.insight.triggerSessionInsight({
sessionId,
displayName: session?.displayName || sessionId,
avatarUrl: session?.avatarUrl
})
if (currentSessionRef.current !== sessionId) return
showSessionInsightHint({
success: result.success,
message: result.message || (result.success ? 'AI 见解已生成' : 'AI 见解生成失败')
})
} catch (error) {
if (currentSessionRef.current !== sessionId) return
showSessionInsightHint({
success: false,
message: `触发失败:${(error as Error).message || String(error)}`
})
} finally {
if (currentSessionRef.current === sessionId) {
setIsTriggeringSessionInsight(false)
}
}
}, [currentSession, currentSessionId, isTriggeringSessionInsight, showSessionInsightHint])
const handleGroupAnalytics = useCallback(() => {
if (!currentSessionId || !isGroupChatSession(currentSessionId)) return
navigate('/analytics/group', {
@@ -6887,6 +7234,8 @@ function ChatPage(props: ChatPageProps) {
messageKey={messageKey}
isSelected={selectedMessages.has(messageKey)}
onToggleSelection={handleToggleSelection}
aiMessageInsightEnabled={aiMessageInsightEnabled}
aiMessageInsightContextCount={aiMessageInsightContextCount}
/>
</div>
)
@@ -6906,7 +7255,9 @@ function ChatPage(props: ChatPageProps) {
handleJumpToQuotedMessage,
isSelectionMode,
selectedMessages,
handleToggleSelection
handleToggleSelection,
aiMessageInsightEnabled,
aiMessageInsightContextCount
])
return (
@@ -7240,9 +7591,11 @@ function ChatPage(props: ChatPageProps) {
isGroupChat={isCurrentSessionGroup}
standaloneSessionWindow={standaloneSessionWindow}
showGroupMembersPanel={showGroupMembersPanel}
showGroupSummaryPanel={showGroupSummaryPanel}
showJumpPopover={showJumpPopover}
showInSessionSearch={showInSessionSearch}
showDetailPanel={showDetailPanel}
aiGroupSummaryEnabled={aiGroupSummaryEnabled}
shouldHideStandaloneDetailButton={shouldHideStandaloneDetailButton}
isPrivateSnsSupported={isCurrentSessionPrivateSnsSupported}
isExportActionBusy={isExportActionBusy}
@@ -7251,10 +7604,13 @@ function ChatPage(props: ChatPageProps) {
isBatchTranscribing={isBatchTranscribing}
runningBatchVoiceTaskType={runningBatchVoiceTaskType}
isBatchDecrypting={isBatchDecrypting}
isTriggeringSessionInsight={isTriggeringSessionInsight}
isRefreshingMessages={isRefreshingMessages}
isLoadingMessages={isLoadingMessages}
currentSessionId={currentSessionId}
jumpCalendarWrapRef={jumpCalendarWrapRef}
onTriggerSessionInsight={handleTriggerSessionInsight}
onToggleGroupSummaryPanel={toggleGroupSummaryPanel}
onGroupAnalytics={handleGroupAnalytics}
onToggleGroupMembersPanel={toggleGroupMembersPanel}
onExportCurrentSession={handleExportCurrentSession}
@@ -7299,6 +7655,20 @@ function ChatPage(props: ChatPageProps) {
</div>
)}
{sessionInsightHint && (
<div className={`session-insight-hint ${sessionInsightHint.success ? 'success' : 'error'}`} role="status" aria-live="polite">
{isTriggeringSessionInsight ? <Loader2 size={14} className="spin" /> : <Sparkles size={14} />}
<span>{sessionInsightHint.message}</span>
</div>
)}
{groupSummaryHint && (
<div className={`session-insight-hint ${groupSummaryHint.success ? 'success' : 'error'}`} role="status" aria-live="polite">
{isTriggeringGroupSummary ? <Loader2 size={14} className="spin" /> : <Newspaper size={14} />}
<span>{groupSummaryHint.message}</span>
</div>
)}
<ContactSnsTimelineDialog
target={chatSnsTimelineTarget}
onClose={() => setChatSnsTimelineTarget(null)}
@@ -7539,6 +7909,138 @@ function ChatPage(props: ChatPageProps) {
</div>
)}
{showGroupSummaryPanel && isCurrentSessionGroup && (
<div className="detail-panel group-summary-panel">
<div className="detail-header">
<div className="detail-title-wrap">
<h4>AI 群聊总结</h4>
<span className="detail-title-sub">{currentSession?.displayName || currentSessionId}</span>
</div>
<button className="close-btn" onClick={() => setShowGroupSummaryPanel(false)}>
<X size={16} />
</button>
</div>
<div className="group-summary-controls">
<div className="group-summary-date-row">
<label>日期</label>
<div className="group-summary-date-picker" ref={groupSummaryDateWrapRef}>
<button
type="button"
className={`group-summary-date-trigger ${showGroupSummaryDatePopover ? 'open' : ''}`}
onClick={() => setShowGroupSummaryDatePopover((open) => !open)}
>
<span>{groupSummaryDateFilter}</span>
<Calendar size={14} />
</button>
<JumpToDatePopover
isOpen={showGroupSummaryDatePopover}
onClose={() => setShowGroupSummaryDatePopover(false)}
currentDate={new Date(`${groupSummaryDateFilter || formatDateInputLocal(new Date())}T00:00:00`)}
onSelect={(date) => setGroupSummaryDateFilter(formatDateInputLocal(date))}
className="group-summary-calendar-popover"
maxDate={new Date()}
/>
</div>
<button
type="button"
className="group-summary-icon-btn"
onClick={() => void loadGroupSummaryRecords(currentSessionId || undefined)}
title="刷新总结"
>
<RefreshCw size={14} className={groupSummaryLoading ? 'spin' : ''} />
</button>
</div>
{isGroupSummaryToday ? (
<div className="group-summary-range-tabs">
{([1, 2, 4, 8, 12, 24] as const).map((hours) => (
<button
key={hours}
type="button"
className={groupSummaryRangeMode === hours ? 'active' : ''}
onClick={() => setGroupSummaryRangeMode(hours)}
>
{hours}h
</button>
))}
</div>
) : (
<div className="group-summary-rule-hint">将按设置里的自动总结间隔切分选中日期的完整聊天记录。</div>
)}
<button
type="button"
className="group-summary-generate-btn"
onClick={() => void triggerManualGroupSummary()}
disabled={isTriggeringGroupSummary}
>
{isTriggeringGroupSummary ? <Loader2 size={14} className="spin" /> : <Sparkles size={14} />}
<span>生成总结</span>
</button>
<div className="group-summary-rule-hint">
{isGroupSummaryToday ? '少于 5 条可总结消息会自动跳过。' : '每个切片少于 5 条可总结消息会自动跳过。'}
</div>
</div>
<div className="group-summary-list">
{groupSummaryLoading ? (
<div className="detail-loading">
<Loader2 size={20} className="spin" />
<span>加载总结中...</span>
</div>
) : groupSummaryError ? (
<div className="detail-empty">{groupSummaryError}</div>
) : groupSummaryRecords.length === 0 ? (
<div className="detail-empty">当前日期暂无群聊总结</div>
) : (
<>
<div className="group-summary-count">共 {groupSummaryTotal} 条总结</div>
{groupSummaryRecords.map((record) => (
<div key={record.id} className="group-summary-record">
<div className="group-summary-record-head">
<div>
<span className="group-summary-period">{formatSummaryPeriod(record.periodStart, record.periodEnd)}</span>
<span className="group-summary-meta">
{record.triggerType === 'manual' ? '手动' : '自动'} · {record.readableMessageCount} 条消息
</span>
</div>
<button
type="button"
className="group-summary-code-btn"
onClick={() => void openGroupSummaryLog(record.id)}
title="查看完整日志"
>
<Code2 size={14} />
</button>
</div>
<div className="group-summary-topic-list">
{record.topics.map((topic, topicIndex) => (
<div key={`${record.id}-${topicIndex}`} className="group-summary-topic">
<h5>{topic.title}</h5>
<div className="group-summary-topic-row">
<span>参与者</span>
<p>{topic.participants.length > 0 ? topic.participants.join('、') : '未明确'}</p>
</div>
<div className="group-summary-topic-row">
<span>关键/矛盾点</span>
<p>{topic.keyPoints.length > 0 ? topic.keyPoints.join(';') : '无'}</p>
</div>
<div className="group-summary-topic-row">
<span>结论</span>
<p>{topic.conclusion || '暂无明确结论'}</p>
</div>
</div>
))}
</div>
</div>
))}
</>
)}
</div>
</div>
)}
{/* 会话详情面板 */}
{showDetailPanel && (
<div className="detail-panel session-detail-panel">
@@ -8195,6 +8697,79 @@ function ChatPage(props: ChatPageProps) {
document.body
)}
{groupSummaryLogRecord && createPortal(
<div className="message-info-overlay" onClick={() => setGroupSummaryLogRecord(null)}>
<div className="message-info-modal group-summary-log-modal" onClick={(e) => e.stopPropagation()}>
<div className="detail-header">
<h4>群聊总结日志</h4>
<button className="close-btn" onClick={() => setGroupSummaryLogRecord(null)}>
<X size={16} />
</button>
</div>
<div className="detail-content">
<div className="detail-section">
<div className="detail-item">
<span className="label">群聊</span>
<span className="value">{groupSummaryLogRecord.displayName}</span>
</div>
<div className="detail-item">
<span className="label">时段</span>
<span className="value">{formatSummaryPeriod(groupSummaryLogRecord.periodStart, groupSummaryLogRecord.periodEnd)}</span>
</div>
<div className="detail-item">
<span className="label">触发</span>
<span className="value">{groupSummaryLogRecord.triggerType === 'manual' ? '手动' : '自动'}</span>
</div>
<div className="detail-item">
<span className="label">模型</span>
<span className="value">{groupSummaryLogRecord.log.model}</span>
</div>
<div className="detail-item">
<span className="label">消息数</span>
<span className="value">{groupSummaryLogRecord.log.readableMessageCount} / {groupSummaryLogRecord.log.messageCount}</span>
</div>
<div className="detail-item">
<span className="label">JSON Mode</span>
<span className="value">
{groupSummaryLogRecord.log.responseFormatJson ? '启用' : '未启用'}
{groupSummaryLogRecord.log.responseFormatFallback ? `,已降级:${groupSummaryLogRecord.log.responseFormatFallbackReason || '未知原因'}` : ''}
</span>
</div>
</div>
<div className="detail-section">
<div className="section-title">
<Code2 size={14} />
<span>系统提示词</span>
</div>
<pre className="group-summary-log-pre">{groupSummaryLogRecord.log.systemPrompt}</pre>
</div>
<div className="detail-section">
<div className="section-title">
<Code2 size={14} />
<span>用户提示词与完整记录</span>
</div>
<pre className="group-summary-log-pre">{groupSummaryLogRecord.log.userPrompt}</pre>
</div>
<div className="detail-section">
<div className="section-title">
<Code2 size={14} />
<span>模型输出原文</span>
</div>
<pre className="group-summary-log-pre">{groupSummaryLogRecord.log.rawOutput}</pre>
</div>
<div className="detail-section">
<div className="section-title">
<Newspaper size={14} />
<span>最终总结</span>
</div>
<pre className="group-summary-log-pre">{groupSummaryLogRecord.log.finalSummary}</pre>
</div>
</div>
</div>
</div>,
document.body
)}
{/* 修改消息弹窗 */}
{editingMessage && createPortal(
<div className="modal-overlay">
@@ -8401,6 +8976,32 @@ const senderAvatarCache = createBoundedCache<{ avatarUrl?: string; displayName?:
})
const senderAvatarLoading = new Map<string, Promise<{ avatarUrl?: string; displayName?: string } | null>>()
type MessageInsightAnalysis = {
explicitText: string
emotion: string
intent: string
topic: string
}
type MessageInsightState = {
status: 'idle' | 'loading' | 'success' | 'error'
data?: MessageInsightAnalysis
error?: string
cached?: boolean
recordId?: string
}
const messageInsightMemoryCache = new Map<string, MessageInsightState>()
function buildMessageInsightCacheKey(sessionId: string, message: Message, messageKey: string): string {
return [
String(sessionId || '').trim(),
Math.floor(Number(message.localId || 0)),
Math.floor(Number(message.createTime || 0)),
messageKey
].join(':')
}
function getSharedImageDecryptTask(
key: string,
createTask: () => Promise<SharedImageDecryptResult>
@@ -8456,6 +9057,190 @@ function QuotedEmoji({ cdnUrl, md5 }: { cdnUrl: string; md5?: string }) {
}
// 消息气泡组件
function MessageInsightControl({
message,
messageKey,
session,
displayName,
avatarUrl,
senderName,
targetText,
contextCount,
compact
}: {
message: Message
messageKey: string
session: ChatSession
displayName?: string
avatarUrl?: string
senderName?: string
targetText: string
contextCount: number
compact?: boolean
}) {
const anchorRef = useRef<HTMLButtonElement | null>(null)
const cardRef = useRef<HTMLDivElement | null>(null)
const cacheKey = useMemo(() => buildMessageInsightCacheKey(session.username, message, messageKey), [message, messageKey, session.username])
const [open, setOpen] = useState(false)
const [state, setState] = useState<MessageInsightState>(() => messageInsightMemoryCache.get(cacheKey) || { status: 'idle' })
const [position, setPosition] = useState<{ top: number; left: number; placement: 'top' | 'bottom' }>({ top: 0, left: 0, placement: 'top' })
useEffect(() => {
setState(messageInsightMemoryCache.get(cacheKey) || { status: 'idle' })
setOpen(false)
}, [cacheKey])
const updatePosition = useCallback(() => {
const anchor = anchorRef.current
if (!anchor) return
const rect = anchor.getBoundingClientRect()
const cardWidth = cardRef.current?.offsetWidth || 320
const cardHeight = cardRef.current?.offsetHeight || 190
const gap = 10
const preferredTop = rect.top - cardHeight - gap
const placement: 'top' | 'bottom' = preferredTop < 8 ? 'bottom' : 'top'
const top = placement === 'top' ? preferredTop : rect.bottom + gap
const left = Math.min(Math.max(8, rect.left + 20), Math.max(8, window.innerWidth - cardWidth - 8))
setPosition({
top: Math.min(Math.max(8, top), Math.max(8, window.innerHeight - cardHeight - 8)),
left,
placement
})
}, [])
useEffect(() => {
if (!open) return
updatePosition()
const handle = () => updatePosition()
window.addEventListener('resize', handle)
window.addEventListener('scroll', handle, true)
return () => {
window.removeEventListener('resize', handle)
window.removeEventListener('scroll', handle, true)
}
}, [open, updatePosition])
const requestInsight = useCallback(async (forceRefresh = false) => {
if (!forceRefresh) {
const cached = messageInsightMemoryCache.get(cacheKey)
if (cached?.status === 'success') {
setState(cached)
return
}
}
setState({ status: 'loading' })
try {
const result = await window.electronAPI.insight.generateMessageInsight({
sessionId: session.username,
displayName: displayName || session.displayName || session.username,
avatarUrl: avatarUrl || session.avatarUrl,
targetLocalId: message.localId,
targetCreateTime: message.createTime,
targetMessageKey: messageKey,
targetText,
targetSenderName: senderName || displayName || session.displayName || session.username,
contextCount,
forceRefresh
})
if (result.success && result.data) {
const nextState: MessageInsightState = {
status: 'success',
data: result.data,
cached: result.cached === true,
recordId: result.recordId
}
messageInsightMemoryCache.set(cacheKey, nextState)
setState(nextState)
} else {
setState({ status: 'error', error: result.message || '解析失败' })
}
} catch (error) {
setState({ status: 'error', error: (error as Error).message || '解析失败' })
}
}, [avatarUrl, cacheKey, contextCount, displayName, message.createTime, message.localId, messageKey, senderName, session.avatarUrl, session.displayName, session.username, targetText])
const handleOpen = useCallback((event: React.MouseEvent) => {
event.stopPropagation()
setOpen(true)
window.setTimeout(updatePosition, 0)
const cached = messageInsightMemoryCache.get(cacheKey)
if (cached?.status === 'success') {
setState(cached)
return
}
void requestInsight(false)
}, [cacheKey, requestInsight, updatePosition])
const card = open ? createPortal(
<>
<button className="message-insight-backdrop" type="button" aria-label="关闭深度解析" onClick={() => setOpen(false)} />
<div
ref={cardRef}
className={`message-insight-card open placement-${position.placement}`}
style={{ top: position.top, left: position.left }}
onClick={(event) => event.stopPropagation()}
>
<div className="message-insight-card-header">
<Star size={14} />
<span>深度解析</span>
<button
type="button"
className="message-insight-refresh"
title="重新解析"
onClick={() => void requestInsight(true)}
disabled={state.status === 'loading'}
>
{state.status === 'loading' ? <Loader2 size={13} className="spin" /> : <RefreshCw size={13} />}
</button>
</div>
<div className="message-insight-card-body">
{state.status === 'loading' && (
<div className="message-insight-loading">
<Loader2 size={15} className="spin" />
<span>解析中...</span>
</div>
)}
{state.status === 'error' && (
<div className="message-insight-error">
<span>{state.error || '解析失败'}</span>
<button type="button" onClick={() => void requestInsight(true)}>重试</button>
</div>
)}
{state.status === 'success' && state.data && (
<>
<p className="message-insight-text">{state.data.explicitText}</p>
<div className="message-insight-divider" />
<div className="message-insight-tags">
<span className="message-insight-tag mood">情绪:{state.data.emotion}</span>
<span className="message-insight-tag intent">意图:{state.data.intent}</span>
<span className="message-insight-tag">话题:{state.data.topic}</span>
</div>
</>
)}
</div>
</div>
</>,
document.body
) : null
return (
<>
<button
ref={anchorRef}
type="button"
className={`message-insight-trigger ${compact ? 'compact' : ''}`}
onClick={handleOpen}
title="深度解析"
aria-label="深度解析"
>
<Star size={12} />
{!compact && <span>深度解析</span>}
</button>
{card}
</>
)
}
function MessageBubble({
message,
messageKey,
@@ -8471,7 +9256,9 @@ function MessageBubble({
onJumpToQuotedMessage,
isSelectionMode,
isSelected,
onToggleSelection
onToggleSelection,
aiMessageInsightEnabled,
aiMessageInsightContextCount
}: {
message: Message;
messageKey: string;
@@ -8488,6 +9275,8 @@ function MessageBubble({
isSelectionMode?: boolean;
isSelected?: boolean;
onToggleSelection?: (messageKey: string, isShiftKey?: boolean) => void;
aiMessageInsightEnabled?: boolean;
aiMessageInsightContextCount?: number;
}) {
const isSystem = isSystemMessage(message.localType)
const isEmoji = message.localType === 47
@@ -9706,6 +10495,33 @@ function MessageBubble({
const avatarUrl = isSent
? (myAvatarUrl || resolvedSenderAvatarUrl)
: (isGroupChat ? resolvedSenderAvatarUrl : session.avatarUrl)
const canShowMessageInsight = Boolean(
aiMessageInsightEnabled &&
!isSent &&
!isSystem &&
!isImage &&
!isVideo &&
!isVoice &&
!isEmoji &&
!isCard &&
!isCall &&
!isType49 &&
message.localType === 1 &&
cleanedParsedContent.trim()
)
const messageInsightControl = canShowMessageInsight ? (
<MessageInsightControl
message={message}
messageKey={messageKey}
session={session}
displayName={session.displayName || session.username}
avatarUrl={avatarUrl}
senderName={resolvedSenderName || session.displayName || session.username}
targetText={cleanedParsedContent}
contextCount={aiMessageInsightContextCount || 50}
compact={!isGroupChat}
/>
) : null
// 是否有引用消息
const hasQuote = quotedContent.length > 0
@@ -11051,6 +11867,7 @@ function MessageBubble({
isSelected={isSelected}
onContextMenu={onContextMenu}
onToggleSelection={onToggleSelection}
actionNode={messageInsightControl}
portal={systemAlertPortal}
>
{renderContent()}
@@ -11073,6 +11890,8 @@ const MemoMessageBubble = React.memo(MessageBubble, (prevProps, nextProps) => {
if (prevProps.onContextMenu !== nextProps.onContextMenu) return false
if (prevProps.onJumpToQuotedMessage !== nextProps.onJumpToQuotedMessage) return false
if (prevProps.onToggleSelection !== nextProps.onToggleSelection) return false
if (prevProps.aiMessageInsightEnabled !== nextProps.aiMessageInsightEnabled) return false
if (prevProps.aiMessageInsightContextCount !== nextProps.aiMessageInsightContextCount) return false
return (
prevProps.session.username === nextProps.session.username &&
+101 -3
View File
@@ -1177,10 +1177,18 @@
}
.export-defaults-modal-actions {
padding: 0 18px 16px;
padding: 12px 18px 16px;
border-top: 1px solid color-mix(in srgb, var(--border-color) 64%, transparent);
background: var(--bg-primary);
}
.export-defaults-close-action {
min-width: 96px;
min-height: 38px;
justify-content: center;
border-radius: 8px;
}
.task-center-card-label {
line-height: 1;
white-space: nowrap;
@@ -1932,10 +1940,11 @@
--contacts-main-col-width: calc(var(--contacts-avatar-col-width) + var(--contacts-column-gap) + var(--contacts-name-text-width));
--contacts-left-sticky-width: calc(var(--contacts-select-col-width) + var(--contacts-main-col-width) + var(--contacts-column-gap));
--contacts-message-col-width: 94px;
--contacts-latest-time-col-width: 128px;
--contacts-media-col-width: 58px;
--contacts-action-col-width: 126px;
--contacts-actions-sticky-width: 160px;
--contacts-table-min-width: 1120px;
--contacts-table-min-width: 1248px;
overflow: hidden;
border: none;
border-radius: 8px;
@@ -2184,6 +2193,58 @@
box-sizing: border-box;
}
.contacts-list-header-latest-time {
width: var(--contacts-latest-time-col-width);
min-width: var(--contacts-latest-time-col-width);
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
text-align: center;
flex-shrink: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
box-sizing: border-box;
}
.contacts-list-header-sortable {
background: transparent;
border: none;
padding: 4px 6px;
margin: 0;
color: inherit;
font: inherit;
cursor: pointer;
border-radius: 6px;
gap: 4px;
transition: background-color 0.12s ease, color 0.12s ease;
&:hover {
background: color-mix(in srgb, var(--primary) 10%, transparent);
color: var(--primary);
}
&:focus-visible {
outline: 2px solid var(--primary);
outline-offset: 1px;
}
&.is-active {
color: var(--primary);
}
}
.contacts-list-header-sort-icon {
color: inherit;
flex-shrink: 0;
&.muted {
color: var(--text-tertiary);
opacity: 0.6;
}
}
.contacts-list-header-media {
width: var(--contacts-media-col-width);
min-width: var(--contacts-media-col-width);
@@ -2501,6 +2562,37 @@
box-sizing: border-box;
}
.row-latest-time {
width: var(--contacts-latest-time-col-width);
min-width: var(--contacts-latest-time-col-width);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
text-align: center;
box-sizing: border-box;
}
.row-latest-time-value {
margin: 0;
font-size: 12px;
line-height: 1.2;
color: var(--text-secondary);
font-variant-numeric: tabular-nums;
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 14px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
&.muted {
color: var(--text-tertiary);
}
}
.row-media-metric {
width: var(--contacts-media-col-width);
min-width: var(--contacts-media-col-width);
@@ -5027,6 +5119,7 @@
--contacts-name-text-width: 10em;
--contacts-main-col-width: calc(var(--contacts-avatar-col-width) + var(--contacts-column-gap) + var(--contacts-name-text-width));
--contacts-message-col-width: 94px;
--contacts-latest-time-col-width: 120px;
--contacts-media-col-width: 56px;
--contacts-action-col-width: 126px;
}
@@ -5054,6 +5147,10 @@
min-width: var(--contacts-message-col-width);
}
.table-wrap .row-latest-time {
min-width: var(--contacts-latest-time-col-width);
}
.table-wrap .row-media-metric {
min-width: var(--contacts-media-col-width);
}
@@ -5753,7 +5850,8 @@
line-height: 1.6;
}
.close-icon-btn {
.automation-modal .close-icon-btn,
.automation-editor-modal .close-icon-btn {
border: none;
background: color-mix(in srgb, var(--text-tertiary) 8%, transparent);
color: var(--text-secondary);
+165 -25
View File
@@ -4,6 +4,9 @@ import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'
import { createPortal } from 'react-dom'
import {
Aperture,
ArrowDown,
ArrowUp,
ArrowUpDown,
Calendar,
Check,
CheckSquare,
@@ -656,6 +659,41 @@ const formatYmdHmDateTime = (timestamp?: number): string => {
return `${y}-${m}-${day} ${h}:${min}`
}
const formatLatestMessageTimeFromSeconds = (
timestamp?: number,
now: number = Date.now()
): { text: string; title: string } => {
if (!timestamp || !Number.isFinite(timestamp) || timestamp <= 0) {
return { text: '--', title: '' }
}
const ms = timestamp * 1000
const absolute = formatYmdHmDateTime(ms)
const diff = Math.max(0, now - ms)
const minute = 60 * 1000
const hour = 60 * minute
const day = 24 * hour
if (diff < minute) {
return { text: '刚刚', title: absolute }
}
if (diff < hour) {
const minutes = Math.max(1, Math.floor(diff / minute))
return { text: `${minutes} 分钟前`, title: absolute }
}
if (diff < day) {
const hours = Math.max(1, Math.floor(diff / hour))
return { text: `${hours} 小时前`, title: absolute }
}
return { text: absolute, title: absolute }
}
type ContactsSortKey = 'messageCount' | 'latestMessageTime'
type ContactsSortOrder = 'desc' | 'asc'
interface ContactsSortConfig {
key: ContactsSortKey | null
order: ContactsSortOrder | null
}
const DEFAULT_CONTACTS_SORT_CONFIG: ContactsSortConfig = { key: null, order: null }
const isSingleContactSession = (sessionId: string): boolean => {
const normalized = String(sessionId || '').trim()
if (!normalized) return false
@@ -2269,6 +2307,18 @@ function ExportPage() {
const [sessionMutualFriendsDialogTarget, setSessionMutualFriendsDialogTarget] = useState<SessionSnsTimelineTarget | null>(null)
const [sessionMutualFriendsSearch, setSessionMutualFriendsSearch] = useState('')
const [backgroundTasks, setBackgroundTasks] = useState<BackgroundTaskRecord[]>([])
const [contactsSortConfig, setContactsSortConfig] = useState<ContactsSortConfig>(DEFAULT_CONTACTS_SORT_CONFIG)
const toggleContactsSort = useCallback((key: ContactsSortKey) => {
setContactsSortConfig(prev => {
if (prev.key !== key) {
return { key, order: 'desc' }
}
if (prev.order === 'desc') return { key, order: 'asc' }
if (prev.order === 'asc') return DEFAULT_CONTACTS_SORT_CONFIG
return { key, order: 'desc' }
})
}, [])
const [exportFolder, setExportFolder] = useState('')
const [writeLayout, setWriteLayout] = useState<configService.ExportWriteLayout>('B')
@@ -4364,7 +4414,7 @@ function ExportPage() {
try {
if (prioritizedSessionIds.length > 0) {
patchSessionLoadTraceStage(prioritizedSessionIds, 'messageCount', 'loading')
const priorityResult = await window.electronAPI.chat.getSessionMessageCounts(prioritizedSessionIds)
const priorityResult = await window.electronAPI.chat.getSessionMessageCounts(prioritizedSessionIds, { bypassSessionCache: true, preferHintCache: false })
if (isStale()) return { ...accumulatedCounts }
if (priorityResult.success) {
applyCounts(priorityResult.counts)
@@ -4381,7 +4431,7 @@ function ExportPage() {
if (remainingSessionIds.length > 0) {
patchSessionLoadTraceStage(remainingSessionIds, 'messageCount', 'loading')
const remainingResult = await window.electronAPI.chat.getSessionMessageCounts(remainingSessionIds)
const remainingResult = await window.electronAPI.chat.getSessionMessageCounts(remainingSessionIds, { bypassSessionCache: true, preferHintCache: false })
if (isStale()) return { ...accumulatedCounts }
if (remainingResult.success) {
applyCounts(remainingResult.counts)
@@ -6661,34 +6711,47 @@ function ExportPage() {
)
})
const indexedContacts = contacts.map((contact, index) => ({
contact,
index,
count: (() => {
const counted = normalizeMessageCount(sessionMessageCounts[contact.username])
if (typeof counted === 'number') return counted
const hinted = normalizeMessageCount(sessionRowByUsername.get(contact.username)?.messageCountHint)
return hinted
})()
}))
const indexedContacts = contacts.map((contact, index) => {
const sessionRow = sessionRowByUsername.get(contact.username)
const counted = normalizeMessageCount(sessionMessageCounts[contact.username])
const hinted = normalizeMessageCount(sessionRow?.messageCountHint)
const count = typeof counted === 'number' ? counted : hinted
const rowTs = sessionRow?.lastTimestamp || sessionRow?.sortTimestamp
const latestTime = typeof rowTs === 'number' && rowTs > 0 ? rowTs : undefined
return { contact, index, count, latestTime }
})
const compareNullable = (a: number | undefined, b: number | undefined, order: ContactsSortOrder): number => {
const aHas = typeof a === 'number' && Number.isFinite(a)
const bHas = typeof b === 'number' && Number.isFinite(b)
if (aHas && bHas) {
const diff = (a as number) - (b as number)
return order === 'desc' ? -diff : diff
}
if (aHas) return -1
if (bHas) return 1
return 0
}
const sortKey = contactsSortConfig.key
const sortOrder = contactsSortConfig.order ?? 'desc'
indexedContacts.sort((a, b) => {
const aHasCount = typeof a.count === 'number'
const bHasCount = typeof b.count === 'number'
if (aHasCount && bHasCount) {
const diff = (b.count as number) - (a.count as number)
if (sortKey === 'latestMessageTime') {
const diff = compareNullable(a.latestTime, b.latestTime, sortOrder)
if (diff !== 0) return diff
} else if (sortKey === 'messageCount') {
const diff = compareNullable(a.count, b.count, sortOrder)
if (diff !== 0) return diff
} else {
const diff = compareNullable(a.count, b.count, 'desc')
if (diff !== 0) return diff
} else if (aHasCount) {
return -1
} else if (bHasCount) {
return 1
}
// 无统计值或同分时保持原顺序,避免列表频繁跳动。
return a.index - b.index
})
return indexedContacts.map(item => item.contact)
}, [contactsList, activeTab, searchKeyword, sessionMessageCounts, sessionRowByUsername])
}, [contactsList, activeTab, searchKeyword, sessionMessageCounts, sessionRowByUsername, contactsSortConfig])
const keywordMatchedContactUsernameSet = useMemo(() => {
const keyword = searchKeyword.trim().toLowerCase()
@@ -6897,7 +6960,7 @@ function ExportPage() {
useEffect(() => {
contactsVirtuosoRef.current?.scrollToIndex({ index: 0, align: 'start' })
setIsContactsListAtTop(true)
}, [activeTab, searchKeyword])
}, [activeTab, searchKeyword, contactsSortConfig])
const collectVisibleSessionMetricTargets = useCallback((sourceContacts: ContactInfo[]): string[] => {
if (sourceContacts.length === 0) return []
@@ -7613,12 +7676,29 @@ function ExportPage() {
scheduleSessionMutualFriendsWorker()
}
// 记录刷新前的会话时间戳
const oldTimestamps = new Map(
sessionsRef.current.map(s => [s.username, s.lastTimestamp || s.sortTimestamp || 0])
)
await Promise.all([
loadContactsList({ scopeKey }),
loadSnsStats({ full: true }),
loadSnsUserPostCounts({ force: true })
])
// 找出有变动的会话(最后消息时间变化)
const changedSessions = sessionsRef.current.filter(session => {
const oldTs = oldTimestamps.get(session.username) || 0
const newTs = session.lastTimestamp || session.sortTimestamp || 0
return newTs > oldTs
})
// 只对有变动的会话重新加载消息数量
if (changedSessions.length > 0) {
await loadSessionMessageCounts(changedSessions, activeTabRef.current, { scopeKey })
}
const currentDetailSessionId = showSessionDetailPanel
? String(sessionDetail?.wxid || '').trim()
: ''
@@ -8391,6 +8471,15 @@ function ExportPage() {
const hintedMessages = normalizeMessageCount(matchedSession?.messageCountHint)
const displayedMessageCount = countedMessages ?? hintedMessages
const mediaMetric = sessionContentMetrics[contact.username]
const rowLatestTs = matchedSession?.lastTimestamp || matchedSession?.sortTimestamp
const resolvedLatestTs = typeof rowLatestTs === 'number' && rowLatestTs > 0 ? rowLatestTs : undefined
const latestTimeInfo = formatLatestMessageTimeFromSeconds(resolvedLatestTs, nowTick)
const latestTimeState: { state: 'value'; text: string; title: string } | { state: 'loading' } | { state: 'na'; text: '--' } =
!canExport
? (isSessionBindingPending ? { state: 'loading' } : { state: 'na', text: '--' })
: (typeof resolvedLatestTs === 'number' && resolvedLatestTs > 0
? { state: 'value', text: latestTimeInfo.text, title: latestTimeInfo.title }
: { state: 'na', text: '--' })
const messageCountState: { state: 'value'; text: string } | { state: 'loading' } | { state: 'na'; text: '--' } =
!canExport
? (isSessionBindingPending ? { state: 'loading' } : { state: 'na', text: '--' })
@@ -8506,6 +8595,18 @@ function ExportPage() {
</button>
)}
</div>
<div className="row-latest-time">
{latestTimeState.state === 'loading'
? <Loader2 size={12} className="spin row-media-metric-icon" aria-label="最新消息时间加载中" />
: (
<span
className={`row-latest-time-value ${latestTimeState.state === 'value' ? '' : 'muted'}`}
title={latestTimeState.state === 'value' ? latestTimeState.title : undefined}
>
{latestTimeState.text}
</span>
)}
</div>
<div className="row-media-metric">
<strong className="row-media-metric-value">
{emojiMetric.state === 'loading'
@@ -9250,7 +9351,7 @@ function ExportPage() {
<div className="export-defaults-modal-actions">
<button
type="button"
className="secondary-btn"
className="secondary-btn export-defaults-close-action"
onClick={() => setIsExportDefaultsModalOpen(false)}
>
关闭
@@ -9454,7 +9555,46 @@ function ExportPage() {
<span className="contacts-list-header-main-label">{contactsHeaderMainLabel}</span>
</span>
</span>
<span className="contacts-list-header-count">总消息数</span>
<button
type="button"
className={`contacts-list-header-count contacts-list-header-sortable ${contactsSortConfig.key === 'messageCount' ? 'is-active' : ''}`}
onClick={() => toggleContactsSort('messageCount')}
title={
contactsSortConfig.key !== 'messageCount'
? '按总消息数降序排列'
: contactsSortConfig.order === 'desc'
? '切换为按总消息数升序'
: '取消排序(恢复默认)'
}
>
<span>总消息数</span>
{contactsSortConfig.key === 'messageCount'
? (contactsSortConfig.order === 'asc'
? <ArrowUp size={12} className="contacts-list-header-sort-icon" />
: <ArrowDown size={12} className="contacts-list-header-sort-icon" />)
: <ArrowUpDown size={12} className="contacts-list-header-sort-icon muted" />
}
</button>
<button
type="button"
className={`contacts-list-header-latest-time contacts-list-header-sortable ${contactsSortConfig.key === 'latestMessageTime' ? 'is-active' : ''}`}
onClick={() => toggleContactsSort('latestMessageTime')}
title={
contactsSortConfig.key !== 'latestMessageTime'
? '按最新消息时间降序排列'
: contactsSortConfig.order === 'desc'
? '切换为按最新消息时间升序'
: '取消排序(恢复默认)'
}
>
<span>最新消息时间</span>
{contactsSortConfig.key === 'latestMessageTime'
? (contactsSortConfig.order === 'asc'
? <ArrowUp size={12} className="contacts-list-header-sort-icon" />
: <ArrowDown size={12} className="contacts-list-header-sort-icon" />)
: <ArrowUpDown size={12} className="contacts-list-header-sort-icon muted" />
}
</button>
<span className="contacts-list-header-media">表情包</span>
<span className="contacts-list-header-media">语音</span>
<span className="contacts-list-header-media">图片</span>
+78
View File
@@ -265,6 +265,25 @@
color: #5b55a0;
background: rgba(99, 102, 241, 0.12);
}
&.manual {
color: #0f766e;
background: rgba(20, 184, 166, 0.13);
}
}
.insight-source-pill {
padding: 5px 8px;
border-radius: 999px;
background: rgba(91, 147, 144, 0.1);
color: var(--primary);
font-size: 12px;
white-space: nowrap;
&.message_analysis {
background: rgba(245, 158, 11, 0.13);
color: #8a5a00;
}
}
.insight-time {
@@ -282,6 +301,43 @@
word-break: break-word;
}
.message-analysis-target {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 8px 10px;
border-radius: 8px;
background: var(--bg-tertiary);
color: var(--text-secondary);
font-size: 13px;
line-height: 1.45;
}
.message-analysis-target-label {
flex: 0 0 auto;
color: var(--text-tertiary);
font-weight: 700;
}
.message-analysis-target-text {
min-width: 0;
word-break: break-word;
}
.message-analysis-tags {
display: flex;
flex-wrap: wrap;
gap: 7px;
span {
border-radius: 6px;
background: var(--bg-tertiary);
color: var(--text-secondary);
padding: 4px 7px;
font-size: 12px;
}
}
.insight-filter-panel {
width: var(--insight-panel-width);
flex-shrink: 0;
@@ -376,6 +432,28 @@
}
}
.insight-source-tabs {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
button {
min-height: 34px;
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-primary);
color: var(--text-secondary);
cursor: pointer;
font-size: 13px;
&.active {
border-color: var(--primary);
color: var(--primary);
background: rgba(91, 147, 144, 0.08);
}
}
}
.insight-custom-dates {
display: grid;
grid-template-columns: 1fr;
+114 -4
View File
@@ -7,6 +7,7 @@ import type {
InsightRecordContactFacet,
InsightRecordFilters,
InsightRecordListResult,
InsightRecordSourceType,
InsightRecordSummary,
InsightRecordTriggerReason
} from '../types/electron'
@@ -15,6 +16,7 @@ import './InsightInboxPage.scss'
const INSIGHT_AVATAR_URL = './assets/insight/AI_Insight.png'
type DateFilterMode = 'all' | 'today' | 'week' | 'custom'
type SourceFilterMode = InsightRecordSourceType | 'all'
function getStartOfDay(date: Date): number {
const next = new Date(date)
@@ -62,16 +64,23 @@ function formatGroupDate(timestamp: number): string {
}
function getTriggerLabel(reason: InsightRecordTriggerReason): string {
if (reason === 'message_analysis') return '深度解析'
if (reason === 'silence') return '沉默提醒'
if (reason === 'test') return '测试见解'
if (reason === 'manual') return '手动触发'
return '活跃分析'
}
function getSourceLabel(sourceType?: InsightRecordSourceType): string {
return sourceType === 'message_analysis' ? '深度解析' : 'AI 见解'
}
function buildLogText(record: InsightRecord): string {
const log = record.log
return [
const lines = [
`时间:${new Date(record.createdAt).toLocaleString('zh-CN')}`,
`联系人:${record.displayName} (${record.sessionId})`,
`来源:${getSourceLabel(record.sourceType)}`,
`触发类型:${getTriggerLabel(record.triggerReason)}`,
`接口地址:${log.endpoint}`,
`模型:${log.model}`,
@@ -90,7 +99,23 @@ function buildLogText(record: InsightRecord): string {
'',
'最终见解:',
log.finalInsight
].join('\n')
]
if (record.sourceType === 'message_analysis') {
lines.splice(8, 0,
`JSON Mode:${log.responseFormatJson ? '启用' : '未启用'}`,
`JSON Mode 降级:${log.responseFormatFallback ? '是' : '否'}`,
`降级原因:${log.responseFormatFallbackReason || '无'}`,
`上下文:请求 ${log.contextStats?.requested ?? log.contextCount} 条,前 ${log.contextStats?.beforeTarget ?? 0} 条,后 ${log.contextStats?.afterTarget ?? 0} 条`,
`上下文读取异常:${log.contextStats?.readError || '无'}`
)
lines.splice(4, 0,
`目标消息:${record.messageInsight?.targetSenderName || log.targetMessage?.senderName || ''}:${record.messageInsight?.targetTextPreview || log.targetMessage?.textPreview || ''}`,
`目标定位:localId=${record.messageInsight?.targetLocalId || log.targetMessage?.localId || 0}, createTime=${record.messageInsight?.targetCreateTime || log.targetMessage?.createTime || 0}, key=${record.messageInsight?.targetMessageKey || log.targetMessage?.messageKey || ''}`
)
}
return lines.join('\n')
}
export default function InsightInboxPage() {
@@ -101,6 +126,7 @@ export default function InsightInboxPage() {
const [keyword, setKeyword] = useState('')
const [contactSearch, setContactSearch] = useState('')
const [selectedSessionId, setSelectedSessionId] = useState('')
const [sourceType, setSourceType] = useState<SourceFilterMode>('all')
const [dateMode, setDateMode] = useState<DateFilterMode>('all')
const [customStart, setCustomStart] = useState(formatDateInput(new Date()))
const [customEnd, setCustomEnd] = useState(formatDateInput(new Date()))
@@ -133,11 +159,12 @@ export default function InsightInboxPage() {
const filters = useMemo<InsightRecordFilters>(() => ({
keyword: keyword.trim() || undefined,
sessionId: selectedSessionId || undefined,
sourceType,
startTime: dateRange.startTime,
endTime: dateRange.endTime,
limit: 200,
offset: 0
}), [dateRange.endTime, dateRange.startTime, keyword, selectedSessionId])
}), [dateRange.endTime, dateRange.startTime, keyword, selectedSessionId, sourceType])
const loadRecords = useCallback(async () => {
setLoading(true)
@@ -200,6 +227,16 @@ export default function InsightInboxPage() {
}, [contactSearch, contacts])
const openChat = (record: InsightRecordSummary) => {
if (record.sourceType === 'message_analysis' && record.messageInsight) {
const query = new URLSearchParams({
sessionId: record.sessionId,
jumpSource: 'messageAnalysis',
jumpLocalId: String(record.messageInsight.targetLocalId || 0),
jumpCreateTime: String(record.messageInsight.targetCreateTime || 0)
})
navigate(`/chat?${query.toString()}`)
return
}
navigate(`/chat?sessionId=${encodeURIComponent(record.sessionId)}`)
}
@@ -305,6 +342,7 @@ export default function InsightInboxPage() {
</div>
</div>
<div className="insight-card-actions">
<span className={`insight-source-pill ${record.sourceType || 'insight'}`}>{getSourceLabel(record.sourceType)}</span>
<span className={`insight-trigger-pill ${record.triggerReason}`}>{getTriggerLabel(record.triggerReason)}</span>
<span className="insight-time">{formatRecordTime(record.createdAt)}</span>
<button className="insight-action-btn" onClick={() => openChat(record)} title="打开聊天">
@@ -318,7 +356,22 @@ export default function InsightInboxPage() {
</button>
</div>
</div>
{record.sourceType === 'message_analysis' && record.messageInsight && (
<div className="message-analysis-target">
<span className="message-analysis-target-label">目标消息</span>
<span className="message-analysis-target-text">
{record.messageInsight.targetSenderName}:{record.messageInsight.targetTextPreview}
</span>
</div>
)}
<p className="insight-body">{record.insight}</p>
{record.sourceType === 'message_analysis' && record.messageInsight && (
<div className="message-analysis-tags">
<span>情绪:{record.messageInsight.analysis.emotion}</span>
<span>意图:{record.messageInsight.analysis.intent}</span>
<span>话题:{record.messageInsight.analysis.topic}</span>
</div>
)}
</div>
</article>
))}
@@ -347,6 +400,28 @@ export default function InsightInboxPage() {
</div>
</div>
<div className="insight-filter-widget">
<div className="insight-widget-title">
<Sparkles size={14} />
<span>来源类型</span>
</div>
<div className="insight-source-tabs">
{[
{ value: 'all', label: '全部' },
{ value: 'insight', label: 'AI 见解' },
{ value: 'message_analysis', label: '深度解析' }
].map((option) => (
<button
key={option.value}
className={sourceType === option.value ? 'active' : ''}
onClick={() => setSourceType(option.value as SourceFilterMode)}
>
{option.label}
</button>
))}
</div>
</div>
<div className="insight-filter-widget">
<div className="insight-widget-title">
<CalendarDays size={14} />
@@ -440,9 +515,44 @@ export default function InsightInboxPage() {
`Max Tokens: ${logRecord.log.maxTokens}`,
`Temperature: ${logRecord.log.temperature}`,
`Duration: ${logRecord.log.durationMs}ms`,
`Trigger: ${getTriggerLabel(logRecord.triggerReason)}`
`Source: ${getSourceLabel(logRecord.sourceType)}`,
`Trigger: ${getTriggerLabel(logRecord.triggerReason)}`,
...(logRecord.sourceType === 'message_analysis'
? [
`JSON Mode: ${logRecord.log.responseFormatJson ? 'enabled' : 'disabled'}`,
`JSON Fallback: ${logRecord.log.responseFormatFallback ? 'yes' : 'no'}`,
`Fallback Reason: ${logRecord.log.responseFormatFallbackReason || 'none'}`
]
: [])
].join('\n')}</pre>
</section>
{logRecord.sourceType === 'message_analysis' && (
<section>
<h4>深度解析目标</h4>
<pre>{[
`Sender: ${logRecord.messageInsight?.targetSenderName || logRecord.log.targetMessage?.senderName || ''}`,
`Preview: ${logRecord.messageInsight?.targetTextPreview || logRecord.log.targetMessage?.textPreview || ''}`,
`LocalId: ${logRecord.messageInsight?.targetLocalId || logRecord.log.targetMessage?.localId || 0}`,
`CreateTime: ${logRecord.messageInsight?.targetCreateTime || logRecord.log.targetMessage?.createTime || 0}`,
`MessageKey: ${logRecord.messageInsight?.targetMessageKey || logRecord.log.targetMessage?.messageKey || ''}`,
`Context Requested: ${logRecord.log.contextStats?.requested ?? logRecord.log.contextCount}`,
`Context Before: ${logRecord.log.contextStats?.beforeTarget ?? 0}`,
`Context After: ${logRecord.log.contextStats?.afterTarget ?? 0}`,
`Context Error: ${logRecord.log.contextStats?.readError || 'none'}`
].join('\n')}</pre>
</section>
)}
{logRecord.sourceType === 'message_analysis' && logRecord.log.parsedAnalysis && (
<section>
<h4>解析字段</h4>
<pre>{[
`explicitText: ${logRecord.log.parsedAnalysis.explicitText}`,
`emotion: ${logRecord.log.parsedAnalysis.emotion}`,
`intent: ${logRecord.log.parsedAnalysis.intent}`,
`topic: ${logRecord.log.parsedAnalysis.topic}`
].join('\n')}</pre>
</section>
)}
<section>
<h4>System Prompt</h4>
<pre>{logRecord.log.systemPrompt}</pre>
+2 -2
View File
@@ -770,12 +770,12 @@ function MyFootprintPage() {
<>
<section className="kpi-grid">
<button type="button" className="kpi-card" onClick={() => setTimelineMode('private')}>
<span className="kpi-label">有聊天的人数</span>
<span className="kpi-label">收到消息的人数</span>
<strong>{data.summary.private_inbound_people}</strong>
<small>回复了其中 {data.summary.private_replied_people} 人</small>
</button>
<button type="button" className="kpi-card" onClick={() => setTimelineMode('private')}>
<span className="kpi-label">我有回复的人数</span>
<span className="kpi-label">发送消息的人数</span>
<strong>{data.summary.private_outbound_people}</strong>
<small>回复率 {formatPercent(data.summary.private_reply_rate)}</small>
</button>
+139 -13
View File
@@ -3390,14 +3390,17 @@
}
&.insight-social-tab {
--insight-moments-column-width: 76px;
--insight-social-column-width: minmax(220px, 300px);
--insight-status-column-width: 82px;
--insight-social-list-grid: minmax(0, 1fr) var(--insight-moments-column-width) var(--insight-social-column-width) var(--insight-status-column-width);
--insight-moments-column-width: 44px;
--insight-profile-column-width: 78px;
--insight-social-column-width: minmax(184px, 230px);
--insight-status-column-width: 70px;
--insight-social-list-grid: minmax(0, 1fr) var(--insight-moments-column-width) var(--insight-profile-column-width) var(--insight-social-column-width) var(--insight-status-column-width);
.anti-revoke-list-header {
grid-template-columns: var(--insight-social-list-grid);
gap: 14px;
gap: 8px;
padding-left: 14px;
padding-right: 10px;
.insight-moments-column-title {
display: flex;
@@ -3405,6 +3408,12 @@
color: var(--text-tertiary);
}
.insight-profile-column-title {
display: flex;
justify-content: center;
color: var(--text-tertiary);
}
.insight-social-column-title {
min-width: 0;
color: var(--text-tertiary);
@@ -3420,11 +3429,19 @@
display: grid;
grid-template-columns: var(--insight-social-list-grid);
align-items: center;
gap: 14px;
gap: 8px;
padding-left: 14px;
padding-right: 10px;
}
.anti-revoke-row-main {
min-width: 0;
gap: 8px;
.anti-revoke-check {
width: 16px;
height: 16px;
}
}
.insight-moments-cell {
@@ -3435,6 +3452,92 @@
min-height: 30px;
}
.insight-profile-cell {
min-width: 0;
display: flex;
align-items: center;
justify-content: center;
min-height: 30px;
}
.insight-profile-status-btn {
position: relative;
width: 74px;
height: 28px;
border-radius: 999px;
border: 1px solid color-mix(in srgb, var(--border-color) 84%, transparent);
background: color-mix(in srgb, var(--bg-secondary) 90%, var(--bg-primary) 10%);
color: var(--text-secondary);
display: inline-flex;
align-items: center;
justify-content: center;
gap: 5px;
font-size: 12px;
line-height: 1;
white-space: nowrap;
cursor: pointer;
transition: border-color 0.16s ease, background 0.16s ease, color 0.16s ease, opacity 0.16s ease;
&:disabled {
cursor: not-allowed;
opacity: 0.48;
}
&:not(:disabled)::after {
content: attr(data-tooltip);
position: absolute;
left: 50%;
bottom: calc(100% + 8px);
transform: translateX(-50%) translateY(2px);
z-index: 8;
max-width: 180px;
width: max-content;
padding: 6px 8px;
border-radius: 6px;
background: color-mix(in srgb, var(--text-primary) 92%, #000 8%);
color: var(--bg-primary);
font-size: 12px;
line-height: 1.2;
pointer-events: none;
opacity: 0;
visibility: hidden;
transition: opacity 0.12s ease, transform 0.12s ease, visibility 0.12s ease;
}
&:not(:disabled):hover::after,
&:not(:disabled):focus-visible::after {
opacity: 1;
visibility: visible;
transform: translateX(-50%) translateY(0);
}
.profile-status-dot {
width: 6px;
height: 6px;
border-radius: 999px;
background: color-mix(in srgb, var(--text-tertiary) 86%, transparent);
flex-shrink: 0;
}
&.ready {
color: var(--primary);
border-color: color-mix(in srgb, var(--primary) 35%, var(--border-color));
background: color-mix(in srgb, var(--primary) 10%, var(--bg-secondary));
}
&.running {
color: color-mix(in srgb, var(--primary) 70%, var(--text-primary) 30%);
border-color: color-mix(in srgb, var(--primary) 30%, var(--border-color));
background: color-mix(in srgb, var(--primary) 9%, var(--bg-secondary));
}
&.failed {
color: color-mix(in srgb, var(--danger) 72%, var(--text-primary) 28%);
border-color: color-mix(in srgb, var(--danger) 24%, var(--border-color));
background: color-mix(in srgb, var(--danger) 8%, var(--bg-secondary));
}
}
.insight-moments-toggle {
position: relative;
width: 18px;
@@ -3489,24 +3592,33 @@
}
.insight-social-binding-cell {
min-width: 0;
display: flex;
align-items: flex-start;
gap: 6px;
flex-wrap: wrap;
}
.insight-social-binding-controls {
min-width: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px 10px;
grid-template-columns: minmax(92px, 1fr) auto;
gap: 6px;
align-items: center;
flex: 1 1 100%;
}
.insight-social-binding-input-wrap {
min-width: 0;
display: flex;
align-items: center;
gap: 8px;
gap: 5px;
}
.binding-platform-chip {
flex-shrink: 0;
border-radius: 999px;
padding: 2px 7px;
padding: 2px 6px;
font-size: 11px;
color: var(--text-secondary);
border: 1px solid color-mix(in srgb, var(--border-color) 84%, transparent);
@@ -3536,12 +3648,18 @@
display: inline-flex;
align-items: center;
justify-self: flex-end;
gap: 8px;
gap: 4px;
.btn {
min-width: 0;
padding-left: 8px;
padding-right: 8px;
}
}
.insight-social-binding-feedback {
grid-column: 1 / span 2;
min-height: 18px;
flex: 1 1 100%;
}
.binding-feedback {
@@ -3563,6 +3681,11 @@
align-items: flex-end;
max-width: none;
min-width: 0;
.status-badge {
padding-left: 8px;
padding-right: 8px;
}
}
}
@@ -3605,6 +3728,7 @@
grid-template-columns: minmax(0, 1fr) auto;
.insight-moments-column-title,
.insight-profile-column-title,
.insight-social-column-title {
display: none;
}
@@ -3617,12 +3741,14 @@
}
.insight-moments-cell,
.insight-profile-cell,
.insight-social-binding-cell,
.anti-revoke-row-status {
width: 100%;
}
.insight-moments-cell {
.insight-moments-cell,
.insight-profile-cell {
justify-content: flex-start;
}
+552 -56
View File
@@ -1,12 +1,15 @@
import { useState, useEffect, useRef } from 'react'
import { useLocation } from 'react-router-dom'
import { useMemo } from 'react'
import { useAppStore } from '../stores/appStore'
import { useChatStore } from '../stores/chatStore'
import { useThemeStore, themes } from '../stores/themeStore'
import { useAnalyticsStore } from '../stores/analyticsStore'
import { dialog } from '../services/ipc'
import * as configService from '../services/config'
import groupSummaryPrompt from '../../shared/groupSummaryPrompt.json'
import type { ChatSession, ContactInfo } from '../types/models'
import type { InsightProfileStatus } from '../types/electron'
import {
Eye, EyeOff, FolderSearch, FolderOpen, Search, Copy,
RotateCcw, Trash2, Plug, Check, Sun, Moon, Monitor,
@@ -32,9 +35,11 @@ type SettingsTab =
| 'aiCommon'
| 'insight'
| 'aiFootprint'
| 'aiGroupSummary'
| 'aiMessageInsight'
| 'autoDownload'
const tabs: { id: Exclude<SettingsTab, 'insight' | 'aiFootprint'>; label: string; icon: React.ElementType }[] = [
const tabs: { id: Exclude<SettingsTab, 'insight' | 'aiFootprint' | 'aiMessageInsight'>; label: string; icon: React.ElementType }[] = [
{ id: 'appearance', label: '外观', icon: Palette },
{ id: 'notification', label: '通知', icon: Bell },
{ id: 'antiRevoke', label: '防撤回', icon: RotateCcw },
@@ -56,16 +61,19 @@ const filteredTabs = tabs.filter(tab => {
return true
})
const aiTabs: Array<{ id: Extract<SettingsTab, 'aiCommon' | 'insight' | 'aiFootprint'>; label: string }> = [
const aiTabs: Array<{ id: Extract<SettingsTab, 'aiCommon' | 'insight' | 'aiFootprint' | 'aiGroupSummary' | 'aiMessageInsight'>; label: string }> = [
{ id: 'aiCommon', label: '基础配置' },
{ id: 'insight', label: 'AI 见解' },
{ id: 'aiFootprint', label: 'AI 足迹' }
{ id: 'aiFootprint', label: 'AI 足迹' },
{ id: 'aiGroupSummary', label: '群聊总结' },
{ id: 'aiMessageInsight', label: '消息解析' }
]
const isMac = navigator.userAgent.toLowerCase().includes('mac')
const isLinux = navigator.userAgent.toLowerCase().includes('linux')
const isWindows = !isMac && !isLinux
const MAC_KEY_FAQ_URL = 'https://github.com/hicccc77/WeFlow/blob/main/docs/MAC-KEY-FAQ.md'
const DEFAULT_GROUP_SUMMARY_SYSTEM_PROMPT = String(groupSummaryPrompt.defaultSystemPrompt || '').trim()
const dbDirName = isMac ? '2.0b4.0.9 目录' : 'xwechat_files 目录'
const dbPathPlaceholder = isMac
@@ -325,8 +333,18 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
const [weiboBindingDrafts, setWeiboBindingDrafts] = useState<Record<string, string>>({})
const [weiboBindingErrors, setWeiboBindingErrors] = useState<Record<string, string>>({})
const [weiboBindingLoadingSessionId, setWeiboBindingLoadingSessionId] = useState<string | null>(null)
const [aiInsightProfileStatuses, setAiInsightProfileStatuses] = useState<Record<string, InsightProfileStatus>>({})
const [aiInsightProfileActiveSessionId, setAiInsightProfileActiveSessionId] = useState<string | null>(null)
const [aiFootprintEnabled, setAiFootprintEnabled] = useState(false)
const [aiFootprintSystemPrompt, setAiFootprintSystemPrompt] = useState('')
const [aiGroupSummaryEnabled, setAiGroupSummaryEnabled] = useState(false)
const [aiGroupSummaryIntervalHours, setAiGroupSummaryIntervalHours] = useState(4)
const [aiGroupSummarySystemPrompt, setAiGroupSummarySystemPrompt] = useState('')
const [aiGroupSummaryFilterList, setAiGroupSummaryFilterList] = useState<string[]>([])
const [aiGroupSummaryFilterSearchKeyword, setAiGroupSummaryFilterSearchKeyword] = useState('')
const [aiMessageInsightEnabled, setAiMessageInsightEnabled] = useState(false)
const [aiMessageInsightContextCount, setAiMessageInsightContextCount] = useState(50)
const [aiMessageInsightSystemPrompt, setAiMessageInsightSystemPrompt] = useState('')
// 自动下载图片
const [autoDownloadStatus, setAutoDownloadStatus] = useState<{ isHooked: boolean; pid: number | null; supported: boolean } | null>(null)
@@ -372,7 +390,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
}, [location.state])
useEffect(() => {
if (activeTab === 'aiCommon' || activeTab === 'insight' || activeTab === 'aiFootprint') {
if (activeTab === 'aiCommon' || activeTab === 'insight' || activeTab === 'aiFootprint' || activeTab === 'aiGroupSummary' || activeTab === 'aiMessageInsight') {
setAiGroupExpanded(true)
}
}, [activeTab])
@@ -590,6 +608,13 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
const savedAiInsightWeiboBindings = await configService.getAiInsightWeiboBindings()
const savedAiFootprintEnabled = await configService.getAiFootprintEnabled()
const savedAiFootprintSystemPrompt = await configService.getAiFootprintSystemPrompt()
const savedAiGroupSummaryEnabled = await configService.getAiGroupSummaryEnabled()
const savedAiGroupSummaryIntervalHours = await configService.getAiGroupSummaryIntervalHours()
const savedAiGroupSummarySystemPrompt = await configService.getAiGroupSummarySystemPrompt()
const savedAiGroupSummaryFilterList = await configService.getAiGroupSummaryFilterList()
const savedAiMessageInsightEnabled = await configService.getAiMessageInsightEnabled()
const savedAiMessageInsightContextCount = await configService.getAiMessageInsightContextCount()
const savedAiMessageInsightSystemPrompt = await configService.getAiMessageInsightSystemPrompt()
setAiInsightEnabled(savedAiInsightEnabled)
setAiModelApiBaseUrl(savedAiModelApiBaseUrl)
@@ -616,6 +641,13 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
setAiInsightWeiboBindings(savedAiInsightWeiboBindings)
setAiFootprintEnabled(savedAiFootprintEnabled)
setAiFootprintSystemPrompt(savedAiFootprintSystemPrompt)
setAiGroupSummaryEnabled(savedAiGroupSummaryEnabled)
setAiGroupSummaryIntervalHours(savedAiGroupSummaryIntervalHours)
setAiGroupSummarySystemPrompt(savedAiGroupSummarySystemPrompt)
setAiGroupSummaryFilterList(savedAiGroupSummaryFilterList)
setAiMessageInsightEnabled(savedAiMessageInsightEnabled)
setAiMessageInsightContextCount(savedAiMessageInsightContextCount)
setAiMessageInsightSystemPrompt(savedAiMessageInsightSystemPrompt)
} catch (e: any) {
console.error('加载配置失败:', e)
@@ -2833,37 +2865,41 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
showMessage('已清空主动推送过滤列表', true)
}
const sessionFilterOptionMap = new Map<string, SessionFilterOption>()
const { sessionFilterOptionMap, sessionFilterOptions } = useMemo(() => {
const optionMap = new Map<string, SessionFilterOption>()
for (const session of chatSessions) {
if (session.username.toLowerCase().includes('placeholder_foldgroup')) continue
sessionFilterOptionMap.set(session.username, {
username: session.username,
displayName: session.displayName || session.username,
avatarUrl: session.avatarUrl,
type: getSessionFilterType(session)
})
}
for (const session of chatSessions) {
if (session.username.toLowerCase().includes('placeholder_foldgroup')) continue
optionMap.set(session.username, {
username: session.username,
displayName: session.displayName || session.username,
avatarUrl: session.avatarUrl,
type: getSessionFilterType(session)
})
}
for (const contact of messagePushContactOptions) {
if (!contact.username) continue
if (contact.type !== 'friend' && contact.type !== 'group' && contact.type !== 'official' && contact.type !== 'former_friend') continue
const existing = sessionFilterOptionMap.get(contact.username)
sessionFilterOptionMap.set(contact.username, {
username: contact.username,
displayName: existing?.displayName || contact.displayName || contact.remark || contact.nickname || contact.username,
avatarUrl: existing?.avatarUrl || contact.avatarUrl,
type: getSessionFilterType(contact)
})
}
for (const contact of messagePushContactOptions) {
if (!contact.username) continue
if (contact.type !== 'friend' && contact.type !== 'group' && contact.type !== 'official' && contact.type !== 'former_friend') continue
const existing = optionMap.get(contact.username)
optionMap.set(contact.username, {
username: contact.username,
displayName: existing?.displayName || contact.displayName || contact.remark || contact.nickname || contact.username,
avatarUrl: existing?.avatarUrl || contact.avatarUrl,
type: getSessionFilterType(contact)
})
}
const sessionFilterOptions = Array.from(sessionFilterOptionMap.values())
.sort((a, b) => {
const aSession = chatSessions.find(session => session.username === a.username)
const bSession = chatSessions.find(session => session.username === b.username)
return Number(bSession?.sortTimestamp || bSession?.lastTimestamp || 0) -
Number(aSession?.sortTimestamp || aSession?.lastTimestamp || 0)
})
const options = Array.from(optionMap.values())
.sort((a, b) => {
const aSession = chatSessions.find(session => session.username === a.username)
const bSession = chatSessions.find(session => session.username === b.username)
return Number(bSession?.sortTimestamp || bSession?.lastTimestamp || 0) -
Number(aSession?.sortTimestamp || aSession?.lastTimestamp || 0)
})
return { sessionFilterOptionMap: optionMap, sessionFilterOptions: options }
}, [chatSessions, messagePushContactOptions])
const getSessionFilterOptionInfo = (username: string) => {
return sessionFilterOptionMap.get(username) || {
@@ -2903,6 +2939,15 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
messagePushFilterSearchKeyword
)
const groupSummaryFilterOptions = sessionFilterOptions.filter((session) => session.type === 'group')
const groupSummaryAvailableSessions = groupSummaryFilterOptions.filter((session) => {
const keyword = aiGroupSummaryFilterSearchKeyword.trim().toLowerCase()
if (aiGroupSummaryFilterList.includes(session.username)) return false
if (!keyword) return true
return String(session.displayName || '').toLowerCase().includes(keyword) ||
session.username.toLowerCase().includes(keyword)
})
const handleAddAllNotificationFilterSessions = async () => {
const usernames = notificationAvailableSessions.map(session => session.username)
if (usernames.length === 0) return
@@ -3231,6 +3276,120 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
await configService.setAiInsightWeiboBindings(nextBindings)
if (!silent) showMessage('已清除微博绑定', true)
}
const refreshAiInsightProfileStatuses = async (sessionIds?: string[]) => {
const ids = normalizeSessionIds(
sessionIds || sessionFilterOptions
.filter((session) => session.type === 'private')
.map((session) => session.username)
)
if (ids.length === 0) {
setAiInsightProfileStatuses({})
setAiInsightProfileActiveSessionId(null)
return
}
try {
const result = await window.electronAPI.insight.listProfileStatuses(ids)
if (!result.success) return
setAiInsightProfileStatuses(result.statuses || {})
setAiInsightProfileActiveSessionId(result.activeTask?.sessionId || null)
} catch (error) {
console.warn('刷新 AI 画像状态失败:', error)
}
}
const handleGenerateInsightProfile = async (session: SessionFilterOption) => {
const sessionId = session.username
const currentStatus = aiInsightProfileStatuses[sessionId]
if (currentStatus?.status === 'running') {
try {
const result = await window.electronAPI.insight.cancelProfile(sessionId)
showMessage(result.message || '已请求取消画像任务', result.success)
} catch (e: any) {
showMessage(`取消画像失败:${e?.message || String(e)}`, false)
} finally {
setTimeout(() => { void refreshAiInsightProfileStatuses() }, 500)
}
return
}
if (aiInsightProfileActiveSessionId && aiInsightProfileActiveSessionId !== sessionId) return
setAiInsightProfileStatuses((prev) => ({
...prev,
[sessionId]: {
sessionId,
status: 'running',
phase: '正在初始化画像...',
updatedAt: Date.now()
}
}))
setAiInsightProfileActiveSessionId(sessionId)
try {
const result = await window.electronAPI.insight.generateProfile({
sessionId,
displayName: session.displayName || session.username,
avatarUrl: session.avatarUrl
})
showMessage(result.message || (result.success ? '画像完成' : '画像失败'), result.success)
} catch (e: any) {
showMessage(`画像失败:${e?.message || String(e)}`, false)
} finally {
await refreshAiInsightProfileStatuses()
}
}
useEffect(() => {
if (activeTab !== 'insight') return
const ids = sessionFilterOptions
.filter((session) => session.type === 'private')
.map((session) => session.username)
if (ids.length === 0) return
void refreshAiInsightProfileStatuses(ids)
const timer = window.setInterval(() => {
void refreshAiInsightProfileStatuses(ids)
}, 2500)
return () => window.clearInterval(timer)
}, [activeTab, sessionFilterOptions])
const getInsightProfileButtonMeta = (sessionId: string) => {
const status = aiInsightProfileStatuses[sessionId]
const activeOther = Boolean(aiInsightProfileActiveSessionId && aiInsightProfileActiveSessionId !== sessionId)
if (status?.status === 'running') {
return {
className: 'running',
label: '取消',
title: status.phase || '画像生成中,点击取消',
disabled: false,
icon: <Loader2 size={13} style={{ animation: 'spin 1s linear infinite' }} />
}
}
if (status?.status === 'ready') {
return {
className: 'ready',
label: '已画像',
title: activeOther ? '其他联系人正在画像中' : '点击以重新画像',
disabled: activeOther,
icon: <Check size={13} />
}
}
if (status?.status === 'failed') {
return {
className: 'failed',
label: '失败',
title: activeOther ? '其他联系人正在画像中' : (status.error || '画像失败,点击重试'),
disabled: activeOther,
icon: <XCircle size={13} />
}
}
return {
className: 'none',
label: '未画像',
title: activeOther ? '其他联系人正在画像中' : '点击进行画像',
disabled: activeOther,
icon: <i className="profile-status-dot" aria-hidden="true" />
}
}
const renderInsightTab = () => (
<div className="tab-content">
{/* 总开关 */}
@@ -3805,6 +3964,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
<div className="anti-revoke-list-header">
<span>对话({filteredSessions.length})</span>
<span className="insight-moments-column-title">朋友圈</span>
<span className="insight-profile-column-title">画像</span>
<span className="insight-social-column-title">社交平台(微博)</span>
<span className="anti-revoke-status-column-title">状态</span>
</div>
@@ -3816,6 +3976,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
const weiboDraftValue = getWeiboBindingDraftValue(session.username)
const isBindingLoading = weiboBindingLoadingSessionId === session.username
const weiboBindingError = weiboBindingErrors[session.username]
const profileButtonMeta = getInsightProfileButtonMeta(session.username)
return (
<div
key={session.username}
@@ -3866,37 +4027,56 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
<span className="binding-feedback muted">-</span>
)}
</div>
<div className="insight-profile-cell">
{isPrivateSession ? (
<button
type="button"
className={`insight-profile-status-btn ${profileButtonMeta.className}`}
title={profileButtonMeta.title}
data-tooltip={profileButtonMeta.title}
disabled={profileButtonMeta.disabled}
onClick={() => void handleGenerateInsightProfile(session)}
>
{profileButtonMeta.icon}
<span>{profileButtonMeta.label}</span>
</button>
) : (
<span className="binding-feedback muted">-</span>
)}
</div>
<div className="insight-social-binding-cell">
{isPrivateSession ? (
<>
<div className="insight-social-binding-input-wrap">
<span className="binding-platform-chip">微博</span>
<input
type="text"
className="insight-social-binding-input"
value={weiboDraftValue}
placeholder="填写数字 UID"
onChange={(e) => updateWeiboBindingDraft(session.username, e.target.value)}
/>
</div>
<div className="insight-social-binding-actions">
<button
type="button"
className="btn btn-secondary btn-sm"
onClick={() => void handleSaveWeiboBinding(session.username, session.displayName || session.username)}
disabled={isBindingLoading || !weiboDraftValue.trim()}
>
{isBindingLoading ? '绑定中...' : (weiboBinding ? '更新' : '绑定')}
</button>
{weiboBinding && (
<div className="insight-social-binding-controls">
<div className="insight-social-binding-input-wrap">
<span className="binding-platform-chip">微博</span>
<input
type="text"
className="insight-social-binding-input"
value={weiboDraftValue}
placeholder="填写数字 UID"
onChange={(e) => updateWeiboBindingDraft(session.username, e.target.value)}
/>
</div>
<div className="insight-social-binding-actions">
<button
type="button"
className="btn btn-secondary btn-sm"
onClick={() => void handleClearWeiboBinding(session.username)}
onClick={() => void handleSaveWeiboBinding(session.username, session.displayName || session.username)}
disabled={isBindingLoading || !weiboDraftValue.trim()}
>
清除
{isBindingLoading ? '绑定中...' : (weiboBinding ? '更新' : '绑定')}
</button>
)}
{weiboBinding && (
<button
type="button"
className="btn btn-secondary btn-sm"
onClick={() => void handleClearWeiboBinding(session.username)}
>
清除
</button>
)}
</div>
</div>
<div className="insight-social-binding-feedback">
{weiboBindingError ? (
@@ -4021,6 +4201,320 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
</div>
)
const renderAiGroupSummaryTab = () => {
const groupSummaryPromptDisplayValue = aiGroupSummarySystemPrompt || DEFAULT_GROUP_SUMMARY_SYSTEM_PROMPT
const addToFilterList = async (username: string) => {
if (!username.endsWith('@chatroom') || aiGroupSummaryFilterList.includes(username)) return
const next = [...aiGroupSummaryFilterList, username]
setAiGroupSummaryFilterList(next)
await configService.setAiGroupSummaryFilterList(next)
showMessage('已添加到群聊总结作用域', true)
}
const removeFromFilterList = async (username: string) => {
const next = aiGroupSummaryFilterList.filter((item) => item !== username)
setAiGroupSummaryFilterList(next)
await configService.setAiGroupSummaryFilterList(next)
showMessage('已从群聊总结作用域移除', true)
}
const addAllFiltered = async () => {
const usernames = groupSummaryAvailableSessions.map((session) => session.username)
if (usernames.length === 0) return
const next = Array.from(new Set([...aiGroupSummaryFilterList, ...usernames]))
setAiGroupSummaryFilterList(next)
await configService.setAiGroupSummaryFilterList(next)
showMessage(`已添加 ${usernames.length} 个群聊`, true)
}
const clearFilterList = async () => {
if (aiGroupSummaryFilterList.length === 0) return
setAiGroupSummaryFilterList([])
await configService.setAiGroupSummaryFilterList([])
showMessage('已清空群聊总结作用域', true)
}
return (
<div className="tab-content">
<div className="form-group">
<label>AI 群聊总结</label>
<span className="form-hint">
开启后,群聊页顶部会显示 AI 总结按钮;自动总结只对下面作用域内的群聊生效。未选择任何群聊时不会自动消耗 token。
</span>
<div className="log-toggle-line">
<span className="log-status">{aiGroupSummaryEnabled ? '已开启' : '已关闭'}</span>
<label className="switch">
<input
type="checkbox"
checked={aiGroupSummaryEnabled}
onChange={async (e) => {
const val = e.target.checked
setAiGroupSummaryEnabled(val)
await configService.setAiGroupSummaryEnabled(val)
showMessage(val ? 'AI 群聊总结已开启' : 'AI 群聊总结已关闭', true)
}}
/>
<span className="switch-slider" />
</label>
</div>
</div>
<div className="divider" />
<div className="form-group">
<label>自动总结间隔</label>
<span className="form-hint">
按本地系统时间从当天 00:00 开始切分完整时间段,到点总结上一段。时段内可总结消息少于 5 条时会跳过。
</span>
<div className="push-filter-type-tabs" style={{ marginTop: 10 }}>
{[1, 2, 4, 8, 12, 24].map((hours) => (
<button
key={hours}
type="button"
className={`push-filter-type-tab ${aiGroupSummaryIntervalHours === hours ? 'active' : ''}`}
onClick={() => {
setAiGroupSummaryIntervalHours(hours)
scheduleConfigSave('aiGroupSummaryIntervalHours', () => configService.setAiGroupSummaryIntervalHours(hours))
}}
>
{hours} 小时
</button>
))}
</div>
</div>
<div className="form-group">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12 }}>
<label style={{ marginBottom: 0 }}>群聊总结提示词</label>
<button
type="button"
className="btn btn-secondary btn-sm"
onClick={async () => {
setAiGroupSummarySystemPrompt('')
await configService.setAiGroupSummarySystemPrompt('')
}}
>
恢复默认
</button>
</div>
<span className="form-hint">
群聊总结专用提示词。留空时使用内置默认提示词。
</span>
<textarea
className="field-input ai-prompt-textarea"
rows={10}
style={{ width: '100%', resize: 'vertical', marginTop: 8 }}
value={groupSummaryPromptDisplayValue}
onChange={(e) => {
const val = e.target.value
setAiGroupSummarySystemPrompt(val)
scheduleConfigSave('aiGroupSummarySystemPrompt', () => configService.setAiGroupSummarySystemPrompt(val))
}}
/>
<span className="form-hint" style={{ color: 'var(--danger, #ef4444)', marginTop: 8, display: 'block' }}>
该提示词控制 JSON 输出结构和总结解析路径,不建议随意修改,否则可能导致总结失败或内容错位。
</span>
</div>
<div className="divider" />
<div className="form-group">
<label>自动总结作用域群聊</label>
<span className="form-hint">
仅控制自动总结范围。手动点击群聊页 AI 总结按钮不受作用域限制;未选择任何群聊时自动总结不会运行。
</span>
{aiGroupSummaryFilterList.length === 0 && (
<div className="api-docs" style={{ marginTop: 12 }}>
<div className="api-item">
<p className="api-desc">当前未选择作用域群聊,自动群聊总结不会触发。</p>
</div>
</div>
)}
<div className="notification-filter-container" style={{ marginTop: 12 }}>
<div className="filter-panel">
<div className="filter-panel-header">
<span>可选群聊</span>
{groupSummaryAvailableSessions.length > 0 && (
<button type="button" className="filter-panel-action" onClick={() => { void addAllFiltered() }}>
全选当前
</button>
)}
<div className="filter-search-box">
<Search size={14} />
<input
type="text"
placeholder="搜索群聊..."
value={aiGroupSummaryFilterSearchKeyword}
onChange={(e) => setAiGroupSummaryFilterSearchKeyword(e.target.value)}
/>
</div>
</div>
<div className="filter-panel-list">
{groupSummaryAvailableSessions.length > 0 ? (
groupSummaryAvailableSessions.map(session => (
<div
key={session.username}
className="filter-panel-item"
onClick={() => { void addToFilterList(session.username) }}
>
<Avatar src={session.avatarUrl} name={session.displayName || session.username} size={28} />
<span className="filter-item-name">{session.displayName || session.username}</span>
<span className="filter-item-type">群聊</span>
<span className="filter-item-action">+</span>
</div>
))
) : (
<div className="filter-panel-empty">
{aiGroupSummaryFilterSearchKeyword ? '没有匹配的群聊' : '暂无可添加的群聊'}
</div>
)}
</div>
</div>
<div className="filter-panel">
<div className="filter-panel-header">
<span>作用域群聊</span>
{aiGroupSummaryFilterList.length > 0 && (
<span className="filter-panel-count">{aiGroupSummaryFilterList.length}</span>
)}
{aiGroupSummaryFilterList.length > 0 && (
<button type="button" className="filter-panel-action" onClick={() => { void clearFilterList() }}>
全不选
</button>
)}
</div>
<div className="filter-panel-list">
{aiGroupSummaryFilterList.length > 0 ? (
aiGroupSummaryFilterList.map(username => {
const info = getSessionFilterOptionInfo(username)
return (
<div
key={username}
className="filter-panel-item selected"
onClick={() => { void removeFromFilterList(username) }}
>
<Avatar src={info.avatarUrl} name={info.displayName} size={28} />
<span className="filter-item-name">{info.displayName}</span>
<span className="filter-item-type">群聊</span>
<span className="filter-item-action">×</span>
</div>
)
})
) : (
<div className="filter-panel-empty">尚未添加任何群聊</div>
)}
</div>
</div>
</div>
</div>
</div>
)
}
const renderAiMessageInsightTab = () => (
<div className="tab-content">
{(() => {
const DEFAULT_MESSAGE_INSIGHT_PROMPT = `你是一个克制、准确的聊天语义分析助手。你的任务是把用户选中的一句聊天消息做深度解析,帮助用户理解对方未明说的含义。
严格要求:
1. 必须且只能输出合法的纯 JSON。
2. 禁止输出解释说明、前言后语,禁止使用 Markdown 或代码块。
3. 不要编造上下文没有支持的信息;不确定时用谨慎表述。
4. explicit_text 用自然中文说明这句话可能想表达的真实含义,80字以内。
5. emotion、intent、topic 必须是短标签。
JSON 输出格式:
{
"explicit_text": "暗示转明示,80字以内",
"emotion": "2-6字情绪标签",
"intent": "2-8字意图标签",
"topic": "2-8字话题标签"
}`
const displayValue = aiMessageInsightSystemPrompt || DEFAULT_MESSAGE_INSIGHT_PROMPT
return (
<>
<div className="form-group">
<label>消息深度解析</label>
<span className="form-hint">
开启后,在聊天页悬停对方文本消息时显示深度解析入口。点击后按需调用 AI,解析结果会保存到灵感信箱。
</span>
<div className="log-toggle-line">
<span className="log-status">{aiMessageInsightEnabled ? '已开启' : '已关闭'}</span>
<label className="switch">
<input
type="checkbox"
checked={aiMessageInsightEnabled}
onChange={async (e) => {
const val = e.target.checked
setAiMessageInsightEnabled(val)
await configService.setAiMessageInsightEnabled(val)
}}
/>
<span className="switch-slider" />
</label>
</div>
</div>
<div className="form-group">
<label>发送上下文对话条数</label>
<span className="form-hint">
围绕选中消息向前、向后各取一半;一侧不足时自动由另一侧补齐。条数越多分析越准确,token 消耗也越多。
</span>
<input
type="number"
className="field-input"
value={aiMessageInsightContextCount}
min={1}
max={200}
onChange={(e) => {
const val = Math.max(1, Math.min(200, parseInt(e.target.value, 10) || 50))
setAiMessageInsightContextCount(val)
scheduleConfigSave('aiMessageInsightContextCount', () => configService.setAiMessageInsightContextCount(val))
}}
style={{ width: 100 }}
/>
</div>
<div className="form-group">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 6 }}>
<label style={{ marginBottom: 0 }}>消息解析提示词</label>
<button
className="btn btn-secondary btn-sm"
onClick={async () => {
setAiMessageInsightSystemPrompt('')
await configService.setAiMessageInsightSystemPrompt('')
}}
>
恢复默认
</button>
</div>
<span className="form-hint">
消息解析专用提示词。留空时使用内置默认提示词。
</span>
<textarea
className="field-input ai-prompt-textarea"
rows={10}
style={{ width: '100%', resize: 'vertical' }}
value={displayValue}
onChange={(e) => {
const val = e.target.value
setAiMessageInsightSystemPrompt(val)
scheduleConfigSave('aiMessageInsightSystemPrompt', () => configService.setAiMessageInsightSystemPrompt(val))
}}
/>
<span className="form-hint" style={{ color: 'var(--danger, #ef4444)', marginTop: 8, display: 'block' }}>
该提示词控制 JSON 输出结构和解析口径,不建议随意修改,否则可能导致解析失败或内容错位。
</span>
</div>
</>
)
})()}
</div>
)
const renderApiTab = () => (
<div className="tab-content">
<div className="form-group">
@@ -5049,7 +5543,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
row.push(
<div key="ai-settings-group" className={`tab-group ${aiGroupExpanded ? 'expanded' : ''}`}>
<button
className={`tab-btn tab-group-trigger ${(activeTab === 'aiCommon' || activeTab === 'insight' || activeTab === 'aiFootprint') ? 'active' : ''}`}
className={`tab-btn tab-group-trigger ${(activeTab === 'aiCommon' || activeTab === 'insight' || activeTab === 'aiFootprint' || activeTab === 'aiGroupSummary' || activeTab === 'aiMessageInsight') ? 'active' : ''}`}
onClick={() => setAiGroupExpanded((prev) => !prev)}
aria-expanded={aiGroupExpanded}
>
@@ -5091,6 +5585,8 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
{activeTab === 'aiCommon' && renderAiCommonTab()}
{activeTab === 'insight' && renderInsightTab()}
{activeTab === 'aiFootprint' && renderAiFootprintTab()}
{activeTab === 'aiGroupSummary' && renderAiGroupSummaryTab()}
{activeTab === 'aiMessageInsight' && renderAiMessageInsightTab()}
{activeTab === 'autoDownload' && renderAutoDownloadTab()}
{activeTab === 'updates' && renderUpdatesTab()}
{activeTab === 'analytics' && renderAnalyticsTab()}
+6 -1
View File
@@ -201,6 +201,7 @@
padding: 12px 16px;
margin-left: -4px;
margin-right: -4px;
overflow: hidden;
// 遮挡流程线
&::before {
@@ -212,7 +213,7 @@
width: 20px;
background: var(--primary);
border-radius: 12px 0 0 12px;
z-index: -1;
z-index: 0;
}
}
@@ -234,6 +235,8 @@
flex-shrink: 0;
margin-top: 2px;
transition: all 0.3s ease;
position: relative;
z-index: 1;
[data-mode="dark"] .welcome-sidebar & {
border-color: rgba(255, 255, 255, 0.2); // 稍微调亮边框
@@ -273,6 +276,8 @@
display: flex;
flex-direction: column;
transition: transform 0.3s ease;
position: relative;
z-index: 1;
}
.nav-title {