+ )}
{shouldShowMediaSection && (
@@ -8461,7 +9807,7 @@ function ExportPage() {
diff --git a/src/pages/MyFootprintPage.scss b/src/pages/MyFootprintPage.scss
new file mode 100644
index 0000000..af1dd69
--- /dev/null
+++ b/src/pages/MyFootprintPage.scss
@@ -0,0 +1,825 @@
+.my-footprint-page {
+ --timeline-mention: #f59e0b; /* muted orange */
+ --timeline-private: #3b82f6; /* muted blue */
+
+ min-height: 100%;
+ margin: -24px -24px 0;
+ padding: 32px 40px;
+ background: var(--bg-primary); /* Pure minimal background */
+ display: flex;
+ flex-direction: column;
+ gap: 24px;
+ overflow-y: auto;
+ overflow-x: hidden;
+ animation: footprintPageEnter 0.4s ease-out;
+
+ .card-surface {
+ /* Removing border and strong shadows, just subtle background if any */
+ background: transparent;
+ }
+
+ .spin {
+ animation: footprintSpin 1s linear infinite;
+ }
+}
+
+.footprint-header {
+ position: relative;
+ z-index: 30;
+ display: flex;
+ flex-direction: column;
+ gap: 20px;
+ padding: 10px 0 20px 0;
+ border-bottom: 1px solid color-mix(in srgb, var(--border-color) 40%, transparent);
+ animation: footprintFadeSlideUp 0.3s ease both;
+}
+
+.footprint-title-wrap {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+
+ h1 {
+ margin: 0;
+ font-size: 26px;
+ font-weight: 600;
+ line-height: 1.3;
+ letter-spacing: -0.3px;
+ color: var(--text-primary);
+ }
+
+ p {
+ margin: 0;
+ color: var(--text-tertiary);
+ font-size: 14px;
+ }
+}
+
+.footprint-title-badge {
+ display: none; /* Removed for minimal design */
+}
+
+.footprint-toolbar {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+}
+
+.range-preset-group {
+ display: flex;
+ gap: 4px;
+ background: color-mix(in srgb, var(--text-tertiary) 8%, transparent);
+ padding: 4px;
+ border-radius: 8px;
+}
+
+.preset-chip {
+ background: transparent;
+ border: none;
+ color: var(--text-secondary);
+ border-radius: 6px;
+ padding: 6px 14px;
+ font-size: 13px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.2s ease;
+
+ &:hover {
+ color: var(--text-primary);
+ }
+
+ &.active {
+ color: var(--text-primary);
+ background: var(--bg-primary);
+ box-shadow: 0 1px 3px rgba(0,0,0,0.05);
+ }
+}
+
+.custom-range-row {
+ display: inline-flex;
+ align-items: center;
+ gap: 10px;
+
+ span {
+ color: var(--text-tertiary);
+ font-size: 13px;
+ font-weight: 500;
+ }
+
+ input[type="date"] {
+ font-family: inherit;
+ border: 1px solid transparent;
+ background: color-mix(in srgb, var(--text-tertiary) 6%, transparent);
+ color: var(--text-primary);
+ border-radius: 8px;
+ padding: 6px 10px;
+ font-size: 13px;
+ font-weight: 500;
+ transition: all 0.2s ease;
+ cursor: pointer;
+ box-shadow: 0 1px 2px rgba(0,0,0,0.02) inset;
+
+ &:hover {
+ background: color-mix(in srgb, var(--text-tertiary) 12%, transparent);
+ }
+
+ &:focus {
+ outline: none;
+ background: color-mix(in srgb, var(--primary) 4%, transparent);
+ border-color: color-mix(in srgb, var(--primary) 30%, transparent);
+ color: var(--primary);
+ }
+
+ &::-webkit-calendar-picker-indicator {
+ cursor: pointer;
+ opacity: 0.5;
+ transition: all 0.2s ease;
+ padding: 4px;
+ margin-left: 4px;
+ margin-right: -4px;
+ border-radius: 4px;
+ }
+
+ &::-webkit-calendar-picker-indicator:hover {
+ opacity: 0.9;
+ background: color-mix(in srgb, var(--text-tertiary) 12%, transparent);
+ }
+ }
+}
+
+.toolbar-actions {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 8px;
+}
+
+.search-input {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ border: none;
+ background: color-mix(in srgb, var(--text-tertiary) 8%, transparent);
+ border-radius: 8px;
+ padding: 8px 12px;
+ color: var(--text-tertiary);
+ transition: all 0.2s ease;
+
+ &:focus-within {
+ background: color-mix(in srgb, var(--primary) 8%, transparent);
+ color: var(--primary);
+ }
+
+ input {
+ min-width: 180px;
+ border: none;
+ background: transparent;
+ color: var(--text-primary);
+ font-size: 13px;
+
+ &::placeholder {
+ color: var(--text-tertiary);
+ }
+ &:focus {
+ outline: none;
+ }
+ }
+}
+
+.action-btn,
+.jump-btn {
+ border: none;
+ background: color-mix(in srgb, var(--text-tertiary) 8%, transparent);
+ color: var(--text-secondary);
+ border-radius: 8px;
+ padding: 8px 12px;
+ font-size: 13px;
+ font-weight: 500;
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ cursor: pointer;
+ transition: all 0.2s ease;
+
+ &:hover {
+ background: color-mix(in srgb, var(--text-tertiary) 15%, transparent);
+ color: var(--text-primary);
+ }
+
+ &:disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+ }
+}
+
+.kpi-grid {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 20px;
+ padding: 20px 0;
+ border-bottom: 1px solid color-mix(in srgb, var(--border-color) 40%, transparent);
+}
+
+.kpi-card {
+ border: none;
+ background: transparent;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 8px;
+ text-align: left;
+ color: var(--text-primary);
+ animation: footprintKpiIn 0.3s ease both;
+ transition: opacity 0.2s ease;
+ cursor: pointer;
+
+ &:hover {
+ opacity: 0.7;
+ }
+
+ strong {
+ font-size: 32px;
+ font-weight: 300;
+ line-height: 1;
+ color: var(--text-primary);
+ letter-spacing: -0.5px;
+ }
+
+ small {
+ color: var(--text-tertiary);
+ font-size: 12px;
+ }
+}
+
+.kpi-icon {
+ display: none; /* Minimalistic, hide icon in KPI */
+}
+
+.footprint-ai-result {
+ border-radius: 10px;
+ padding: 14px 16px;
+ background: color-mix(in srgb, var(--text-tertiary) 8%, transparent);
+ border: 1px solid color-mix(in srgb, var(--border-color) 40%, transparent);
+
+ .footprint-ai-head {
+ display: flex;
+ justify-content: space-between;
+ gap: 8px;
+ margin-bottom: 8px;
+
+ strong {
+ font-size: 14px;
+ color: var(--text-primary);
+ }
+
+ span {
+ font-size: 12px;
+ color: var(--text-tertiary);
+ }
+ }
+
+ p {
+ margin: 0;
+ white-space: pre-wrap;
+ line-height: 1.6;
+ color: var(--text-secondary);
+ font-size: 13px;
+ }
+
+ &.footprint-ai-result-error {
+ border-color: color-mix(in srgb, #ef4444 50%, transparent);
+ }
+}
+
+.kpi-label {
+ font-size: 13px;
+ font-weight: 500;
+ color: var(--text-secondary);
+}
+
+.kpi-diagnostics {
+ cursor: default;
+ &:hover { opacity: 1; }
+ border-left: 1px solid color-mix(in srgb, var(--border-color) 40%, transparent);
+ padding-left: 20px;
+}
+
+.footprint-timeline {
+ animation: timelineSwitchFade 0.4s cubic-bezier(0.16, 1, 0.3, 1) both;
+ --timeline-time-col-width: 64px;
+ --timeline-dot-col-width: 20px;
+ --timeline-gap: 24px;
+
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ gap: 24px;
+ padding: 10px 0;
+
+ &.timeline-time-month_day_clock {
+ --timeline-time-col-width: 86px;
+ }
+
+ &.timeline-time-full_date_clock {
+ --timeline-time-col-width: 124px;
+ }
+}
+
+.timeline-head {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 10px;
+}
+
+.timeline-head-left h2 {
+ display: none; /* the minimalist approach relies on content, we skip this redundant title */
+}
+
+.timeline-head-left p {
+ display: none;
+}
+
+.timeline-mode-row {
+ display: flex;
+ gap: 4px;
+ background: color-mix(in srgb, var(--text-tertiary) 6%, transparent);
+ padding: 3px;
+ border-radius: 8px;
+}
+
+.timeline-mode-chip {
+ border: none;
+ background: transparent;
+ color: var(--text-secondary);
+ font-size: 13px;
+ font-weight: 500;
+ padding: 6px 12px;
+ border-radius: 6px;
+ cursor: pointer;
+ transition: all 0.2s ease;
+
+ &:hover {
+ color: var(--text-primary);
+ }
+
+ &.active {
+ color: var(--text-primary);
+ background: var(--bg-primary);
+ box-shadow: 0 1px 3px rgba(0,0,0,0.04);
+ }
+}
+
+.timeline-stream {
+ position: relative;
+ padding-bottom: 40px;
+
+ &::before {
+ content: '';
+ position: absolute;
+ left: calc(var(--timeline-time-col-width) + var(--timeline-gap) + 9px);
+ top: 0;
+ bottom: 0;
+ width: 2px;
+ background: color-mix(in srgb, var(--text-tertiary) 20%, transparent);
+ }
+}
+
+.timeline-item {
+ display: grid;
+ grid-template-columns: var(--timeline-time-col-width) var(--timeline-dot-col-width) minmax(0, 1fr);
+ column-gap: var(--timeline-gap);
+ align-items: stretch;
+ margin-bottom: 38px;
+}
+
+.timeline-time {
+ font-size: 13px;
+ color: var(--text-tertiary);
+ text-align: right;
+ padding-top: 5px;
+ height: 100%;
+}
+
+.timeline-time-private {
+ padding-top: 5px;
+}
+
+.timeline-time-range {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ justify-content: space-between;
+ height: 100%;
+}
+
+.timeline-time-main,
+.timeline-time-end {
+ color: var(--text-secondary);
+ font-weight: 500;
+ line-height: 1;
+}
+
+.timeline-time-sep {
+ display: none;
+}
+
+.timeline-dot-col {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ padding-top: 9px;
+ height: 100%;
+}
+
+.timeline-dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: var(--text-tertiary);
+ position: relative;
+ z-index: 1;
+ flex-shrink: 0;
+}
+
+.timeline-dot-mention {
+ background: var(--timeline-mention);
+}
+
+.timeline-dot-private {
+ background: var(--timeline-private);
+}
+
+.timeline-dot-private-inbound_only {
+ background: var(--timeline-mention);
+ border: none;
+}
+
+.timeline-dot-private-outbound_only {
+ background: #22c55e;
+}
+
+.timeline-dot-private-both {
+ background: var(--timeline-private);
+}
+
+.timeline-dot-start,
+.timeline-dot-end {
+ background: transparent;
+ border: 1.5px solid var(--text-tertiary);
+ width: 10px;
+ height: 10px;
+ margin-top: -1px;
+}
+
+.timeline-dot-range {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ height: 100%;
+ width: 100%;
+}
+
+.timeline-dot-range-line {
+ width: 2px;
+ flex: 1;
+ margin: 4px 0;
+ background: color-mix(in srgb, var(--timeline-private) 30%, transparent);
+}
+
+.timeline-dot-range-line-inbound_only {
+ background: color-mix(in srgb, var(--timeline-mention) 55%, transparent);
+}
+
+.timeline-dot-range-line-outbound_only {
+ background: #22c55e;
+}
+
+.timeline-dot-range-line-both {
+ background: color-mix(in srgb, var(--timeline-private) 55%, transparent);
+}
+
+.timeline-content-wrap {
+ padding-top: 2px;
+ padding-bottom: 6px;
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ min-height: 100%;
+}
+
+.timeline-boundary {
+ font-size: 13px;
+ color: var(--text-tertiary);
+ padding: 4px 0;
+}
+
+.timeline-card {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+
+ /* completely clean out the old card style */
+ border: none;
+ background: transparent;
+ padding: 0;
+}
+
+.timeline-card-head {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.timeline-identity {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+.timeline-avatar {
+ width: 28px;
+ height: 28px;
+ border-radius: 50%; /* Modern circle avatars */
+ overflow: hidden;
+ background: color-mix(in srgb, var(--text-tertiary) 10%, transparent);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--text-tertiary);
+
+ img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ }
+}
+
+.timeline-avatar-private {
+ color: var(--timeline-private);
+}
+
+.timeline-title-group {
+ display: flex;
+ align-items: baseline;
+ gap: 8px;
+}
+
+.timeline-title {
+ font-size: 14px;
+ font-weight: 600;
+ color: var(--text-primary);
+}
+
+.timeline-subtitle {
+ font-size: 13px;
+ color: var(--text-tertiary);
+}
+
+.timeline-right-tools {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+.timeline-count-badge {
+ font-size: 12px;
+ color: var(--text-tertiary);
+}
+
+.timeline-jump-btn {
+ padding: 4px 10px;
+ font-size: 12px;
+ background: transparent;
+ color: var(--text-tertiary);
+
+ &:hover {
+ background: color-mix(in srgb, var(--text-tertiary) 10%, transparent);
+ color: var(--text-primary);
+ }
+}
+
+.timeline-message {
+ font-size: 13px;
+ line-height: 1.5;
+ color: var(--text-secondary);
+ padding: 0;
+ margin-top: 4px;
+ background: transparent;
+ border-radius: 0;
+ word-break: break-word;
+ white-space: pre-wrap;
+}
+
+.mention-message {
+ color: var(--text-primary);
+}
+
+.private-message {
+ color: var(--text-tertiary);
+}
+
+@keyframes timelineSwitchFade {
+ from {
+ opacity: 0;
+ transform: translateY(12px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+
+.mention-token {
+ color: var(--timeline-mention);
+ font-weight: 600;
+}
+
+.panel-empty-state {
+ text-align: center;
+ padding: 60px 0;
+ color: var(--text-tertiary);
+ font-size: 14px;
+}
+
+.footprint-loading {
+ padding: 40px 0;
+}
+
+.kpi-skeleton-grid {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 20px;
+ margin-bottom: 20px;
+}
+
+.kpi-skeleton-card {
+ height: 60px;
+ border-radius: 8px;
+ background: color-mix(in srgb, var(--text-tertiary) 6%, transparent);
+}
+
+.timeline-skeleton-list {
+ display: flex;
+ flex-direction: column;
+ gap: 20px;
+}
+
+.timeline-skeleton-item {
+ height: 80px;
+ border-radius: 8px;
+ background: color-mix(in srgb, var(--text-tertiary) 6%, transparent);
+}
+
+.footprint-export-modal-mask {
+ position: fixed;
+ inset: 0;
+ z-index: 1200;
+ background: color-mix(in srgb, #000 36%, transparent);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 24px;
+}
+
+.footprint-export-modal {
+ width: min(520px, 100%);
+ border-radius: 16px;
+ background: var(--bg-primary);
+ border: 1px solid color-mix(in srgb, var(--border-color) 60%, transparent);
+ box-shadow: 0 18px 60px rgba(0, 0, 0, 0.2);
+ padding: 22px 22px 18px;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ animation: footprintFadeSlideUp 0.2s ease both;
+
+ h3 {
+ margin: 0;
+ font-size: 18px;
+ font-weight: 600;
+ color: var(--text-primary);
+ }
+
+ p {
+ margin: 0;
+ color: var(--text-secondary);
+ font-size: 14px;
+ line-height: 1.5;
+ }
+}
+
+.export-modal-icon {
+ width: 34px;
+ height: 34px;
+ border-radius: 10px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.export-modal-icon-progress {
+ color: var(--primary);
+ background: color-mix(in srgb, var(--primary) 16%, transparent);
+}
+
+.export-modal-icon-success {
+ color: #16a34a;
+ background: color-mix(in srgb, #16a34a 18%, transparent);
+}
+
+.export-modal-icon-error {
+ color: #ef4444;
+ background: color-mix(in srgb, #ef4444 18%, transparent);
+}
+
+.export-modal-path {
+ display: block;
+ margin-top: 2px;
+ padding: 10px 12px;
+ border-radius: 10px;
+ font-family: inherit;
+ font-weight: 500;
+ font-size: 12px;
+ line-height: 1.4;
+ color: var(--text-tertiary);
+ background: color-mix(in srgb, var(--text-tertiary) 8%, transparent);
+ border: 1px solid color-mix(in srgb, var(--border-color) 40%, transparent);
+ white-space: pre-wrap;
+ word-break: break-all;
+}
+
+.export-modal-actions {
+ display: flex;
+ justify-content: flex-end;
+ margin-top: 6px;
+}
+
+.skeleton-shimmer {
+ position: relative;
+ overflow: hidden;
+
+ &::after {
+ content: '';
+ position: absolute;
+ inset: 0;
+ background: linear-gradient(
+ 90deg,
+ transparent,
+ color-mix(in srgb, var(--bg-primary) 50%, transparent),
+ transparent
+ );
+ transform: translateX(-100%);
+ animation: footprintShimmer 1.5s infinite;
+ }
+}
+
+/* Animations */
+@keyframes footprintPageEnter {
+ from { opacity: 0; transform: translateY(10px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+
+@keyframes footprintFadeSlideUp {
+ from { opacity: 0; transform: translateY(5px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+
+@keyframes footprintKpiIn {
+ from { opacity: 0; transform: translateY(8px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+
+@keyframes footprintTimelineItemIn {
+ from { opacity: 0; transform: translateY(10px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+
+@keyframes footprintSpin {
+ 100% { transform: rotate(360deg); }
+}
+
+@keyframes footprintShimmer {
+ 100% { transform: translateX(100%); }
+}
+
+@media (max-width: 1100px) {
+ .kpi-grid,
+ .kpi-skeleton-grid {
+ grid-template-columns: repeat(3, 1fr);
+ }
+}
+
+@media (max-width: 800px) {
+ .my-footprint-page {
+ padding: 20px;
+ }
+ .kpi-grid,
+ .kpi-skeleton-grid {
+ grid-template-columns: repeat(2, 1fr);
+ }
+}
diff --git a/src/pages/MyFootprintPage.tsx b/src/pages/MyFootprintPage.tsx
new file mode 100644
index 0000000..ff7918d
--- /dev/null
+++ b/src/pages/MyFootprintPage.tsx
@@ -0,0 +1,983 @@
+import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
+import { useNavigate } from 'react-router-dom'
+import { AlertCircle, AtSign, CheckCircle2, Download, Loader2, MessageCircle, RefreshCw, Search, Sparkles, Users } from 'lucide-react'
+import DateRangePicker from '../components/DateRangePicker'
+import './MyFootprintPage.scss'
+
+type RangePreset = 'today' | 'yesterday' | 'this_week' | 'last_week' | 'custom'
+type TimelineMode = 'all' | 'mention' | 'private'
+type TimelineTimeMode = 'clock' | 'month_day_clock' | 'full_date_clock'
+type PrivateDotVariant = 'both' | 'inbound_only' | 'outbound_only'
+type ExportModalStatus = 'idle' | 'progress' | 'success' | 'error'
+type FootprintAiStatus = 'idle' | 'loading' | 'success' | 'error'
+
+interface MyFootprintSummary {
+ private_inbound_people: number
+ private_replied_people: number
+ private_outbound_people: number
+ private_reply_rate: number
+ mention_count: number
+ mention_group_count: number
+}
+
+interface MyFootprintPrivateSession {
+ session_id: string
+ incoming_count: number
+ outgoing_count: number
+ replied: boolean
+ first_incoming_ts: number
+ first_reply_ts: number
+ latest_ts: number
+ anchor_local_id: number
+ anchor_create_time: number
+ displayName?: string
+ avatarUrl?: string
+}
+
+interface MyFootprintPrivateSegment {
+ session_id: string
+ segment_index: number
+ start_ts: number
+ end_ts: number
+ duration_sec: number
+ incoming_count: number
+ outgoing_count: number
+ message_count: number
+ replied: boolean
+ first_incoming_ts: number
+ first_reply_ts: number
+ latest_ts: number
+ anchor_local_id: number
+ anchor_create_time: number
+ displayName?: string
+ avatarUrl?: string
+}
+
+interface MyFootprintMention {
+ session_id: string
+ local_id: number
+ create_time: number
+ sender_username: string
+ message_content: string
+ source: string
+ sessionDisplayName?: string
+ senderDisplayName?: string
+ senderAvatarUrl?: string
+}
+
+interface MyFootprintMentionGroup {
+ session_id: string
+ count: number
+ latest_ts: number
+ displayName?: string
+ avatarUrl?: string
+}
+
+interface MyFootprintDiagnostics {
+ truncated: boolean
+ scanned_dbs: number
+ elapsed_ms: number
+ mention_truncated?: boolean
+ private_truncated?: boolean
+}
+
+interface MyFootprintData {
+ summary: MyFootprintSummary
+ private_sessions: MyFootprintPrivateSession[]
+ private_segments: MyFootprintPrivateSegment[]
+ mentions: MyFootprintMention[]
+ mention_groups: MyFootprintMentionGroup[]
+ diagnostics: MyFootprintDiagnostics
+}
+
+interface TimelineBoundaryItem {
+ kind: 'boundary'
+ edge: 'start' | 'end'
+ key: string
+ time: number
+ label: string
+}
+
+interface TimelineMentionItem {
+ kind: 'mention'
+ key: string
+ time: number
+ sessionId: string
+ localId: number
+ createTime: number
+ groupName: string
+ groupAvatarUrl?: string
+ senderName: string
+ messageContent: string
+}
+
+interface TimelinePrivateItem {
+ kind: 'private'
+ key: string
+ time: number
+ endTime: number
+ sessionId: string
+ anchorLocalId: number
+ anchorCreateTime: number
+ displayName: string
+ avatarUrl?: string
+ subtitle: string
+ totalInteractions: number
+ summaryText: string
+ dotVariant: PrivateDotVariant
+ isRange: boolean
+}
+
+type TimelineItem = TimelineBoundaryItem | TimelineMentionItem | TimelinePrivateItem
+
+const EMPTY_DATA: MyFootprintData = {
+ summary: {
+ private_inbound_people: 0,
+ private_replied_people: 0,
+ private_outbound_people: 0,
+ private_reply_rate: 0,
+ mention_count: 0,
+ mention_group_count: 0
+ },
+ private_sessions: [],
+ private_segments: [],
+ mentions: [],
+ mention_groups: [],
+ diagnostics: {
+ truncated: false,
+ scanned_dbs: 0,
+ elapsed_ms: 0,
+ mention_truncated: false,
+ private_truncated: false
+ }
+}
+
+function toDayStart(date: Date): Date {
+ const next = new Date(date)
+ next.setHours(0, 0, 0, 0)
+ return next
+}
+
+function toDayEnd(date: Date): Date {
+ const next = new Date(date)
+ next.setHours(23, 59, 59, 999)
+ return next
+}
+
+function toSeconds(date: Date): number {
+ return Math.floor(date.getTime() / 1000)
+}
+
+function toDateInputValue(date: Date): string {
+ const y = date.getFullYear()
+ const m = `${date.getMonth() + 1}`.padStart(2, '0')
+ const d = `${date.getDate()}`.padStart(2, '0')
+ return `${y}-${m}-${d}`
+}
+
+function getWeekStart(date: Date): Date {
+ const base = toDayStart(date)
+ const day = base.getDay()
+ const diff = day === 0 ? -6 : 1 - day
+ base.setDate(base.getDate() + diff)
+ return base
+}
+
+function formatTimelineMoment(seconds: number, mode: TimelineTimeMode): string {
+ if (!seconds || !Number.isFinite(seconds)) return '--'
+ const date = new Date(seconds * 1000)
+ const yyyy = `${date.getFullYear()}`
+ const mm = `${date.getMonth() + 1}`.padStart(2, '0')
+ const dd = `${date.getDate()}`.padStart(2, '0')
+ const hh = `${date.getHours()}`.padStart(2, '0')
+ const min = `${date.getMinutes()}`.padStart(2, '0')
+ if (mode === 'full_date_clock') {
+ return `${yyyy}-${mm}-${dd} ${hh}:${min}`
+ }
+ if (mode === 'month_day_clock') {
+ return `${mm}-${dd} ${hh}:${min}`
+ }
+ return `${hh}:${min}`
+}
+
+function formatPercent(value: number): string {
+ const safe = Number.isFinite(value) ? value : 0
+ return `${(safe * 100).toFixed(1)}%`
+}
+
+function decodeHtmlEntities(content: string): string {
+ return String(content || '')
+ .replace(/&/g, '&')
+ .replace(/</g, '<')
+ .replace(/>/g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, "'")
+}
+
+function stripGroupSenderPrefix(content: string): string {
+ return String(content || '')
+ .replace(/^[\s]*([a-zA-Z0-9_@-]+):(?!\/\/)(?:\s*(?:\r?\n|
)\s*|\s*)/i, '')
+ .replace(/^[a-zA-Z0-9]+@openim:\n?/i, '')
+}
+
+function normalizeFootprintMessageContent(content: string): string {
+ const decoded = decodeHtmlEntities(content || '')
+ const stripped = stripGroupSenderPrefix(decoded)
+ return stripped.trim()
+}
+
+function renderMentionContent(content: string): ReactNode {
+ const normalized = String(content || '').trim() || '[空消息]'
+ const parts = normalized.split(/(@我|@我)/g)
+ if (parts.length <= 1) return normalized
+ return parts.map((part, index) => {
+ if (part === '@我' || part === '@我') {
+ return (
+
+ {part}
+
+ )
+ }
+ return
{part}
+ })
+}
+
+function formatDurationLabel(beginTimestamp: number, endTimestamp: number): string {
+ if (!beginTimestamp || !endTimestamp || endTimestamp <= beginTimestamp) {
+ return '持续不足 1 分钟'
+ }
+ const minutes = Math.max(1, Math.round((endTimestamp - beginTimestamp) / 60))
+ return `持续 ${minutes} 分钟`
+}
+
+function resolveRangePresetLabel(preset: RangePreset): string {
+ switch (preset) {
+ case 'today':
+ return '今天'
+ case 'yesterday':
+ return '昨天'
+ case 'this_week':
+ return '本周'
+ case 'last_week':
+ return '上周'
+ default:
+ return '自定义'
+ }
+}
+
+function buildRange(preset: RangePreset, customStart: string, customEnd: string): { begin: number; end: number; label: string } {
+ const now = new Date()
+
+ if (preset === 'today') {
+ return {
+ begin: toSeconds(toDayStart(now)),
+ end: toSeconds(now),
+ label: '今天'
+ }
+ }
+
+ if (preset === 'yesterday') {
+ const yesterday = new Date(now)
+ yesterday.setDate(yesterday.getDate() - 1)
+ return {
+ begin: toSeconds(toDayStart(yesterday)),
+ end: toSeconds(toDayEnd(yesterday)),
+ label: '昨天'
+ }
+ }
+
+ if (preset === 'this_week') {
+ const weekStart = getWeekStart(now)
+ const weekEnd = new Date(weekStart)
+ weekEnd.setDate(weekStart.getDate() + 6)
+ return {
+ begin: toSeconds(toDayStart(weekStart)),
+ end: toSeconds(toDayEnd(weekEnd)),
+ label: '本周'
+ }
+ }
+
+ if (preset === 'last_week') {
+ const thisWeekStart = getWeekStart(now)
+ const lastWeekStart = new Date(thisWeekStart)
+ lastWeekStart.setDate(lastWeekStart.getDate() - 7)
+ const lastWeekEnd = new Date(thisWeekStart)
+ lastWeekEnd.setDate(lastWeekEnd.getDate() - 1)
+ return {
+ begin: toSeconds(toDayStart(lastWeekStart)),
+ end: toSeconds(toDayEnd(lastWeekEnd)),
+ label: '上周'
+ }
+ }
+
+ const customStartDate = customStart ? new Date(`${customStart}T00:00:00`) : toDayStart(now)
+ const customEndDate = customEnd ? new Date(`${customEnd}T23:59:59`) : toDayEnd(now)
+ const begin = toSeconds(customStartDate)
+ const end = Math.max(begin, toSeconds(customEndDate))
+
+ return {
+ begin,
+ end,
+ label: `${toDateInputValue(customStartDate)} 至 ${toDateInputValue(customEndDate)}`
+ }
+}
+
+function MyFootprintPage() {
+ const navigate = useNavigate()
+ const [preset, setPreset] = useState
('today')
+ const [customStartDate, setCustomStartDate] = useState(() => toDateInputValue(toDayStart(new Date())))
+ const [customEndDate, setCustomEndDate] = useState(() => toDateInputValue(toDayStart(new Date())))
+ const [searchKeyword, setSearchKeyword] = useState('')
+ const [timelineMode, setTimelineMode] = useState('all')
+ const [data, setData] = useState(EMPTY_DATA)
+ const [loading, setLoading] = useState(false)
+ const [exporting, setExporting] = useState(false)
+ const [exportModalStatus, setExportModalStatus] = useState('idle')
+ const [exportModalTitle, setExportModalTitle] = useState('')
+ const [exportModalDescription, setExportModalDescription] = useState('')
+ const [exportModalPath, setExportModalPath] = useState('')
+ const [error, setError] = useState(null)
+ const [footprintAiStatus, setFootprintAiStatus] = useState('idle')
+ const [footprintAiText, setFootprintAiText] = useState('')
+ const inflightRangeKeyRef = useRef(null)
+
+ const currentRange = useMemo(() => buildRange(preset, customStartDate, customEndDate), [preset, customStartDate, customEndDate])
+ const timelineTimeMode = useMemo(() => {
+ const span = Math.max(0, currentRange.end - currentRange.begin)
+ if (span > 365 * 24 * 60 * 60) return 'full_date_clock'
+ if (span > 24 * 60 * 60) return 'month_day_clock'
+ return 'clock'
+ }, [currentRange.begin, currentRange.end])
+
+ const handleJump = useCallback((sessionId: string, localId: number, createTime: number) => {
+ if (!sessionId || !localId || !createTime) return
+ const query = new URLSearchParams({
+ sessionId,
+ jumpLocalId: String(localId),
+ jumpCreateTime: String(createTime),
+ jumpSource: 'footprint'
+ })
+ navigate(`/chat?${query.toString()}`)
+ }, [navigate])
+
+ const loadData = useCallback(async () => {
+ const rangeKey = `${currentRange.begin}-${currentRange.end}`
+ if (inflightRangeKeyRef.current === rangeKey) {
+ return
+ }
+ inflightRangeKeyRef.current = rangeKey
+ setLoading(true)
+ setError(null)
+ try {
+ const result = await window.electronAPI.chat.getMyFootprintStats(currentRange.begin, currentRange.end)
+ if (!result.success || !result.data) {
+ setError(result.error || '读取统计失败')
+ setData(EMPTY_DATA)
+ return
+ }
+ setData({
+ ...result.data,
+ private_segments: Array.isArray(result.data.private_segments) ? result.data.private_segments : []
+ })
+ } catch (loadError) {
+ setError(String(loadError))
+ setData(EMPTY_DATA)
+ } finally {
+ setLoading(false)
+ if (inflightRangeKeyRef.current === rangeKey) {
+ inflightRangeKeyRef.current = null
+ }
+ }
+ }, [currentRange.begin, currentRange.end])
+
+ useEffect(() => {
+ void loadData()
+ }, [loadData])
+
+ const keyword = searchKeyword.trim().toLowerCase()
+
+ const privateSessionMetaMap = useMemo(() => {
+ const map = new Map()
+ for (const item of data.private_sessions) {
+ map.set(item.session_id, {
+ displayName: item.displayName,
+ avatarUrl: item.avatarUrl
+ })
+ }
+ for (const item of data.private_segments) {
+ if (!map.has(item.session_id)) {
+ map.set(item.session_id, {
+ displayName: item.displayName,
+ avatarUrl: item.avatarUrl
+ })
+ }
+ }
+ return map
+ }, [data.private_sessions, data.private_segments])
+
+ const filteredMentions = useMemo(() => {
+ if (!keyword) return data.mentions
+ return data.mentions.filter((item) => {
+ const sessionName = (item.sessionDisplayName || '').toLowerCase()
+ const senderName = (item.senderDisplayName || '').toLowerCase()
+ const sender = item.sender_username.toLowerCase()
+ const content = normalizeFootprintMessageContent(item.message_content).toLowerCase()
+ return sessionName.includes(keyword) || senderName.includes(keyword) || sender.includes(keyword) || content.includes(keyword)
+ })
+ }, [data.mentions, keyword])
+
+ const filteredPrivateSegments = useMemo(() => {
+ const rawSegments = data.private_segments.length > 0
+ ? data.private_segments
+ : data.private_sessions.map((item, index) => ({
+ session_id: item.session_id,
+ segment_index: index + 1,
+ start_ts: item.first_incoming_ts > 0
+ ? item.first_incoming_ts
+ : item.first_reply_ts > 0
+ ? item.first_reply_ts
+ : item.latest_ts,
+ end_ts: item.latest_ts,
+ duration_sec: Math.max(0, item.latest_ts - (item.first_incoming_ts || item.first_reply_ts || item.latest_ts)),
+ incoming_count: item.incoming_count,
+ outgoing_count: item.outgoing_count,
+ message_count: Math.max(0, item.incoming_count + item.outgoing_count),
+ replied: item.replied,
+ first_incoming_ts: item.first_incoming_ts,
+ first_reply_ts: item.first_reply_ts,
+ latest_ts: item.latest_ts,
+ anchor_local_id: item.anchor_local_id,
+ anchor_create_time: item.anchor_create_time,
+ displayName: item.displayName,
+ avatarUrl: item.avatarUrl
+ }))
+
+ if (!keyword) return rawSegments
+ return rawSegments.filter((item) => {
+ const meta = privateSessionMetaMap.get(item.session_id)
+ const name = String(item.displayName || meta?.displayName || '').toLowerCase()
+ const id = item.session_id.toLowerCase()
+ return name.includes(keyword) || id.includes(keyword)
+ })
+ }, [data.private_segments, data.private_sessions, keyword, privateSessionMetaMap])
+
+ const mentionGroupMetaMap = useMemo(() => {
+ const map = new Map()
+ for (const item of data.mention_groups) {
+ map.set(item.session_id, { displayName: item.displayName, avatarUrl: item.avatarUrl })
+ }
+ for (const item of data.private_sessions) {
+ if (!map.has(item.session_id)) {
+ map.set(item.session_id, { displayName: item.displayName, avatarUrl: item.avatarUrl })
+ }
+ }
+ return map
+ }, [data.mention_groups, data.private_sessions])
+
+ const mentionTimelineItems = useMemo(() => {
+ return filteredMentions
+ .filter((item) => item.create_time > 0)
+ .map((item) => {
+ const groupMeta = mentionGroupMetaMap.get(item.session_id)
+ return {
+ kind: 'mention' as const,
+ key: `mention:${item.session_id}:${item.local_id}`,
+ time: item.create_time,
+ sessionId: item.session_id,
+ localId: item.local_id,
+ createTime: item.create_time,
+ groupName: item.sessionDisplayName || groupMeta?.displayName || item.session_id,
+ groupAvatarUrl: groupMeta?.avatarUrl,
+ senderName: item.senderDisplayName || item.sender_username || '未知',
+ messageContent: normalizeFootprintMessageContent(item.message_content)
+ }
+ })
+ }, [filteredMentions, mentionGroupMetaMap])
+
+ const privateTimelineItems = useMemo(() => {
+ return filteredPrivateSegments
+ .map((item) => {
+ const startTime = item.start_ts > 0
+ ? item.start_ts
+ : item.first_incoming_ts > 0
+ ? item.first_incoming_ts
+ : item.first_reply_ts > 0
+ ? item.first_reply_ts
+ : item.latest_ts
+
+ const endTime = item.end_ts > 0 ? item.end_ts : item.latest_ts
+ const isRange = endTime > startTime + 60
+ const totalInteractions = Math.max(0, item.message_count || (item.incoming_count + item.outgoing_count))
+ const durationLabel = item.duration_sec > 0
+ ? `持续 ${Math.max(1, Math.round(item.duration_sec / 60))} 分钟`
+ : formatDurationLabel(startTime, endTime)
+ const subtitle = isRange
+ ? `${formatTimelineMoment(startTime, timelineTimeMode)} 至 ${formatTimelineMoment(endTime || startTime, timelineTimeMode)} · ${durationLabel}`
+ : ''
+ const summaryText = `收到 ${item.incoming_count} 条 / 发送 ${item.outgoing_count} 条${item.replied ? ' · 已回复' : ''}`
+ const sessionMeta = privateSessionMetaMap.get(item.session_id)
+ let dotVariant: PrivateDotVariant = 'both'
+ if (item.incoming_count > 0 && item.outgoing_count === 0) {
+ dotVariant = 'inbound_only'
+ } else if (item.incoming_count === 0 && item.outgoing_count > 0) {
+ dotVariant = 'outbound_only'
+ }
+
+ return {
+ kind: 'private' as const,
+ key: `private:${item.session_id}:${item.segment_index}:${item.start_ts}`,
+ time: startTime,
+ endTime,
+ sessionId: item.session_id,
+ anchorLocalId: item.anchor_local_id,
+ anchorCreateTime: item.anchor_create_time,
+ displayName: item.displayName || sessionMeta?.displayName || item.session_id,
+ avatarUrl: item.avatarUrl || sessionMeta?.avatarUrl,
+ subtitle,
+ totalInteractions,
+ summaryText,
+ dotVariant,
+ isRange
+ }
+ })
+ .filter((item) => item.time > 0)
+ }, [filteredPrivateSegments, privateSessionMetaMap, timelineTimeMode])
+
+ const timelineItems = useMemo(() => {
+ const events: TimelineItem[] = []
+ if (timelineMode !== 'private') {
+ events.push(...mentionTimelineItems)
+ }
+ if (timelineMode !== 'mention') {
+ events.push(...privateTimelineItems)
+ }
+
+ events.sort((a, b) => {
+ if (a.time !== b.time) return a.time - b.time
+ const rankA = a.kind === 'mention' ? 0 : a.kind === 'private' ? 1 : 2
+ const rankB = b.kind === 'mention' ? 0 : b.kind === 'private' ? 1 : 2
+ return rankA - rankB
+ })
+
+ const presetLabel = resolveRangePresetLabel(preset)
+ const startNode: TimelineBoundaryItem = {
+ kind: 'boundary',
+ edge: 'start',
+ key: 'boundary:start',
+ time: currentRange.begin,
+ label: `区域时间开始(${presetLabel})`
+ }
+
+ const endNode: TimelineBoundaryItem = {
+ kind: 'boundary',
+ edge: 'end',
+ key: 'boundary:end',
+ time: currentRange.end,
+ label: `区域时间结束(${preset === 'today' ? '现在' : presetLabel})`
+ }
+
+ return [startNode, ...events, endNode]
+ }, [timelineMode, mentionTimelineItems, privateTimelineItems, currentRange.begin, currentRange.end, preset])
+
+ const timelineEventCount = useMemo(
+ () => timelineItems.filter((item) => item.kind !== 'boundary').length,
+ [timelineItems]
+ )
+
+ const handleExport = useCallback(async (format: 'csv' | 'json') => {
+ try {
+ setExporting(true)
+ setExportModalStatus('progress')
+ setExportModalTitle(`正在准备导出 ${format.toUpperCase()}`)
+ setExportModalDescription('正在准备文件保存信息...')
+ setExportModalPath('')
+ const downloadsPath = await window.electronAPI.app.getDownloadsPath()
+ const separator = downloadsPath && downloadsPath.includes('\\') ? '\\' : '/'
+ const rangeName = currentRange.label.replace(/[\\/:*?"<>|\s]+/g, '_')
+ const suggestedName = `my_footprint_${rangeName}_${Date.now()}.${format}`
+ const defaultPath = downloadsPath ? `${downloadsPath}${separator}${suggestedName}` : suggestedName
+
+ setExportModalDescription('请在弹窗中选择导出路径...')
+ const saveResult = await window.electronAPI.dialog.saveFile({
+ title: format === 'csv' ? '导出我的足迹 CSV' : '导出我的足迹 JSON',
+ defaultPath,
+ filters: format === 'csv'
+ ? [{ name: 'CSV', extensions: ['csv'] }]
+ : [{ name: 'JSON', extensions: ['json'] }]
+ })
+ if (saveResult.canceled || !saveResult.filePath) {
+ setExportModalStatus('idle')
+ setExportModalTitle('')
+ setExportModalDescription('')
+ setExportModalPath('')
+ return
+ }
+
+ setExportModalDescription('正在导出数据,请稍候...')
+ setExportModalPath(saveResult.filePath)
+ const exportResult = await window.electronAPI.chat.exportMyFootprint(
+ currentRange.begin,
+ currentRange.end,
+ format,
+ saveResult.filePath
+ )
+ if (!exportResult.success) {
+ setExportModalStatus('error')
+ setExportModalTitle('导出失败')
+ setExportModalDescription(exportResult.error || '未知错误')
+ setExportModalPath(saveResult.filePath)
+ return
+ }
+ setExportModalStatus('success')
+ setExportModalTitle('导出完成')
+ setExportModalDescription(`文件已成功导出为 ${format.toUpperCase()}。`)
+ setExportModalPath(exportResult.filePath || saveResult.filePath)
+ } catch (exportError) {
+ setExportModalStatus('error')
+ setExportModalTitle('导出失败')
+ setExportModalDescription(String(exportError))
+ } finally {
+ setExporting(false)
+ }
+ }, [currentRange.begin, currentRange.end, currentRange.label])
+
+ const handleGenerateAiSummary = useCallback(async () => {
+ setFootprintAiStatus('loading')
+ setFootprintAiText('')
+ try {
+ const privateSegments = (data.private_segments.length > 0 ? data.private_segments : data.private_sessions).slice(0, 12)
+ const result = await window.electronAPI.insight.generateFootprintInsight({
+ rangeLabel: currentRange.label,
+ summary: data.summary,
+ privateSegments: privateSegments.map((item: MyFootprintPrivateSegment | MyFootprintPrivateSession) => ({
+ session_id: item.session_id,
+ displayName: item.displayName,
+ incoming_count: item.incoming_count,
+ outgoing_count: item.outgoing_count,
+ message_count: 'message_count' in item ? item.message_count : item.incoming_count + item.outgoing_count,
+ replied: item.replied
+ })),
+ mentionGroups: data.mention_groups.slice(0, 12).map((item) => ({
+ session_id: item.session_id,
+ displayName: item.displayName,
+ count: item.count
+ }))
+ })
+ if (!result.success || !result.insight) {
+ setFootprintAiStatus('error')
+ setFootprintAiText(result.message || '生成失败')
+ return
+ }
+ setFootprintAiStatus('success')
+ setFootprintAiText(result.insight)
+ } catch (generateError) {
+ setFootprintAiStatus('error')
+ setFootprintAiText(String(generateError))
+ }
+ }, [currentRange.label, data])
+
+ return (
+
+
+
+
我的微信足迹
+
范围:{currentRange.label}
+
+
+
+
+ {[
+ { value: 'today', label: '今天' },
+ { value: 'yesterday', label: '昨天' },
+ { value: 'this_week', label: '本周' },
+ { value: 'last_week', label: '上周' },
+ { value: 'custom', label: '自定义' }
+ ].map((item) => (
+
+ ))}
+
+
+ {preset === 'custom' && (
+
+
+
+ )}
+
+
+
+
+ setSearchKeyword(event.target.value)}
+ placeholder="搜索联系人/群聊/内容"
+ />
+
+
+
+
+
+
+
+
+
+ {loading ? (
+
+
+ {Array.from({ length: 4 }).map((_, index) => (
+
+ ))}
+
+
+ {Array.from({ length: 7 }).map((_, index) => (
+
+ ))}
+
+
+ ) : error ? (
+
+ 读取我的足迹失败
+ {error}
+
+
+ ) : (
+ <>
+
+
+
+
+
+
+
+ {footprintAiStatus !== 'idle' && (
+
+
+ AI 足迹总结
+ {currentRange.label}
+
+ {footprintAiText}
+
+ )}
+
+
+
+
+
联络时间线
+
最上方是时间区间开始,最下方是时间区间终点,中间按时间展示群聊 @我 与私聊分段会话节点。
+
+
+
+
+
+
+
+
+ {timelineEventCount === 0 ? (
+ 当前区间暂无联络事件,试试切换日期范围或清空关键词筛选。
+ ) : (
+
+ {timelineItems.map((item, index) => (
+
+
+ {item.kind === 'private' ? (
+
+ {formatTimelineMoment(item.time, timelineTimeMode)}
+ {item.isRange && (
+
+ {formatTimelineMoment(item.endTime, timelineTimeMode)}
+
+ )}
+
+ ) : (
+ formatTimelineMoment(item.time, timelineTimeMode)
+ )}
+
+
+ {item.kind === 'private' ? (
+
+
+ {item.isRange && (
+ <>
+
+
+ >
+ )}
+
+ ) : (
+
+ )}
+
+
+ {item.kind === 'boundary' && (
+
{item.label}
+ )}
+
+ {item.kind === 'mention' && (
+
+
+
+
+ {item.groupAvatarUrl ? (
+

+ ) : (
+
+ )}
+
+
+
{item.groupName}
+
发送人:{item.senderName}
+
+
+
+
+
{renderMentionContent(item.messageContent)}
+
+ )}
+
+ {item.kind === 'private' && (
+
+
+
+
+ {item.avatarUrl ? (
+

+ ) : (
+
+ )}
+
+
+
{item.displayName}
+
{item.subtitle}
+
+
+
+ 共 {item.totalInteractions} 条
+
+
+
+
{item.summaryText}
+
+ )}
+
+
+ ))}
+
+ )}
+
+ >
+ )}
+ {exportModalStatus !== 'idle' && (
+
+
+
+ {exportModalStatus === 'progress' &&
}
+ {exportModalStatus === 'success' &&
}
+ {exportModalStatus === 'error' &&
}
+
+
{exportModalTitle}
+
{exportModalDescription}
+ {exportModalPath &&
{exportModalPath}}
+ {exportModalStatus !== 'progress' && (
+
+
+
+ )}
+
+
+ )}
+
+ )
+}
+
+export default MyFootprintPage
diff --git a/src/pages/SettingsPage.scss b/src/pages/SettingsPage.scss
index 37eb6b1..ac35a22 100644
--- a/src/pages/SettingsPage.scss
+++ b/src/pages/SettingsPage.scss
@@ -177,6 +177,66 @@
box-shadow: var(--shadow-sm);
}
}
+
+ .tab-group {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ }
+
+ .tab-group-trigger {
+ position: relative;
+ }
+
+ .tab-group-arrow {
+ margin-left: auto;
+ color: var(--text-tertiary);
+ transition: transform 0.2s ease;
+
+ &.expanded {
+ transform: rotate(180deg);
+ }
+ }
+
+ .tab-sublist {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ padding-left: 8px;
+ }
+
+ .tab-sublist-wrap {
+ display: grid;
+ grid-template-rows: 0fr;
+ opacity: 0;
+ transition: grid-template-rows 0.22s ease, opacity 0.18s ease;
+
+ &.expanded {
+ grid-template-rows: 1fr;
+ opacity: 1;
+ }
+
+ &.collapsed {
+ pointer-events: none;
+ }
+ }
+
+ .tab-sublist {
+ min-height: 0;
+ overflow: hidden;
+ }
+
+ .tab-sub-btn {
+ padding-left: 24px;
+ font-size: 13px;
+ }
+
+ .tab-sub-dot {
+ width: 5px;
+ height: 5px;
+ border-radius: 999px;
+ background: color-mix(in srgb, var(--text-tertiary) 70%, transparent);
+ }
}
.settings-body {
@@ -199,6 +259,12 @@
}
}
+.ai-prompt-textarea {
+ font-family: inherit !important;
+ font-size: 14px !important;
+ line-height: 1.6;
+}
+
.tab-content {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
@@ -2283,6 +2349,24 @@
border-radius: 10px;
}
+.filter-panel-action {
+ flex-shrink: 0;
+ border: 1px solid var(--border-color);
+ border-radius: 6px;
+ background: var(--bg-tertiary);
+ color: var(--text-secondary);
+ padding: 4px 8px;
+ font-size: 12px;
+ cursor: pointer;
+ transition: all 0.16s ease;
+
+ &:hover {
+ color: var(--primary);
+ border-color: color-mix(in srgb, var(--primary) 42%, var(--border-color));
+ background: color-mix(in srgb, var(--primary) 8%, var(--bg-tertiary));
+ }
+}
+
.filter-panel-list {
flex: 1;
min-height: 200px;
@@ -2346,6 +2430,16 @@
white-space: nowrap;
}
+ .filter-item-type {
+ flex-shrink: 0;
+ padding: 2px 6px;
+ border-radius: 6px;
+ font-size: 11px;
+ color: var(--text-secondary);
+ background: var(--bg-tertiary);
+ border: 1px solid var(--border-color);
+ }
+
.filter-item-action {
font-size: 18px;
font-weight: 500;
@@ -2355,6 +2449,36 @@
}
}
+.push-filter-type-tabs {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin-top: 12px;
+ margin-bottom: 10px;
+}
+
+.push-filter-type-tab {
+ border: 1px solid var(--border-color);
+ border-radius: 8px;
+ background: var(--bg-primary);
+ color: var(--text-secondary);
+ padding: 6px 10px;
+ font-size: 13px;
+ cursor: pointer;
+ transition: all 0.16s ease;
+
+ &:hover {
+ color: var(--text-primary);
+ border-color: color-mix(in srgb, var(--primary) 38%, var(--border-color));
+ }
+
+ &.active {
+ color: var(--primary);
+ border-color: color-mix(in srgb, var(--primary) 54%, var(--border-color));
+ background: color-mix(in srgb, var(--primary) 8%, var(--bg-primary));
+ }
+}
+
.filter-panel-empty {
display: flex;
align-items: center;
diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx
index 2d6c3f2..b62f101 100644
--- a/src/pages/SettingsPage.tsx
+++ b/src/pages/SettingsPage.tsx
@@ -6,6 +6,7 @@ import { useThemeStore, themes } from '../stores/themeStore'
import { useAnalyticsStore } from '../stores/analyticsStore'
import { dialog } from '../services/ipc'
import * as configService from '../services/config'
+import type { ContactInfo } from '../types/models'
import {
Eye, EyeOff, FolderSearch, FolderOpen, Search, Copy,
RotateCcw, Trash2, Plug, Check, Sun, Moon, Monitor,
@@ -16,9 +17,23 @@ import {
import { Avatar } from '../components/Avatar'
import './SettingsPage.scss'
-type SettingsTab = 'appearance' | 'notification' | 'antiRevoke' | 'database' | 'models' | 'cache' | 'api' | 'updates' | 'security' | 'about' | 'analytics' | 'insight'
+type SettingsTab =
+ | 'appearance'
+ | 'notification'
+ | 'antiRevoke'
+ | 'database'
+ | 'models'
+ | 'cache'
+ | 'api'
+ | 'updates'
+ | 'security'
+ | 'about'
+ | 'analytics'
+ | 'aiCommon'
+ | 'insight'
+ | 'aiFootprint'
-const tabs: { id: SettingsTab; label: string; icon: React.ElementType }[] = [
+const tabs: { id: Exclude; label: string; icon: React.ElementType }[] = [
{ id: 'appearance', label: '外观', icon: Palette },
{ id: 'notification', label: '通知', icon: Bell },
{ id: 'antiRevoke', label: '防撤回', icon: RotateCcw },
@@ -27,12 +42,17 @@ const tabs: { id: SettingsTab; label: string; icon: React.ElementType }[] = [
{ id: 'cache', label: '缓存', icon: HardDrive },
{ id: 'api', label: 'API 服务', icon: Globe },
{ id: 'analytics', label: '分析', icon: BarChart2 },
- { id: 'insight', label: 'AI 见解', icon: Sparkles },
{ id: 'security', label: '安全', icon: ShieldCheck },
{ id: 'updates', label: '版本更新', icon: RefreshCw },
{ id: 'about', label: '关于', icon: Info }
]
+const aiTabs: Array<{ id: Extract; label: string }> = [
+ { id: 'aiCommon', label: 'AI 通用' },
+ { id: 'insight', label: 'AI 见解' },
+ { id: 'aiFootprint', label: 'AI 足迹' }
+]
+
const isMac = navigator.userAgent.toLowerCase().includes('mac')
const isLinux = navigator.userAgent.toLowerCase().includes('linux')
const isWindows = !isMac && !isLinux
@@ -52,6 +72,25 @@ interface WxidOption {
avatarUrl?: string
}
+type SessionFilterType = configService.MessagePushSessionType
+type SessionFilterTypeValue = 'all' | SessionFilterType
+type SessionFilterMode = 'all' | 'whitelist' | 'blacklist'
+
+interface SessionFilterOption {
+ username: string
+ displayName: string
+ avatarUrl?: string
+ type: SessionFilterType
+}
+
+const sessionFilterTypeOptions: Array<{ value: SessionFilterTypeValue; label: string }> = [
+ { value: 'all', label: '全部' },
+ { value: 'private', label: '私聊' },
+ { value: 'group', label: '群聊' },
+ { value: 'official', label: '订阅号/服务号' },
+ { value: 'other', label: '其他/非好友' }
+]
+
interface SettingsPageProps {
onClose?: () => void
}
@@ -88,6 +127,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
const clearAnalyticsStoreCache = useAnalyticsStore((state) => state.clearCache)
const [activeTab, setActiveTab] = useState('appearance')
+ const [aiGroupExpanded, setAiGroupExpanded] = useState(false)
const [decryptKey, setDecryptKey] = useState('')
const [imageXorKey, setImageXorKey] = useState('')
const [imageAesKey, setImageAesKey] = useState('')
@@ -125,7 +165,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
setHttpApiToken(token)
await configService.setHttpApiToken(token)
- showMessage('已生成��保存新的 Access Token', true)
+ showMessage('已生成并保存新的 Access Token', true)
}
const clearApiToken = async () => {
@@ -150,6 +190,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
const [quoteLayout, setQuoteLayout] = useState('quote-top')
const [updateChannel, setUpdateChannel] = useState('stable')
const [filterSearchKeyword, setFilterSearchKeyword] = useState('')
+ const [notificationTypeFilter, setNotificationTypeFilter] = useState('all')
const [filterModeDropdownOpen, setFilterModeDropdownOpen] = useState(false)
const [positionDropdownOpen, setPositionDropdownOpen] = useState(false)
const [closeBehaviorDropdownOpen, setCloseBehaviorDropdownOpen] = useState(false)
@@ -205,6 +246,12 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
const [isTogglingApi, setIsTogglingApi] = useState(false)
const [showApiWarning, setShowApiWarning] = useState(false)
const [messagePushEnabled, setMessagePushEnabled] = useState(false)
+ const [messagePushFilterMode, setMessagePushFilterMode] = useState('all')
+ const [messagePushFilterList, setMessagePushFilterList] = useState([])
+ const [messagePushFilterDropdownOpen, setMessagePushFilterDropdownOpen] = useState(false)
+ const [messagePushFilterSearchKeyword, setMessagePushFilterSearchKeyword] = useState('')
+ const [messagePushTypeFilter, setMessagePushTypeFilter] = useState('all')
+ const [messagePushContactOptions, setMessagePushContactOptions] = useState([])
const [antiRevokeSearchKeyword, setAntiRevokeSearchKeyword] = useState('')
const [antiRevokeSelectedIds, setAntiRevokeSelectedIds] = useState>(new Set())
const [antiRevokeStatusMap, setAntiRevokeStatusMap] = useState>({})
@@ -217,9 +264,9 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
// AI 见解 state
const [aiInsightEnabled, setAiInsightEnabled] = useState(false)
- const [aiInsightApiBaseUrl, setAiInsightApiBaseUrl] = useState('')
- const [aiInsightApiKey, setAiInsightApiKey] = useState('')
- const [aiInsightApiModel, setAiInsightApiModel] = useState('gpt-4o-mini')
+ const [aiModelApiBaseUrl, setAiModelApiBaseUrl] = useState('')
+ const [aiModelApiKey, setAiModelApiKey] = useState('')
+ const [aiModelApiModel, setAiModelApiModel] = useState('gpt-4o-mini')
const [aiInsightSilenceDays, setAiInsightSilenceDays] = useState(3)
const [aiInsightAllowContext, setAiInsightAllowContext] = useState(false)
const [isTestingInsight, setIsTestingInsight] = useState(false)
@@ -237,6 +284,9 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
const [aiInsightTelegramEnabled, setAiInsightTelegramEnabled] = useState(false)
const [aiInsightTelegramToken, setAiInsightTelegramToken] = useState('')
const [aiInsightTelegramChatIds, setAiInsightTelegramChatIds] = useState('')
+ const [aiFootprintEnabled, setAiFootprintEnabled] = useState(false)
+ const [aiFootprintSystemPrompt, setAiFootprintSystemPrompt] = useState('')
+ const [aiInsightDebugLogEnabled, setAiInsightDebugLogEnabled] = useState(false)
// 检查 Hello 可用性
useEffect(() => {
@@ -276,6 +326,12 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
setActiveTab(initialTab)
}, [location.state])
+ useEffect(() => {
+ if (activeTab === 'aiCommon' || activeTab === 'insight' || activeTab === 'aiFootprint') {
+ setAiGroupExpanded(true)
+ }
+ }, [activeTab])
+
useEffect(() => {
if (!onClose) return
const handleKeyDown = (event: KeyboardEvent) => {
@@ -328,15 +384,16 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
setFilterModeDropdownOpen(false)
setPositionDropdownOpen(false)
setCloseBehaviorDropdownOpen(false)
+ setMessagePushFilterDropdownOpen(false)
}
}
- if (filterModeDropdownOpen || positionDropdownOpen || closeBehaviorDropdownOpen) {
+ if (filterModeDropdownOpen || positionDropdownOpen || closeBehaviorDropdownOpen || messagePushFilterDropdownOpen) {
document.addEventListener('click', handleClickOutside)
}
return () => {
document.removeEventListener('click', handleClickOutside)
}
- }, [closeBehaviorDropdownOpen, filterModeDropdownOpen, positionDropdownOpen])
+ }, [closeBehaviorDropdownOpen, filterModeDropdownOpen, messagePushFilterDropdownOpen, positionDropdownOpen])
const loadConfig = async () => {
@@ -359,6 +416,9 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
const savedNotificationFilterMode = await configService.getNotificationFilterMode()
const savedNotificationFilterList = await configService.getNotificationFilterList()
const savedMessagePushEnabled = await configService.getMessagePushEnabled()
+ const savedMessagePushFilterMode = await configService.getMessagePushFilterMode()
+ const savedMessagePushFilterList = await configService.getMessagePushFilterList()
+ const contactsResult = await window.electronAPI.chat.getContacts({ lite: true })
const savedLaunchAtStartupStatus = await window.electronAPI.app.getLaunchAtStartupStatus()
const savedWindowCloseBehavior = await configService.getWindowCloseBehavior()
const savedQuoteLayout = await configService.getQuoteLayout()
@@ -409,6 +469,11 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
setNotificationFilterMode(savedNotificationFilterMode)
setNotificationFilterList(savedNotificationFilterList)
setMessagePushEnabled(savedMessagePushEnabled)
+ setMessagePushFilterMode(savedMessagePushFilterMode)
+ setMessagePushFilterList(savedMessagePushFilterList)
+ if (contactsResult.success && Array.isArray(contactsResult.contacts)) {
+ setMessagePushContactOptions(contactsResult.contacts as ContactInfo[])
+ }
setLaunchAtStartup(savedLaunchAtStartupStatus.enabled)
setLaunchAtStartupSupported(savedLaunchAtStartupStatus.supported)
setLaunchAtStartupReason(savedLaunchAtStartupStatus.reason || '')
@@ -448,35 +513,42 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
// 加载 AI 见解配置
const savedAiInsightEnabled = await configService.getAiInsightEnabled()
- const savedAiInsightApiBaseUrl = await configService.getAiInsightApiBaseUrl()
- const savedAiInsightApiKey = await configService.getAiInsightApiKey()
- const savedAiInsightApiModel = await configService.getAiInsightApiModel()
+ const savedAiModelApiBaseUrl = await configService.getAiModelApiBaseUrl()
+ const savedAiModelApiKey = await configService.getAiModelApiKey()
+ const savedAiModelApiModel = await configService.getAiModelApiModel()
const savedAiInsightSilenceDays = await configService.getAiInsightSilenceDays()
- const savedAiInsightAllowContext = await configService.getAiInsightAllowContext()
- const savedAiInsightWhitelistEnabled = await configService.getAiInsightWhitelistEnabled()
- const savedAiInsightWhitelist = await configService.getAiInsightWhitelist()
- const savedAiInsightCooldownMinutes = await configService.getAiInsightCooldownMinutes()
- const savedAiInsightScanIntervalHours = await configService.getAiInsightScanIntervalHours()
- const savedAiInsightContextCount = await configService.getAiInsightContextCount()
- const savedAiInsightSystemPrompt = await configService.getAiInsightSystemPrompt()
- const savedAiInsightTelegramEnabled = await configService.getAiInsightTelegramEnabled()
- const savedAiInsightTelegramToken = await configService.getAiInsightTelegramToken()
- const savedAiInsightTelegramChatIds = await configService.getAiInsightTelegramChatIds()
- setAiInsightEnabled(savedAiInsightEnabled)
- setAiInsightApiBaseUrl(savedAiInsightApiBaseUrl)
- setAiInsightApiKey(savedAiInsightApiKey)
- setAiInsightApiModel(savedAiInsightApiModel)
- setAiInsightSilenceDays(savedAiInsightSilenceDays)
- setAiInsightAllowContext(savedAiInsightAllowContext)
- setAiInsightWhitelistEnabled(savedAiInsightWhitelistEnabled)
- setAiInsightWhitelist(new Set(savedAiInsightWhitelist))
- setAiInsightCooldownMinutes(savedAiInsightCooldownMinutes)
- setAiInsightScanIntervalHours(savedAiInsightScanIntervalHours)
- setAiInsightContextCount(savedAiInsightContextCount)
- setAiInsightSystemPrompt(savedAiInsightSystemPrompt)
- setAiInsightTelegramEnabled(savedAiInsightTelegramEnabled)
- setAiInsightTelegramToken(savedAiInsightTelegramToken)
- setAiInsightTelegramChatIds(savedAiInsightTelegramChatIds)
+ const savedAiInsightAllowContext = await configService.getAiInsightAllowContext()
+ const savedAiInsightWhitelistEnabled = await configService.getAiInsightWhitelistEnabled()
+ const savedAiInsightWhitelist = await configService.getAiInsightWhitelist()
+ const savedAiInsightCooldownMinutes = await configService.getAiInsightCooldownMinutes()
+ const savedAiInsightScanIntervalHours = await configService.getAiInsightScanIntervalHours()
+ const savedAiInsightContextCount = await configService.getAiInsightContextCount()
+ const savedAiInsightSystemPrompt = await configService.getAiInsightSystemPrompt()
+ const savedAiInsightTelegramEnabled = await configService.getAiInsightTelegramEnabled()
+ const savedAiInsightTelegramToken = await configService.getAiInsightTelegramToken()
+ const savedAiInsightTelegramChatIds = await configService.getAiInsightTelegramChatIds()
+ const savedAiFootprintEnabled = await configService.getAiFootprintEnabled()
+ const savedAiFootprintSystemPrompt = await configService.getAiFootprintSystemPrompt()
+ const savedAiInsightDebugLogEnabled = await configService.getAiInsightDebugLogEnabled()
+
+ setAiInsightEnabled(savedAiInsightEnabled)
+ setAiModelApiBaseUrl(savedAiModelApiBaseUrl)
+ setAiModelApiKey(savedAiModelApiKey)
+ setAiModelApiModel(savedAiModelApiModel)
+ setAiInsightSilenceDays(savedAiInsightSilenceDays)
+ setAiInsightAllowContext(savedAiInsightAllowContext)
+ setAiInsightWhitelistEnabled(savedAiInsightWhitelistEnabled)
+ setAiInsightWhitelist(new Set(savedAiInsightWhitelist))
+ setAiInsightCooldownMinutes(savedAiInsightCooldownMinutes)
+ setAiInsightScanIntervalHours(savedAiInsightScanIntervalHours)
+ setAiInsightContextCount(savedAiInsightContextCount)
+ setAiInsightSystemPrompt(savedAiInsightSystemPrompt)
+ setAiInsightTelegramEnabled(savedAiInsightTelegramEnabled)
+ setAiInsightTelegramToken(savedAiInsightTelegramToken)
+ setAiInsightTelegramChatIds(savedAiInsightTelegramChatIds)
+ setAiFootprintEnabled(savedAiFootprintEnabled)
+ setAiFootprintSystemPrompt(savedAiFootprintSystemPrompt)
+ setAiInsightDebugLogEnabled(savedAiInsightDebugLogEnabled)
} catch (e: any) {
console.error('加载配置失败:', e)
@@ -618,7 +690,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
showMessage(`已切换到${channelLabel}更新渠道,正在检查更新`, true)
await handleCheckUpdate()
} catch (e: any) {
- showMessage(`切换更新渠道��败: ${e}`, false)
+ showMessage(`切换更新渠道失败: ${e}`, false)
}
}
@@ -1154,7 +1226,13 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
const keysOverride = buildKeysFromInputs({ decryptKey: result.key })
await handleScanWxid(true, { preferCurrentKeys: true, showDialog: false, keysOverride })
} else {
- if (result.error?.includes('未找到微信安装路径') || result.error?.includes('启动微信失败')) {
+ if (
+ result.error?.includes('未找到微信安装路径') ||
+ result.error?.includes('启动微信失败') ||
+ result.error?.includes('未能自动启动微信') ||
+ result.error?.includes('未找到微信进程') ||
+ result.error?.includes('微信进程未运行')
+ ) {
setIsManualStartPrompt(true)
setDbKeyStatus('需要手动启动微信')
} else {
@@ -1213,7 +1291,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
if (result.success && result.aesKey) {
if (typeof result.xorKey === 'number') setImageXorKey(`0x${result.xorKey.toString(16).toUpperCase().padStart(2, '0')}`)
setImageAesKey(result.aesKey)
- setImageKeyStatus('已获取图片��钥')
+ setImageKeyStatus('已获取图片密钥')
showMessage('已自动获取图片密钥', true)
const newXorKey = typeof result.xorKey === 'number' ? result.xorKey : 0
const newAesKey = result.aesKey
@@ -1457,13 +1535,11 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
{
value: 'quote-top' as const,
label: '引用在上',
- description: '更接近当前 WeFlow 风格',
successMessage: '已切换为引用在上样式'
},
{
value: 'quote-bottom' as const,
label: '正文在上',
- description: '更接近微信 / 密语风格',
successMessage: '已切换为正文在上样式'
}
].map(option => {
@@ -1513,7 +1589,6 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
{option.label}
- {option.description}
@@ -1613,15 +1688,6 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
)
const renderNotificationTab = () => {
- // 获取已过滤会话的信息
- const getSessionInfo = (username: string) => {
- const session = chatSessions.find(s => s.username === username)
- return {
- displayName: session?.displayName || username,
- avatarUrl: session?.avatarUrl || ''
- }
- }
-
// 添加会话到过滤列表
const handleAddToFilterList = async (username: string) => {
if (notificationFilterList.includes(username)) return
@@ -1639,18 +1705,6 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
showMessage('已从过滤列表移除', true)
}
- // 过滤掉已在列表中的会话,并根据搜索关键字过滤
- const availableSessions = chatSessions.filter(s => {
- if (notificationFilterList.includes(s.username)) return false
- if (filterSearchKeyword) {
- const keyword = filterSearchKeyword.toLowerCase()
- const displayName = (s.displayName || '').toLowerCase()
- const username = s.username.toLowerCase()
- return displayName.includes(keyword) || username.includes(keyword)
- }
- return true
- })
-
return (
@@ -1742,17 +1796,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
{
- const val = option.value as 'all' | 'whitelist' | 'blacklist'
- setNotificationFilterMode(val)
- setFilterModeDropdownOpen(false)
- await configService.setNotificationFilterMode(val)
- showMessage(
- val === 'all' ? '已设为接收所有通知' :
- val === 'whitelist' ? '已设为仅接收白名单通知' : '已设为屏蔽黑名单通知',
- true
- )
- }}
+ onClick={() => { void handleSetNotificationFilterMode(option.value as SessionFilterMode) }}
>
{option.label}
{notificationFilterMode === option.value &&
}
@@ -1771,11 +1815,33 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
: '点击左侧会话添加到黑名单,点击右侧会话从黑名单移除'}
+
+ {sessionFilterTypeOptions.map(option => (
+
+ ))}
+
+
{/* 可选会话列表 */}
可选会话
+ {notificationAvailableSessions.length > 0 && (
+
+ )}
- {availableSessions.length > 0 ? (
- availableSessions.map(session => (
+ {notificationAvailableSessions.length > 0 ? (
+ notificationAvailableSessions.map(session => (
{session.displayName || session.username}
+
{getSessionFilterTypeLabel(session.type)}
+
))
) : (
- {filterSearchKeyword ? '没有匹配的会话' : '暂无可添加的会话'}
+ {filterSearchKeyword || notificationTypeFilter !== 'all' ? '没有匹配的会话' : '暂无可添加的会话'}
)}
@@ -1818,11 +1885,20 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
{notificationFilterList.length > 0 && (
{notificationFilterList.length}
)}
+ {notificationFilterList.length > 0 && (
+
+ )}
{notificationFilterList.length > 0 ? (
notificationFilterList.map(username => {
- const info = getSessionInfo(username)
+ const info = getSessionFilterOptionInfo(username)
return (
{info.displayName}
+
{getSessionFilterTypeLabel(info.type)}
×
)
@@ -2079,9 +2156,9 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
{isManualStartPrompt ? (
-
未能自动启动微信,请手动启动并登录后点击下方确认
+
未能自动启动微信,请手动启动微信,看到登录窗口后点击下方确认
) : (
@@ -2488,11 +2565,168 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
showMessage(enabled ? '已开启主动推送' : '已关闭主动推送', true)
}
+ const getSessionFilterType = (session: { username: string; type?: ContactInfo['type'] | number }): SessionFilterType => {
+ const username = String(session.username || '').trim()
+ if (username.endsWith('@chatroom')) return 'group'
+ if (username.startsWith('gh_') || session.type === 'official') return 'official'
+ if (username.toLowerCase().includes('placeholder_foldgroup')) return 'other'
+ if (session.type === 'former_friend' || session.type === 'other') return 'other'
+ return 'private'
+ }
+
+ const getSessionFilterTypeLabel = (type: SessionFilterType) => {
+ switch (type) {
+ case 'private': return '私聊'
+ case 'group': return '群聊'
+ case 'official': return '订阅号/服务号'
+ default: return '其他/非好友'
+ }
+ }
+
+ const handleSetMessagePushFilterMode = async (mode: configService.MessagePushFilterMode) => {
+ setMessagePushFilterMode(mode)
+ setMessagePushFilterDropdownOpen(false)
+ await configService.setMessagePushFilterMode(mode)
+ showMessage(
+ mode === 'all' ? '主动推送已设为接收所有会话' :
+ mode === 'whitelist' ? '主动推送已设为仅推送白名单' : '主动推送已设为屏蔽黑名单',
+ true
+ )
+ }
+
+ const handleAddMessagePushFilterSession = async (username: string) => {
+ if (messagePushFilterList.includes(username)) return
+ const next = [...messagePushFilterList, username]
+ setMessagePushFilterList(next)
+ await configService.setMessagePushFilterList(next)
+ showMessage('已添加到主动推送过滤列表', true)
+ }
+
+ const handleRemoveMessagePushFilterSession = async (username: string) => {
+ const next = messagePushFilterList.filter(item => item !== username)
+ setMessagePushFilterList(next)
+ await configService.setMessagePushFilterList(next)
+ showMessage('已从主动推送过滤列表移除', true)
+ }
+
+ const handleAddAllMessagePushFilterSessions = async () => {
+ const usernames = messagePushAvailableSessions.map(session => session.username)
+ if (usernames.length === 0) return
+ const next = Array.from(new Set([...messagePushFilterList, ...usernames]))
+ setMessagePushFilterList(next)
+ await configService.setMessagePushFilterList(next)
+ showMessage(`已添加 ${usernames.length} 个会话`, true)
+ }
+
+ const handleRemoveAllMessagePushFilterSessions = async () => {
+ if (messagePushFilterList.length === 0) return
+ setMessagePushFilterList([])
+ await configService.setMessagePushFilterList([])
+ showMessage('已清空主动推送过滤列表', true)
+ }
+
+ const sessionFilterOptionMap = new Map
()
+
+ 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 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)
+ })
+ }
+
+ 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 getSessionFilterOptionInfo = (username: string) => {
+ return sessionFilterOptionMap.get(username) || {
+ username,
+ displayName: username,
+ avatarUrl: undefined,
+ type: 'other' as SessionFilterType
+ }
+ }
+
+ const getAvailableSessionFilterOptions = (
+ selectedList: string[],
+ typeFilter: SessionFilterTypeValue,
+ searchKeyword: string
+ ) => {
+ const keyword = searchKeyword.trim().toLowerCase()
+ return sessionFilterOptions.filter(session => {
+ if (selectedList.includes(session.username)) return false
+ if (typeFilter !== 'all' && session.type !== typeFilter) return false
+ if (keyword) {
+ return String(session.displayName || '').toLowerCase().includes(keyword) ||
+ session.username.toLowerCase().includes(keyword)
+ }
+ return true
+ })
+ }
+
+ const notificationAvailableSessions = getAvailableSessionFilterOptions(
+ notificationFilterList,
+ notificationTypeFilter,
+ filterSearchKeyword
+ )
+
+ const messagePushAvailableSessions = getAvailableSessionFilterOptions(
+ messagePushFilterList,
+ messagePushTypeFilter,
+ messagePushFilterSearchKeyword
+ )
+
+ const handleAddAllNotificationFilterSessions = async () => {
+ const usernames = notificationAvailableSessions.map(session => session.username)
+ if (usernames.length === 0) return
+ const next = Array.from(new Set([...notificationFilterList, ...usernames]))
+ setNotificationFilterList(next)
+ await configService.setNotificationFilterList(next)
+ showMessage(`已添加 ${usernames.length} 个会话`, true)
+ }
+
+ const handleRemoveAllNotificationFilterSessions = async () => {
+ if (notificationFilterList.length === 0) return
+ setNotificationFilterList([])
+ await configService.setNotificationFilterList([])
+ showMessage('已清空通知过滤列表', true)
+ }
+
+ const handleSetNotificationFilterMode = async (mode: SessionFilterMode) => {
+ setNotificationFilterMode(mode)
+ setFilterModeDropdownOpen(false)
+ await configService.setNotificationFilterMode(mode)
+ showMessage(
+ mode === 'all' ? '已设为接收所有通知' :
+ mode === 'whitelist' ? '已设为仅接收白名单通知' : '已设为屏蔽黑名单通知',
+ true
+ )
+ }
+
const handleTestInsightConnection = async () => {
setIsTestingInsight(true)
setInsightTestResult(null)
try {
- const result = await (window.electronAPI as any).insight.testConnection()
+ const result = await window.electronAPI.insight.testConnection()
setInsightTestResult(result)
} catch (e: any) {
setInsightTestResult({ success: false, message: `调用失败:${e?.message || String(e)}` })
@@ -2501,6 +2735,118 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
}
}
+ const renderAiCommonTab = () => (
+
+
+
+
+ 这是「AI 见解」与「AI 足迹总结」共享的模型接入配置。填写 OpenAI 兼容接口的 Base URL,末尾不要加斜杠。
+ 程序会自动拼接 /chat/completions。
+
+ 示例:https://api.ohmygpt.com/v1 或 https://api.openai.com/v1
+
+ {
+ const val = e.target.value
+ setAiModelApiBaseUrl(val)
+ scheduleConfigSave('aiModelApiBaseUrl', () => configService.setAiModelApiBaseUrl(val))
+ }}
+ />
+
+
+
+
+
+ 你的 API Key,保存后经过系统加密存储,不会明文写入磁盘。
+
+
+ {
+ const val = e.target.value
+ setAiModelApiKey(val)
+ scheduleConfigSave('aiModelApiKey', () => configService.setAiModelApiKey(val))
+ }}
+ style={{ flex: 1 }}
+ />
+
+ {aiModelApiKey && (
+
+ )}
+
+
+
+
+
+
+ 填写你的 API 提供商支持的模型名,将同时用于见解和足迹模块。
+
+ 常用示例:gpt-4o-mini、gpt-4o、deepseek-chat、claude-3-5-haiku-20241022
+
+ {
+ const val = e.target.value.trim() || 'gpt-4o-mini'
+ setAiModelApiModel(val)
+ scheduleConfigSave('aiModelApiModel', () => configService.setAiModelApiModel(val))
+ }}
+ style={{ width: 260 }}
+ />
+
+
+
+
+
+ 测试通用模型连接,见解与足迹都会使用这套配置。
+
+
+
+ {insightTestResult && (
+
+ {insightTestResult.success ? : }
+ {insightTestResult.message}
+
+ )}
+
+
+
+ )
+
const renderInsightTab = () => (
{/* 总开关 */}
@@ -2529,149 +2875,41 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
- {/* API 配置 */}
-
-
-
- 填写 OpenAI 兼容接口的 Base URL,末尾不要加斜杠。
- 程序会自动拼接 /chat/completions。
-
- 示例:https://api.ohmygpt.com/v1 或 https://api.openai.com/v1
-
- {
- const val = e.target.value
- setAiInsightApiBaseUrl(val)
- scheduleConfigSave('aiInsightApiBaseUrl', () => configService.setAiInsightApiBaseUrl(val))
- }}
- style={{ fontFamily: 'monospace' }}
- />
-
-
-
-
-
- 你的 API Key,保存后经过系统加密存储,不会明文写入磁盘。
-
-
- {
- const val = e.target.value
- setAiInsightApiKey(val)
- scheduleConfigSave('aiInsightApiKey', () => configService.setAiInsightApiKey(val))
- }}
- style={{ flex: 1, fontFamily: 'monospace' }}
- />
-
- {aiInsightApiKey && (
-
- )}
-
-
-
-
-
-
- 填写你的 API 提供商支持的模型名,建议使用综合能力较强的模型以获得有洞察力的见解。
-
- 常用示例:gpt-4o-mini、gpt-4o、deepseek-chat、claude-3-5-haiku-20241022
-
- {
- const val = e.target.value.trim() || 'gpt-4o-mini'
- setAiInsightApiModel(val)
- scheduleConfigSave('aiInsightApiModel', () => configService.setAiInsightApiModel(val))
- }}
- style={{ width: 260, fontFamily: 'monospace' }}
- />
-
-
- {/* 测试连接 + 触发测试 */}
- 先用"测试 API 连接"确认 Key 和 URL 填写正确,再用"立即触发测试见解"验证完整链路(数据库→API→弹窗)。触发后请留意右下角通知弹窗。
+ 该功能依赖「AI 通用」里的模型配置。用于验证完整链路(数据库→API→弹窗)。
-
- {/* 测试 API 连接 */}
-
-
- {insightTestResult && (
-
- {insightTestResult.success ? : }
- {insightTestResult.message}
-
+
+
- {/* 触发测试见解 */}
-
-
- {insightTriggerResult && (
-
- {insightTriggerResult.success ? : }
- {insightTriggerResult.message}
-
- )}
-
+
+ {insightTriggerResult && (
+
+ {insightTriggerResult.success ? : }
+ {insightTriggerResult.message}
+
+ )}
@@ -2827,9 +3065,9 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
当前显示内置默认提示词,可直接编辑修改。修改后立即生效,无需重启。可变的统计信息(触发次数、对话内容)会自动附加在用户消息里,无需在此填写。
+
+
+
+
+
+
+ 开启后,AI 见解链路会额外把完整调试日志写到桌面上的 weflow-ai-insight-debug-YYYY-MM-DD.log。
+ 其中会包含发送给 AI 的完整提示词原文、近期对话上下文原文和模型输出原文,但不会记录 API Key。
+
+
+ {aiInsightDebugLogEnabled ? '已开启' : '已关闭'}
+
+
+
+
+ )
+
+ const renderAiFootprintTab = () => (
+
+ {(() => {
+ const DEFAULT_FOOTPRINT_PROMPT = `你是用户的聊天足迹教练,负责基于统计数据给出一段简明复盘。
+要求:
+1. 输出 2-3 句,总长度不超过 180 字。
+2. 必须包含:总体观察 + 一个可执行建议。
+3. 语气务实,不夸张,不使用 Markdown。`
+ const displayValue = aiFootprintSystemPrompt || DEFAULT_FOOTPRINT_PROMPT
+ return (
+ <>
+
+
+
+ 开启后,可在「我的微信足迹」页面一键生成当前范围的 AI 复盘总结。
+
+
+ {aiFootprintEnabled ? '已开启' : '已关闭'}
+
+
+
+
+
+
+
+
+
+
+ 足迹模块专用的小配置。留空时使用内置默认提示词。
+
+
+ >
+ )
+ })()}
)
@@ -3210,7 +3542,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
value={`http://${httpApiHost}:${httpApiPort}`}
readOnly
/>
-
@@ -3249,6 +3581,154 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {