mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-04-22 15:09:04 +00:00
Merge branch 'dev' into feat-chatlab
This commit is contained in:
12
src/App.tsx
12
src/App.tsx
@@ -80,6 +80,7 @@ function App() {
|
||||
const isChatHistoryWindow = location.pathname.startsWith('/chat-history/') || location.pathname.startsWith('/chat-history-inline/')
|
||||
const isStandaloneChatWindow = location.pathname === '/chat-window'
|
||||
const isNotificationWindow = location.pathname === '/notification-window'
|
||||
const isAnnualReportWindow = location.pathname === '/annual-report/view'
|
||||
const isSettingsRoute = location.pathname === '/settings'
|
||||
const settingsRouteState = location.state as { backgroundLocation?: Location; initialTab?: unknown } | null
|
||||
const routeLocation = isSettingsRoute
|
||||
@@ -127,7 +128,7 @@ function App() {
|
||||
const body = document.body
|
||||
const appRoot = document.getElementById('app')
|
||||
|
||||
if (isOnboardingWindow || isNotificationWindow) {
|
||||
if (isOnboardingWindow || isNotificationWindow || isAnnualReportWindow) {
|
||||
root.style.background = 'transparent'
|
||||
body.style.background = 'transparent'
|
||||
body.style.overflow = 'hidden'
|
||||
@@ -144,7 +145,7 @@ function App() {
|
||||
appRoot.style.overflow = ''
|
||||
}
|
||||
}
|
||||
}, [isOnboardingWindow])
|
||||
}, [isOnboardingWindow, isNotificationWindow, isAnnualReportWindow])
|
||||
|
||||
// 应用主题
|
||||
useEffect(() => {
|
||||
@@ -165,7 +166,7 @@ function App() {
|
||||
}
|
||||
mq.addEventListener('change', handler)
|
||||
return () => mq.removeEventListener('change', handler)
|
||||
}, [currentTheme, themeMode, isOnboardingWindow, isNotificationWindow])
|
||||
}, [currentTheme, themeMode, isOnboardingWindow, isNotificationWindow, isAnnualReportWindow])
|
||||
|
||||
// 读取已保存的主题设置
|
||||
useEffect(() => {
|
||||
@@ -511,6 +512,11 @@ function App() {
|
||||
return <NotificationWindow />
|
||||
}
|
||||
|
||||
// 独立年度报告全屏窗口
|
||||
if (isAnnualReportWindow) {
|
||||
return <AnnualReportWindow />
|
||||
}
|
||||
|
||||
// 主窗口 - 完整布局
|
||||
const handleCloseSettings = () => {
|
||||
const backgroundLocation = settingsRouteState?.backgroundLocation ?? settingsBackgroundRef.current
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
.annual-report-page {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
@@ -8,6 +9,11 @@
|
||||
padding: 40px 24px;
|
||||
}
|
||||
|
||||
.annual-report-page.report-route-transitioning > :not(.report-launch-overlay) {
|
||||
animation: report-page-exit 420ms cubic-bezier(0.4, 0, 0.2, 1) both;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
color: var(--primary);
|
||||
margin-bottom: 16px;
|
||||
@@ -199,6 +205,11 @@
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
pointer-events: none;
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
&.selected {
|
||||
border-color: var(--primary);
|
||||
background: var(--primary-light);
|
||||
@@ -251,6 +262,10 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&.is-pending {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&.secondary {
|
||||
background: var(--card-bg);
|
||||
color: var(--text-primary);
|
||||
@@ -259,6 +274,40 @@
|
||||
}
|
||||
}
|
||||
|
||||
.report-launch-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: color-mix(in srgb, var(--bg-primary) 78%, transparent);
|
||||
backdrop-filter: blur(8px);
|
||||
animation: report-launch-overlay-in 420ms ease-out both;
|
||||
}
|
||||
|
||||
.launch-core {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
text-align: center;
|
||||
color: var(--text-primary);
|
||||
animation: report-launch-core-in 420ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
|
||||
}
|
||||
|
||||
.launch-title {
|
||||
margin: 4px 0 0;
|
||||
font-size: 18px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.launch-subtitle {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
@@ -271,3 +320,36 @@
|
||||
@keyframes dot-ellipsis {
|
||||
to { width: 1.4em; }
|
||||
}
|
||||
|
||||
@keyframes report-page-exit {
|
||||
from {
|
||||
opacity: 1;
|
||||
filter: blur(0);
|
||||
transform: scale(1);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
filter: blur(8px);
|
||||
transform: scale(0.985);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes report-launch-overlay-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes report-launch-core-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(18px) scale(0.96);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Calendar, Loader2, Sparkles, Users } from 'lucide-react'
|
||||
import {
|
||||
@@ -25,6 +25,8 @@ type YearsLoadPayload = {
|
||||
nativeTimedOut?: boolean
|
||||
}
|
||||
|
||||
const REPORT_LAUNCH_DELAY_MS = 420
|
||||
|
||||
const formatLoadElapsed = (ms: number) => {
|
||||
const totalSeconds = Math.max(0, ms) / 1000
|
||||
if (totalSeconds < 60) return `${totalSeconds.toFixed(1)}s`
|
||||
@@ -50,7 +52,10 @@ function AnnualReportPage() {
|
||||
const [hasSwitchedStrategy, setHasSwitchedStrategy] = useState(false)
|
||||
const [nativeTimedOut, setNativeTimedOut] = useState(false)
|
||||
const [isGenerating, setIsGenerating] = useState(false)
|
||||
const [isRouteTransitioning, setIsRouteTransitioning] = useState(false)
|
||||
const [launchingYearLabel, setLaunchingYearLabel] = useState('')
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
const launchTimerRef = useRef<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false
|
||||
@@ -186,21 +191,37 @@ function AnnualReportPage() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleGenerateReport = async () => {
|
||||
if (selectedYear === null) return
|
||||
setIsGenerating(true)
|
||||
try {
|
||||
const yearParam = selectedYear === 'all' ? 0 : selectedYear
|
||||
navigate(`/annual-report/view?year=${yearParam}`)
|
||||
} catch (e) {
|
||||
console.error('生成报告失败:', e)
|
||||
} finally {
|
||||
setIsGenerating(false)
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (launchTimerRef.current !== null) {
|
||||
window.clearTimeout(launchTimerRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleGenerateReport = () => {
|
||||
if (selectedYear === null || isRouteTransitioning) return
|
||||
const yearParam = selectedYear === 'all' ? 0 : selectedYear
|
||||
const yearLabel = selectedYear === 'all' ? '全部时间' : `${selectedYear}年`
|
||||
setIsGenerating(true)
|
||||
setIsRouteTransitioning(true)
|
||||
setLaunchingYearLabel(yearLabel)
|
||||
if (launchTimerRef.current !== null) {
|
||||
window.clearTimeout(launchTimerRef.current)
|
||||
}
|
||||
launchTimerRef.current = window.setTimeout(() => {
|
||||
try {
|
||||
navigate(`/annual-report/view?year=${yearParam}`)
|
||||
} catch (e) {
|
||||
console.error('生成报告失败:', e)
|
||||
setIsGenerating(false)
|
||||
setIsRouteTransitioning(false)
|
||||
}
|
||||
}, REPORT_LAUNCH_DELAY_MS)
|
||||
}
|
||||
|
||||
const handleGenerateDualReport = () => {
|
||||
if (selectedPairYear === null) return
|
||||
if (selectedPairYear === null || isRouteTransitioning) return
|
||||
const yearParam = selectedPairYear === 'all' ? 0 : selectedPairYear
|
||||
navigate(`/dual-report?year=${yearParam}`)
|
||||
}
|
||||
@@ -251,7 +272,7 @@ function AnnualReportPage() {
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="annual-report-page">
|
||||
<div className={`annual-report-page ${isRouteTransitioning ? 'report-route-transitioning' : ''}`}>
|
||||
<Sparkles size={32} className="header-icon" />
|
||||
<h1 className="page-title">年度报告</h1>
|
||||
<p className="page-desc">选择年份,回顾你在微信里的点点滴滴</p>
|
||||
@@ -270,8 +291,11 @@ function AnnualReportPage() {
|
||||
{yearOptions.map(option => (
|
||||
<div
|
||||
key={option}
|
||||
className={`year-card ${option === 'all' ? 'all-time' : ''} ${selectedYear === option ? 'selected' : ''}`}
|
||||
onClick={() => setSelectedYear(option)}
|
||||
className={`year-card ${option === 'all' ? 'all-time' : ''} ${selectedYear === option ? 'selected' : ''} ${isRouteTransitioning ? 'disabled' : ''}`}
|
||||
onClick={() => {
|
||||
if (isRouteTransitioning) return
|
||||
setSelectedYear(option)
|
||||
}}
|
||||
>
|
||||
<span className="year-number">{option === 'all' ? '全部' : option}</span>
|
||||
<span className="year-label">{option === 'all' ? '时间' : '年'}</span>
|
||||
@@ -281,14 +305,14 @@ function AnnualReportPage() {
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="generate-btn"
|
||||
className={`generate-btn ${isRouteTransitioning ? 'is-pending' : ''}`}
|
||||
onClick={handleGenerateReport}
|
||||
disabled={!selectedYear || isGenerating}
|
||||
disabled={!selectedYear || isGenerating || isRouteTransitioning}
|
||||
>
|
||||
{isGenerating ? (
|
||||
<>
|
||||
<Loader2 size={20} className="spin" />
|
||||
<span>正在生成...</span>
|
||||
<span>{isRouteTransitioning ? '正在进入报告...' : '正在生成...'}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -316,8 +340,11 @@ function AnnualReportPage() {
|
||||
{yearOptions.map(option => (
|
||||
<div
|
||||
key={`pair-${option}`}
|
||||
className={`year-card ${option === 'all' ? 'all-time' : ''} ${selectedPairYear === option ? 'selected' : ''}`}
|
||||
onClick={() => setSelectedPairYear(option)}
|
||||
className={`year-card ${option === 'all' ? 'all-time' : ''} ${selectedPairYear === option ? 'selected' : ''} ${isRouteTransitioning ? 'disabled' : ''}`}
|
||||
onClick={() => {
|
||||
if (isRouteTransitioning) return
|
||||
setSelectedPairYear(option)
|
||||
}}
|
||||
>
|
||||
<span className="year-number">{option === 'all' ? '全部' : option}</span>
|
||||
<span className="year-label">{option === 'all' ? '时间' : '年'}</span>
|
||||
@@ -327,9 +354,9 @@ function AnnualReportPage() {
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="generate-btn secondary"
|
||||
className={`generate-btn secondary ${isRouteTransitioning ? 'is-pending' : ''}`}
|
||||
onClick={handleGenerateDualReport}
|
||||
disabled={!selectedPairYear}
|
||||
disabled={!selectedPairYear || isRouteTransitioning}
|
||||
>
|
||||
<Users size={20} />
|
||||
<span>选择好友并生成报告</span>
|
||||
@@ -337,6 +364,16 @@ function AnnualReportPage() {
|
||||
<p className="section-hint">从聊天排行中选择好友生成双人报告</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{isRouteTransitioning && (
|
||||
<div className="report-launch-overlay" role="status" aria-live="polite">
|
||||
<div className="launch-core">
|
||||
<Loader2 size={30} className="spin" />
|
||||
<p className="launch-title">正在进入{launchingYearLabel}年度报告</p>
|
||||
<p className="launch-subtitle">正在整理你的聊天记忆...</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1898,6 +1898,9 @@ const TaskCenterModal = memo(function TaskCenterModal({
|
||||
const mediaCacheMetricLabel = mediaCacheTotal > 0
|
||||
? `缓存命中 ${mediaCacheHitFiles}/${mediaCacheTotal}`
|
||||
: ''
|
||||
const mediaMissMetricLabel = mediaCacheMissFiles > 0
|
||||
? `未导出 ${mediaCacheMissFiles} 个文件/媒体`
|
||||
: ''
|
||||
const mediaDedupMetricLabel = mediaDedupReuseFiles > 0
|
||||
? `复用 ${mediaDedupReuseFiles}`
|
||||
: ''
|
||||
@@ -1958,6 +1961,7 @@ const TaskCenterModal = memo(function TaskCenterModal({
|
||||
{phaseMetricLabel ? ` · ${phaseMetricLabel}` : ''}
|
||||
{mediaLiveMetricLabel ? ` · ${mediaLiveMetricLabel}` : ''}
|
||||
{mediaCacheMetricLabel ? ` · ${mediaCacheMetricLabel}` : ''}
|
||||
{mediaMissMetricLabel ? ` · ${mediaMissMetricLabel}` : ''}
|
||||
{mediaDedupMetricLabel ? ` · ${mediaDedupMetricLabel}` : ''}
|
||||
{task.status === 'running' && currentSessionRatio !== null
|
||||
? `(当前会话 ${Math.round(currentSessionRatio * 100)}%)`
|
||||
|
||||
@@ -75,6 +75,7 @@ interface WxidOption {
|
||||
type SessionFilterType = configService.MessagePushSessionType
|
||||
type SessionFilterTypeValue = 'all' | SessionFilterType
|
||||
type SessionFilterMode = 'all' | 'whitelist' | 'blacklist'
|
||||
type InsightSessionFilterTypeValue = 'all' | 'private' | 'group' | 'official'
|
||||
|
||||
interface SessionFilterOption {
|
||||
username: string
|
||||
@@ -91,6 +92,13 @@ const sessionFilterTypeOptions: Array<{ value: SessionFilterTypeValue; label: st
|
||||
{ value: 'other', label: '其他/非好友' }
|
||||
]
|
||||
|
||||
const insightFilterTypeOptions: Array<{ value: InsightSessionFilterTypeValue; label: string }> = [
|
||||
{ value: 'all', label: '全部' },
|
||||
{ value: 'private', label: '私聊' },
|
||||
{ value: 'group', label: '群聊' },
|
||||
{ value: 'official', label: '订阅号/服务号' }
|
||||
]
|
||||
|
||||
interface SettingsPageProps {
|
||||
onClose?: () => void
|
||||
}
|
||||
@@ -194,6 +202,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
const [filterModeDropdownOpen, setFilterModeDropdownOpen] = useState(false)
|
||||
const [positionDropdownOpen, setPositionDropdownOpen] = useState(false)
|
||||
const [closeBehaviorDropdownOpen, setCloseBehaviorDropdownOpen] = useState(false)
|
||||
const [insightFilterModeDropdownOpen, setInsightFilterModeDropdownOpen] = useState(false)
|
||||
|
||||
const [wordCloudExcludeWords, setWordCloudExcludeWords] = useState<string[]>([])
|
||||
const [excludeWordsInput, setExcludeWordsInput] = useState('')
|
||||
@@ -275,8 +284,9 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
const [showInsightApiKey, setShowInsightApiKey] = useState(false)
|
||||
const [isTriggeringInsightTest, setIsTriggeringInsightTest] = useState(false)
|
||||
const [insightTriggerResult, setInsightTriggerResult] = useState<{ success: boolean; message: string } | null>(null)
|
||||
const [aiInsightWhitelistEnabled, setAiInsightWhitelistEnabled] = useState(false)
|
||||
const [aiInsightWhitelist, setAiInsightWhitelist] = useState<Set<string>>(new Set())
|
||||
const [aiInsightFilterMode, setAiInsightFilterMode] = useState<configService.AiInsightFilterMode>('whitelist')
|
||||
const [aiInsightFilterList, setAiInsightFilterList] = useState<Set<string>>(new Set())
|
||||
const [insightFilterType, setInsightFilterType] = useState<InsightSessionFilterTypeValue>('all')
|
||||
const [insightWhitelistSearch, setInsightWhitelistSearch] = useState('')
|
||||
const [aiInsightCooldownMinutes, setAiInsightCooldownMinutes] = useState(120)
|
||||
const [aiInsightScanIntervalHours, setAiInsightScanIntervalHours] = useState(4)
|
||||
@@ -397,15 +407,16 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
setPositionDropdownOpen(false)
|
||||
setCloseBehaviorDropdownOpen(false)
|
||||
setMessagePushFilterDropdownOpen(false)
|
||||
setInsightFilterModeDropdownOpen(false)
|
||||
}
|
||||
}
|
||||
if (filterModeDropdownOpen || positionDropdownOpen || closeBehaviorDropdownOpen || messagePushFilterDropdownOpen) {
|
||||
if (filterModeDropdownOpen || positionDropdownOpen || closeBehaviorDropdownOpen || messagePushFilterDropdownOpen || insightFilterModeDropdownOpen) {
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
}
|
||||
}, [closeBehaviorDropdownOpen, filterModeDropdownOpen, messagePushFilterDropdownOpen, positionDropdownOpen])
|
||||
}, [closeBehaviorDropdownOpen, filterModeDropdownOpen, insightFilterModeDropdownOpen, messagePushFilterDropdownOpen, positionDropdownOpen])
|
||||
|
||||
|
||||
const loadConfig = async () => {
|
||||
@@ -531,8 +542,8 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
const savedAiModelApiMaxTokens = await configService.getAiModelApiMaxTokens()
|
||||
const savedAiInsightSilenceDays = await configService.getAiInsightSilenceDays()
|
||||
const savedAiInsightAllowContext = await configService.getAiInsightAllowContext()
|
||||
const savedAiInsightWhitelistEnabled = await configService.getAiInsightWhitelistEnabled()
|
||||
const savedAiInsightWhitelist = await configService.getAiInsightWhitelist()
|
||||
const savedAiInsightFilterMode = await configService.getAiInsightFilterMode()
|
||||
const savedAiInsightFilterList = await configService.getAiInsightFilterList()
|
||||
const savedAiInsightCooldownMinutes = await configService.getAiInsightCooldownMinutes()
|
||||
const savedAiInsightScanIntervalHours = await configService.getAiInsightScanIntervalHours()
|
||||
const savedAiInsightContextCount = await configService.getAiInsightContextCount()
|
||||
@@ -555,8 +566,8 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
setAiModelApiMaxTokens(savedAiModelApiMaxTokens)
|
||||
setAiInsightSilenceDays(savedAiInsightSilenceDays)
|
||||
setAiInsightAllowContext(savedAiInsightAllowContext)
|
||||
setAiInsightWhitelistEnabled(savedAiInsightWhitelistEnabled)
|
||||
setAiInsightWhitelist(new Set(savedAiInsightWhitelist))
|
||||
setAiInsightFilterMode(savedAiInsightFilterMode)
|
||||
setAiInsightFilterList(new Set(savedAiInsightFilterList))
|
||||
setAiInsightCooldownMinutes(savedAiInsightCooldownMinutes)
|
||||
setAiInsightScanIntervalHours(savedAiInsightScanIntervalHours)
|
||||
setAiInsightContextCount(savedAiInsightContextCount)
|
||||
@@ -3390,98 +3401,129 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
|
||||
<div className="divider" />
|
||||
|
||||
{/* 对话白名单 */}
|
||||
{/* 对话过滤名单 */}
|
||||
{(() => {
|
||||
const sortedSessions = [...chatSessions].sort((a, b) => (b.sortTimestamp || 0) - (a.sortTimestamp || 0))
|
||||
const selectableSessions = sessionFilterOptions.filter((session) =>
|
||||
session.type === 'private' || session.type === 'group' || session.type === 'official'
|
||||
)
|
||||
const keyword = insightWhitelistSearch.trim().toLowerCase()
|
||||
const filteredSessions = sortedSessions.filter((s) => {
|
||||
const id = s.username?.trim() || ''
|
||||
if (!id || id.endsWith('@chatroom') || id.toLowerCase().includes('placeholder')) return false
|
||||
const filteredSessions = selectableSessions.filter((session) => {
|
||||
if (insightFilterType !== 'all' && session.type !== insightFilterType) return false
|
||||
const id = session.username?.trim() || ''
|
||||
if (!id || id.toLowerCase().includes('placeholder')) return false
|
||||
if (!keyword) return true
|
||||
return (
|
||||
String(s.displayName || '').toLowerCase().includes(keyword) ||
|
||||
String(session.displayName || '').toLowerCase().includes(keyword) ||
|
||||
id.toLowerCase().includes(keyword)
|
||||
)
|
||||
})
|
||||
const filteredIds = filteredSessions.map((s) => s.username)
|
||||
const selectedCount = aiInsightWhitelist.size
|
||||
const selectedInFilteredCount = filteredIds.filter((id) => aiInsightWhitelist.has(id)).length
|
||||
const filteredIds = filteredSessions.map((session) => session.username)
|
||||
const selectedCount = aiInsightFilterList.size
|
||||
const selectedInFilteredCount = filteredIds.filter((id) => aiInsightFilterList.has(id)).length
|
||||
const allFilteredSelected = filteredIds.length > 0 && selectedInFilteredCount === filteredIds.length
|
||||
|
||||
const toggleSession = (id: string) => {
|
||||
setAiInsightWhitelist((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
const saveFilterList = async (next: Set<string>) => {
|
||||
await configService.setAiInsightFilterList(Array.from(next))
|
||||
}
|
||||
|
||||
const saveWhitelist = async (next: Set<string>) => {
|
||||
await configService.setAiInsightWhitelist(Array.from(next))
|
||||
const saveFilterMode = async (mode: configService.AiInsightFilterMode) => {
|
||||
setAiInsightFilterMode(mode)
|
||||
setInsightFilterModeDropdownOpen(false)
|
||||
await configService.setAiInsightFilterMode(mode)
|
||||
showMessage(mode === 'whitelist' ? '已切换为白名单模式' : '已切换为黑名单模式', true)
|
||||
}
|
||||
|
||||
const selectAllFiltered = () => {
|
||||
setAiInsightWhitelist((prev) => {
|
||||
setAiInsightFilterList((prev) => {
|
||||
const next = new Set(prev)
|
||||
for (const id of filteredIds) next.add(id)
|
||||
void saveWhitelist(next)
|
||||
void saveFilterList(next)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const clearSelection = () => {
|
||||
const next = new Set<string>()
|
||||
setAiInsightWhitelist(next)
|
||||
void saveWhitelist(next)
|
||||
setAiInsightFilterList(next)
|
||||
void saveFilterList(next)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="anti-revoke-tab insight-social-tab">
|
||||
<div className="anti-revoke-hero">
|
||||
<div className="anti-revoke-hero-main">
|
||||
<h3>对话白名单</h3>
|
||||
<h3>对话黑白名单</h3>
|
||||
<p>
|
||||
开启后,AI 见解仅对勾选的私聊对话生效,未勾选的对话将被完全忽略。关闭时对所有私聊均生效。中间可填写微博 UID。
|
||||
白名单模式下仅对已选会话触发见解;黑名单模式下会跳过已选会话。默认白名单且不选择任何会话。支持私聊、群聊、订阅号/服务号分类筛选后批量选择。
|
||||
</p>
|
||||
</div>
|
||||
<div className="anti-revoke-metrics">
|
||||
<div className="anti-revoke-metric is-total">
|
||||
<span className="label">私聊总数</span>
|
||||
<span className="value">{filteredIds.length + (keyword ? 0 : 0)}</span>
|
||||
<span className="label">可选会话总数</span>
|
||||
<span className="value">{selectableSessions.length}</span>
|
||||
</div>
|
||||
<div className="anti-revoke-metric is-installed">
|
||||
<span className="label">已选中</span>
|
||||
<span className="label">已加入名单</span>
|
||||
<span className="value">{selectedCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="log-toggle-line" style={{ marginBottom: 12 }}>
|
||||
<span className="log-status" style={{ fontWeight: 600 }}>
|
||||
{aiInsightWhitelistEnabled ? '白名单已启用(仅对勾选对话生效)' : '白名单未启用(对所有私聊生效)'}
|
||||
</span>
|
||||
<label className="switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={aiInsightWhitelistEnabled}
|
||||
onChange={async (e) => {
|
||||
const val = e.target.checked
|
||||
setAiInsightWhitelistEnabled(val)
|
||||
await configService.setAiInsightWhitelistEnabled(val)
|
||||
}}
|
||||
/>
|
||||
<span className="switch-slider" />
|
||||
</label>
|
||||
<div className="form-group" style={{ marginBottom: 12 }}>
|
||||
<div className="log-toggle-line">
|
||||
<span className="log-status" style={{ fontWeight: 600 }}>
|
||||
{aiInsightFilterMode === 'whitelist'
|
||||
? '白名单模式(仅对名单内会话生效)'
|
||||
: '黑名单模式(名单内会话将被忽略)'}
|
||||
</span>
|
||||
<div className="custom-select" style={{ minWidth: 210 }}>
|
||||
<div
|
||||
className={`custom-select-trigger ${insightFilterModeDropdownOpen ? 'open' : ''}`}
|
||||
onClick={() => setInsightFilterModeDropdownOpen(!insightFilterModeDropdownOpen)}
|
||||
>
|
||||
<span className="custom-select-value">
|
||||
{aiInsightFilterMode === 'whitelist' ? '白名单模式' : '黑名单模式'}
|
||||
</span>
|
||||
<ChevronDown size={14} className={`custom-select-arrow ${insightFilterModeDropdownOpen ? 'rotate' : ''}`} />
|
||||
</div>
|
||||
<div className={`custom-select-dropdown ${insightFilterModeDropdownOpen ? 'open' : ''}`}>
|
||||
{[
|
||||
{ value: 'whitelist', label: '白名单模式' },
|
||||
{ value: 'blacklist', label: '黑名单模式' }
|
||||
].map(option => (
|
||||
<div
|
||||
key={option.value}
|
||||
className={`custom-select-option ${aiInsightFilterMode === option.value ? 'selected' : ''}`}
|
||||
onClick={() => { void saveFilterMode(option.value as configService.AiInsightFilterMode) }}
|
||||
>
|
||||
{option.label}
|
||||
{aiInsightFilterMode === option.value && <Check size={14} />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="anti-revoke-control-card">
|
||||
<div className="push-filter-type-tabs" style={{ marginBottom: 10 }}>
|
||||
{insightFilterTypeOptions.map(option => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
className={`push-filter-type-tab ${insightFilterType === option.value ? 'active' : ''}`}
|
||||
onClick={() => setInsightFilterType(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="anti-revoke-toolbar">
|
||||
<div className="filter-search-box anti-revoke-search">
|
||||
<Search size={14} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索私聊对话..."
|
||||
placeholder="搜索对话..."
|
||||
value={insightWhitelistSearch}
|
||||
onChange={(e) => setInsightWhitelistSearch(e.target.value)}
|
||||
/>
|
||||
@@ -3517,7 +3559,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
<div className="anti-revoke-list">
|
||||
{filteredSessions.length === 0 ? (
|
||||
<div className="anti-revoke-empty">
|
||||
{insightWhitelistSearch ? '没有匹配的对话' : '暂无私聊对话'}
|
||||
{insightWhitelistSearch || insightFilterType !== 'all' ? '没有匹配的对话' : '暂无可选对话'}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
@@ -3527,7 +3569,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
<span>状态</span>
|
||||
</div>
|
||||
{filteredSessions.map((session) => {
|
||||
const isSelected = aiInsightWhitelist.has(session.username)
|
||||
const isSelected = aiInsightFilterList.has(session.username)
|
||||
const weiboBinding = aiInsightWeiboBindings[session.username]
|
||||
const weiboDraftValue = getWeiboBindingDraftValue(session.username)
|
||||
const isBindingLoading = weiboBindingLoadingSessionId === session.username
|
||||
@@ -3543,11 +3585,11 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={async () => {
|
||||
setAiInsightWhitelist((prev) => {
|
||||
setAiInsightFilterList((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(session.username)) next.delete(session.username)
|
||||
else next.add(session.username)
|
||||
void configService.setAiInsightWhitelist(Array.from(next))
|
||||
void configService.setAiInsightFilterList(Array.from(next))
|
||||
return next
|
||||
})
|
||||
}}
|
||||
@@ -3563,54 +3605,65 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
/>
|
||||
<div className="anti-revoke-row-text">
|
||||
<span className="name">{session.displayName || session.username}</span>
|
||||
<span className="desc">{getSessionFilterTypeLabel(session.type)}</span>
|
||||
</div>
|
||||
</label>
|
||||
<div className="insight-social-binding-cell">
|
||||
<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 && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={() => void handleClearWeiboBinding(session.username)}
|
||||
>
|
||||
清除
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="insight-social-binding-feedback">
|
||||
{weiboBindingError ? (
|
||||
<span className="binding-feedback error">{weiboBindingError}</span>
|
||||
) : weiboBinding?.screenName ? (
|
||||
<span className="binding-feedback">@{weiboBinding.screenName}</span>
|
||||
) : weiboBinding?.uid ? (
|
||||
<span className="binding-feedback">已绑定 UID:{weiboBinding.uid}</span>
|
||||
) : (
|
||||
<span className="binding-feedback muted">仅支持手动填写数字 UID</span>
|
||||
)}
|
||||
</div>
|
||||
{session.type === 'private' ? (
|
||||
<>
|
||||
<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 && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={() => void handleClearWeiboBinding(session.username)}
|
||||
>
|
||||
清除
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="insight-social-binding-feedback">
|
||||
{weiboBindingError ? (
|
||||
<span className="binding-feedback error">{weiboBindingError}</span>
|
||||
) : weiboBinding?.screenName ? (
|
||||
<span className="binding-feedback">@{weiboBinding.screenName}</span>
|
||||
) : weiboBinding?.uid ? (
|
||||
<span className="binding-feedback">已绑定 UID:{weiboBinding.uid}</span>
|
||||
) : (
|
||||
<span className="binding-feedback muted">仅支持手动填写数字 UID</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="insight-social-binding-feedback">
|
||||
<span className="binding-feedback muted">仅私聊支持微博绑定</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="anti-revoke-row-status">
|
||||
<span className={`status-badge ${isSelected ? 'installed' : 'not-installed'}`}>
|
||||
<i className="status-dot" aria-hidden="true" />
|
||||
{isSelected ? '已加入' : '未加入'}
|
||||
{isSelected
|
||||
? (aiInsightFilterMode === 'whitelist' ? '已允许' : '已屏蔽')
|
||||
: (aiInsightFilterMode === 'whitelist' ? '未允许' : '允许')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -3631,7 +3684,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
<div className="api-docs">
|
||||
<div className="api-item">
|
||||
<p className="api-desc" style={{ lineHeight: 1.7 }}>
|
||||
<strong>触发方式一:活跃会话分析</strong> — 每当微信数据库变化(即你收到新消息)时,经过 500ms 防抖后,对最近活跃的私聊会话进行分析。<br />
|
||||
<strong>触发方式一:活跃会话分析</strong> — 每当微信数据库变化(即你收到新消息)时,经过 500ms 防抖后,对符合黑白名单规则的活跃会话进行分析。<br />
|
||||
<strong>触发方式二:沉默扫描</strong> — 每 4 小时独立扫描一次,对超过阈值天数无消息的联系人发出提醒。<br />
|
||||
<strong>时间观念</strong> — 每次调用时,AI 会收到今天已向该联系人和全局发出过多少次见解,由 AI 自行决定是否需要克制。<br />
|
||||
<strong>隐私</strong> — 所有分析请求均直接从你的电脑发往你填写的 API 地址,不经过任何 WeFlow 服务器。
|
||||
|
||||
@@ -97,6 +97,8 @@ export const CONFIG_KEYS = {
|
||||
AI_INSIGHT_SILENCE_DAYS: 'aiInsightSilenceDays',
|
||||
AI_INSIGHT_ALLOW_CONTEXT: 'aiInsightAllowContext',
|
||||
AI_INSIGHT_ALLOW_SOCIAL_CONTEXT: 'aiInsightAllowSocialContext',
|
||||
AI_INSIGHT_FILTER_MODE: 'aiInsightFilterMode',
|
||||
AI_INSIGHT_FILTER_LIST: 'aiInsightFilterList',
|
||||
AI_INSIGHT_WHITELIST_ENABLED: 'aiInsightWhitelistEnabled',
|
||||
AI_INSIGHT_WHITELIST: 'aiInsightWhitelist',
|
||||
AI_INSIGHT_COOLDOWN_MINUTES: 'aiInsightCooldownMinutes',
|
||||
@@ -1917,22 +1919,49 @@ export async function setAiInsightAllowSocialContext(allow: boolean): Promise<vo
|
||||
await config.set(CONFIG_KEYS.AI_INSIGHT_ALLOW_SOCIAL_CONTEXT, allow)
|
||||
}
|
||||
|
||||
export type AiInsightFilterMode = 'whitelist' | 'blacklist'
|
||||
|
||||
const normalizeAiInsightFilterList = (value: unknown): string[] => {
|
||||
if (!Array.isArray(value)) return []
|
||||
return Array.from(new Set(value.map((item) => String(item || '').trim()).filter(Boolean)))
|
||||
}
|
||||
|
||||
export async function getAiInsightFilterMode(): Promise<AiInsightFilterMode> {
|
||||
const value = await config.get(CONFIG_KEYS.AI_INSIGHT_FILTER_MODE)
|
||||
if (value === 'blacklist') return 'blacklist'
|
||||
if (value === 'whitelist') return 'whitelist'
|
||||
return 'whitelist'
|
||||
}
|
||||
|
||||
export async function setAiInsightFilterMode(mode: AiInsightFilterMode): Promise<void> {
|
||||
const normalizedMode: AiInsightFilterMode = mode === 'blacklist' ? 'blacklist' : 'whitelist'
|
||||
await config.set(CONFIG_KEYS.AI_INSIGHT_FILTER_MODE, normalizedMode)
|
||||
}
|
||||
|
||||
export async function getAiInsightFilterList(): Promise<string[]> {
|
||||
const value = await config.get(CONFIG_KEYS.AI_INSIGHT_FILTER_LIST)
|
||||
return normalizeAiInsightFilterList(value)
|
||||
}
|
||||
|
||||
export async function setAiInsightFilterList(list: string[]): Promise<void> {
|
||||
await config.set(CONFIG_KEYS.AI_INSIGHT_FILTER_LIST, normalizeAiInsightFilterList(list))
|
||||
}
|
||||
|
||||
// 兼容旧字段命名:内部已映射到新的黑白名单模式
|
||||
export async function getAiInsightWhitelistEnabled(): Promise<boolean> {
|
||||
const value = await config.get(CONFIG_KEYS.AI_INSIGHT_WHITELIST_ENABLED)
|
||||
return value === true
|
||||
return (await getAiInsightFilterMode()) === 'whitelist'
|
||||
}
|
||||
|
||||
export async function setAiInsightWhitelistEnabled(enabled: boolean): Promise<void> {
|
||||
await config.set(CONFIG_KEYS.AI_INSIGHT_WHITELIST_ENABLED, enabled)
|
||||
await setAiInsightFilterMode(enabled ? 'whitelist' : 'blacklist')
|
||||
}
|
||||
|
||||
export async function getAiInsightWhitelist(): Promise<string[]> {
|
||||
const value = await config.get(CONFIG_KEYS.AI_INSIGHT_WHITELIST)
|
||||
return Array.isArray(value) ? (value as string[]) : []
|
||||
return getAiInsightFilterList()
|
||||
}
|
||||
|
||||
export async function setAiInsightWhitelist(list: string[]): Promise<void> {
|
||||
await config.set(CONFIG_KEYS.AI_INSIGHT_WHITELIST, list)
|
||||
await setAiInsightFilterList(list)
|
||||
}
|
||||
|
||||
export async function getAiInsightCooldownMinutes(): Promise<number> {
|
||||
|
||||
8
src/types/electron.d.ts
vendored
8
src/types/electron.d.ts
vendored
@@ -849,6 +849,8 @@ export interface ElectronAPI {
|
||||
initiatedChats: number
|
||||
receivedChats: number
|
||||
initiativeRate: number
|
||||
topInitiatedFriend?: string
|
||||
topInitiatedCount?: number
|
||||
} | null
|
||||
responseSpeed: {
|
||||
avgResponseTime: number
|
||||
@@ -881,6 +883,12 @@ export interface ElectronAPI {
|
||||
dir?: string
|
||||
error?: string
|
||||
}>
|
||||
captureCurrentWindow: () => Promise<{
|
||||
success: boolean
|
||||
dataUrl?: string
|
||||
size?: { width: number; height: number }
|
||||
error?: string
|
||||
}>
|
||||
onAvailableYearsProgress: (callback: (payload: {
|
||||
taskId: string
|
||||
years?: number[]
|
||||
|
||||
Reference in New Issue
Block a user