mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-06-02 15:11:36 +00:00
merge: 同步 dev 并解决 AI 见解冲突
This commit is contained in:
@@ -94,6 +94,7 @@ function App() {
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
|
||||
const [showCloseDialog, setShowCloseDialog] = useState(false)
|
||||
const [canMinimizeToTray, setCanMinimizeToTray] = useState(false)
|
||||
const [closeRestoreMethod, setCloseRestoreMethod] = useState<'tray' | 'dock'>('tray')
|
||||
|
||||
// 锁定状态
|
||||
// const [isLocked, setIsLocked] = useState(false) // Moved to store
|
||||
@@ -120,6 +121,7 @@ function App() {
|
||||
useEffect(() => {
|
||||
const removeCloseConfirmListener = window.electronAPI.window.onCloseConfirmRequested((payload) => {
|
||||
setCanMinimizeToTray(Boolean(payload.canMinimizeToTray))
|
||||
setCloseRestoreMethod(payload.restoreMethod === 'dock' ? 'dock' : 'tray')
|
||||
setShowCloseDialog(true)
|
||||
})
|
||||
|
||||
@@ -685,6 +687,7 @@ function App() {
|
||||
<WindowCloseDialog
|
||||
open={showCloseDialog}
|
||||
canMinimizeToTray={canMinimizeToTray}
|
||||
restoreMethod={closeRestoreMethod}
|
||||
onSelect={(action, rememberChoice) => handleWindowCloseAction(action, rememberChoice)}
|
||||
onCancel={() => handleWindowCloseAction('cancel')}
|
||||
/>
|
||||
|
||||
@@ -551,8 +551,13 @@ export function ExportDateRangeDialog({
|
||||
if (!open) return null
|
||||
|
||||
return createPortal(
|
||||
<div className="export-date-range-dialog-overlay" onClick={onClose}>
|
||||
<div className="export-date-range-dialog" role="dialog" aria-modal="true" onClick={(event) => event.stopPropagation()}>
|
||||
<div
|
||||
className="export-date-range-dialog-overlay"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onClose()
|
||||
}}
|
||||
> <div className="export-date-range-dialog" role="dialog" aria-modal="true" onClick={(event) => event.stopPropagation()}>
|
||||
<div className="export-date-range-dialog-header">
|
||||
<h4>{title}</h4>
|
||||
<button
|
||||
|
||||
@@ -231,7 +231,8 @@
|
||||
label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
gap: 6px;
|
||||
position: relative;
|
||||
margin-bottom: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
@@ -239,11 +240,55 @@
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: border-color 0.16s ease, background 0.16s ease;
|
||||
}
|
||||
|
||||
input[type='checkbox'] {
|
||||
margin: 0;
|
||||
accent-color: var(--primary);
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
|
||||
&:checked + .media-default-check {
|
||||
border-color: var(--primary);
|
||||
background: color-mix(in srgb, var(--primary) 88%, #fff);
|
||||
}
|
||||
|
||||
&:checked + .media-default-check::after {
|
||||
opacity: 1;
|
||||
transform: rotate(-45deg) scale(1);
|
||||
}
|
||||
|
||||
&:focus-visible + .media-default-check {
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 18%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.media-default-check {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex: 0 0 14px;
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 82%, var(--text-tertiary));
|
||||
border-radius: 4px;
|
||||
background: var(--bg-primary);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: border-color 0.16s ease, background 0.16s ease, box-shadow 0.16s ease;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
width: 7px;
|
||||
height: 4px;
|
||||
border-left: 2px solid #fff;
|
||||
border-bottom: 2px solid #fff;
|
||||
opacity: 0;
|
||||
transform: rotate(-45deg) scale(0.72);
|
||||
transform-origin: center;
|
||||
transition: opacity 0.16s ease, transform 0.16s ease;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -563,15 +608,69 @@
|
||||
gap: 8px;
|
||||
|
||||
label {
|
||||
min-height: 36px;
|
||||
padding: 8px 10px;
|
||||
min-height: 34px;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-primary);
|
||||
|
||||
&:hover {
|
||||
border-color: color-mix(in srgb, var(--primary) 38%, var(--border-color));
|
||||
background: color-mix(in srgb, var(--text-tertiary) 4%, var(--bg-primary));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.select-dropdown-floating {
|
||||
background: color-mix(in srgb, var(--bg-primary) 85%, var(--bg-secondary));
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
padding: 6px;
|
||||
box-shadow: var(--shadow-md);
|
||||
overflow-y: auto;
|
||||
backdrop-filter: blur(14px);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
|
||||
.select-option {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 10px 12px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, color 0.15s ease;
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: color-mix(in srgb, var(--primary) 12%, transparent);
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
.option-label {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.option-desc {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.select-option.active .option-desc {
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.export-defaults-settings-form.layout-split {
|
||||
.form-group {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { CSSProperties } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import * as configService from '../../services/config'
|
||||
import { ExportDateRangeDialog } from './ExportDateRangeDialog'
|
||||
@@ -56,6 +58,48 @@ const getOptionLabel = (options: ReadonlyArray<{ value: string; label: string }>
|
||||
return options.find((option) => option.value === value)?.label ?? value
|
||||
}
|
||||
|
||||
interface SelectDropdownPlacement {
|
||||
left: number
|
||||
width: number
|
||||
maxHeight: number
|
||||
top?: number
|
||||
bottom?: number
|
||||
}
|
||||
|
||||
const resolveSelectDropdownPlacement = (anchor: HTMLElement | null): SelectDropdownPlacement | null => {
|
||||
if (!anchor || typeof window === 'undefined') return null
|
||||
|
||||
const rect = anchor.getBoundingClientRect()
|
||||
const viewportWidth = window.innerWidth || document.documentElement.clientWidth
|
||||
const viewportHeight = window.innerHeight || document.documentElement.clientHeight
|
||||
const viewportMargin = 12
|
||||
const dropdownGap = 6
|
||||
const minDropdownHeight = 128
|
||||
const availableWidth = Math.max(160, viewportWidth - viewportMargin * 2)
|
||||
const width = Math.min(Math.max(rect.width, 220), availableWidth)
|
||||
const left = Math.max(viewportMargin, Math.min(rect.left, viewportWidth - width - viewportMargin))
|
||||
const spaceBelow = Math.max(0, viewportHeight - rect.bottom - viewportMargin - dropdownGap)
|
||||
const spaceAbove = Math.max(0, rect.top - viewportMargin - dropdownGap)
|
||||
const shouldOpenAbove = spaceBelow < minDropdownHeight && spaceAbove > spaceBelow
|
||||
const availableHeight = shouldOpenAbove ? spaceAbove : spaceBelow
|
||||
const maxHeight = Math.max(96, Math.min(320, availableHeight))
|
||||
|
||||
return shouldOpenAbove
|
||||
? { left, width, maxHeight, bottom: viewportHeight - rect.top + dropdownGap }
|
||||
: { left, width, maxHeight, top: rect.bottom + dropdownGap }
|
||||
}
|
||||
|
||||
const getSelectDropdownStyle = (placement: SelectDropdownPlacement): CSSProperties => ({
|
||||
position: 'fixed',
|
||||
top: placement.top,
|
||||
bottom: placement.bottom,
|
||||
left: placement.left,
|
||||
right: 'auto',
|
||||
width: placement.width,
|
||||
maxHeight: placement.maxHeight,
|
||||
zIndex: 9300
|
||||
})
|
||||
|
||||
export function ExportDefaultsSettingsForm({
|
||||
onNotify,
|
||||
onDefaultsChanged,
|
||||
@@ -66,6 +110,10 @@ export function ExportDefaultsSettingsForm({
|
||||
const [isExportDateRangeDialogOpen, setIsExportDateRangeDialogOpen] = useState(false)
|
||||
const exportExcelColumnsDropdownRef = useRef<HTMLDivElement>(null)
|
||||
const exportFileNamingModeDropdownRef = useRef<HTMLDivElement>(null)
|
||||
const exportExcelColumnsMenuRef = useRef<HTMLDivElement>(null)
|
||||
const exportFileNamingModeMenuRef = useRef<HTMLDivElement>(null)
|
||||
const [exportExcelColumnsPlacement, setExportExcelColumnsPlacement] = useState<SelectDropdownPlacement | null>(null)
|
||||
const [exportFileNamingModePlacement, setExportFileNamingModePlacement] = useState<SelectDropdownPlacement | null>(null)
|
||||
|
||||
const [exportDefaultFormat, setExportDefaultFormat] = useState('excel')
|
||||
const [exportDefaultAvatars, setExportDefaultAvatars] = useState(true)
|
||||
@@ -122,10 +170,20 @@ export function ExportDefaultsSettingsForm({
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
const target = e.target as Node
|
||||
if (showExportExcelColumnsSelect && exportExcelColumnsDropdownRef.current && !exportExcelColumnsDropdownRef.current.contains(target)) {
|
||||
if (
|
||||
showExportExcelColumnsSelect &&
|
||||
exportExcelColumnsDropdownRef.current &&
|
||||
!exportExcelColumnsDropdownRef.current.contains(target) &&
|
||||
!exportExcelColumnsMenuRef.current?.contains(target)
|
||||
) {
|
||||
setShowExportExcelColumnsSelect(false)
|
||||
}
|
||||
if (showExportFileNamingModeSelect && exportFileNamingModeDropdownRef.current && !exportFileNamingModeDropdownRef.current.contains(target)) {
|
||||
if (
|
||||
showExportFileNamingModeSelect &&
|
||||
exportFileNamingModeDropdownRef.current &&
|
||||
!exportFileNamingModeDropdownRef.current.contains(target) &&
|
||||
!exportFileNamingModeMenuRef.current?.contains(target)
|
||||
) {
|
||||
setShowExportFileNamingModeSelect(false)
|
||||
}
|
||||
}
|
||||
@@ -134,6 +192,30 @@ export function ExportDefaultsSettingsForm({
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [showExportExcelColumnsSelect, showExportFileNamingModeSelect])
|
||||
|
||||
const updateSelectDropdownPlacements = useCallback(() => {
|
||||
if (showExportExcelColumnsSelect) {
|
||||
setExportExcelColumnsPlacement(resolveSelectDropdownPlacement(exportExcelColumnsDropdownRef.current))
|
||||
}
|
||||
if (showExportFileNamingModeSelect) {
|
||||
setExportFileNamingModePlacement(resolveSelectDropdownPlacement(exportFileNamingModeDropdownRef.current))
|
||||
}
|
||||
}, [showExportExcelColumnsSelect, showExportFileNamingModeSelect])
|
||||
|
||||
useEffect(() => {
|
||||
if (!showExportExcelColumnsSelect) setExportExcelColumnsPlacement(null)
|
||||
if (!showExportFileNamingModeSelect) setExportFileNamingModePlacement(null)
|
||||
if (!showExportExcelColumnsSelect && !showExportFileNamingModeSelect) return
|
||||
|
||||
updateSelectDropdownPlacements()
|
||||
window.addEventListener('resize', updateSelectDropdownPlacements)
|
||||
document.addEventListener('scroll', updateSelectDropdownPlacements, true)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', updateSelectDropdownPlacements)
|
||||
document.removeEventListener('scroll', updateSelectDropdownPlacements, true)
|
||||
}
|
||||
}, [showExportExcelColumnsSelect, showExportFileNamingModeSelect, updateSelectDropdownPlacements])
|
||||
|
||||
const exportExcelColumnsValue = exportDefaultExcelCompactColumns ? 'compact' : 'full'
|
||||
const exportDateRangeLabel = useMemo(() => getExportDateRangeLabel(exportDefaultDateRange), [exportDefaultDateRange])
|
||||
const exportExcelColumnsLabel = useMemo(() => getOptionLabel(exportExcelColumnOptions, exportExcelColumnsValue), [exportExcelColumnsValue])
|
||||
@@ -143,6 +225,73 @@ export function ExportDefaultsSettingsForm({
|
||||
onNotify?.(text, success)
|
||||
}
|
||||
|
||||
const fileNamingModeDropdown = showExportFileNamingModeSelect && exportFileNamingModePlacement
|
||||
? createPortal(
|
||||
<div
|
||||
ref={exportFileNamingModeMenuRef}
|
||||
className="select-dropdown select-dropdown-floating"
|
||||
role="listbox"
|
||||
style={getSelectDropdownStyle(exportFileNamingModePlacement)}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
{exportFileNamingModeOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={exportDefaultFileNamingMode === option.value}
|
||||
className={`select-option ${exportDefaultFileNamingMode === option.value ? 'active' : ''}`}
|
||||
onClick={async () => {
|
||||
setExportDefaultFileNamingMode(option.value)
|
||||
await configService.setExportDefaultFileNamingMode(option.value)
|
||||
onDefaultsChanged?.({ fileNamingMode: option.value })
|
||||
notify('已更新导出文件命名方式', true)
|
||||
setShowExportFileNamingModeSelect(false)
|
||||
}}
|
||||
>
|
||||
<span className="option-label">{option.label}</span>
|
||||
<span className="option-desc">{option.desc}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
: null
|
||||
|
||||
const excelColumnsDropdown = showExportExcelColumnsSelect && exportExcelColumnsPlacement
|
||||
? createPortal(
|
||||
<div
|
||||
ref={exportExcelColumnsMenuRef}
|
||||
className="select-dropdown select-dropdown-floating"
|
||||
role="listbox"
|
||||
style={getSelectDropdownStyle(exportExcelColumnsPlacement)}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
{exportExcelColumnOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={exportExcelColumnsValue === option.value}
|
||||
className={`select-option ${exportExcelColumnsValue === option.value ? 'active' : ''}`}
|
||||
onClick={async () => {
|
||||
const compact = option.value === 'compact'
|
||||
setExportDefaultExcelCompactColumns(compact)
|
||||
await configService.setExportDefaultExcelCompactColumns(compact)
|
||||
onDefaultsChanged?.({ excelCompactColumns: compact })
|
||||
notify(compact ? '已启用精简列' : '已启用完整列', true)
|
||||
setShowExportExcelColumnsSelect(false)
|
||||
}}
|
||||
>
|
||||
<span className="option-label">{option.label}</span>
|
||||
<span className="option-desc">{option.desc}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className={`export-defaults-settings-form ${layout === 'split' ? 'layout-split' : 'layout-stacked'}`}>
|
||||
<div className="form-group">
|
||||
@@ -273,6 +422,8 @@ export function ExportDefaultsSettingsForm({
|
||||
<button
|
||||
type="button"
|
||||
className={`select-trigger ${showExportFileNamingModeSelect ? 'open' : ''}`}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={showExportFileNamingModeSelect}
|
||||
onClick={() => {
|
||||
setShowExportFileNamingModeSelect(!showExportFileNamingModeSelect)
|
||||
setShowExportExcelColumnsSelect(false)
|
||||
@@ -282,27 +433,7 @@ export function ExportDefaultsSettingsForm({
|
||||
<span className="select-value">{exportFileNamingModeLabel}</span>
|
||||
<ChevronDown size={16} />
|
||||
</button>
|
||||
{showExportFileNamingModeSelect && (
|
||||
<div className="select-dropdown">
|
||||
{exportFileNamingModeOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
className={`select-option ${exportDefaultFileNamingMode === option.value ? 'active' : ''}`}
|
||||
onClick={async () => {
|
||||
setExportDefaultFileNamingMode(option.value)
|
||||
await configService.setExportDefaultFileNamingMode(option.value)
|
||||
onDefaultsChanged?.({ fileNamingMode: option.value })
|
||||
notify('已更新导出文件命名方式', true)
|
||||
setShowExportFileNamingModeSelect(false)
|
||||
}}
|
||||
>
|
||||
<span className="option-label">{option.label}</span>
|
||||
<span className="option-desc">{option.desc}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{fileNamingModeDropdown}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -317,6 +448,8 @@ export function ExportDefaultsSettingsForm({
|
||||
<button
|
||||
type="button"
|
||||
className={`select-trigger ${showExportExcelColumnsSelect ? 'open' : ''}`}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={showExportExcelColumnsSelect}
|
||||
onClick={() => {
|
||||
setShowExportExcelColumnsSelect(!showExportExcelColumnsSelect)
|
||||
setShowExportFileNamingModeSelect(false)
|
||||
@@ -326,28 +459,7 @@ export function ExportDefaultsSettingsForm({
|
||||
<span className="select-value">{exportExcelColumnsLabel}</span>
|
||||
<ChevronDown size={16} />
|
||||
</button>
|
||||
{showExportExcelColumnsSelect && (
|
||||
<div className="select-dropdown">
|
||||
{exportExcelColumnOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
className={`select-option ${exportExcelColumnsValue === option.value ? 'active' : ''}`}
|
||||
onClick={async () => {
|
||||
const compact = option.value === 'compact'
|
||||
setExportDefaultExcelCompactColumns(compact)
|
||||
await configService.setExportDefaultExcelCompactColumns(compact)
|
||||
onDefaultsChanged?.({ excelCompactColumns: compact })
|
||||
notify(compact ? '已启用精简列' : '已启用完整列', true)
|
||||
setShowExportExcelColumnsSelect(false)
|
||||
}}
|
||||
>
|
||||
<span className="option-label">{option.label}</span>
|
||||
<span className="option-desc">{option.desc}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{excelColumnsDropdown}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -371,7 +483,8 @@ export function ExportDefaultsSettingsForm({
|
||||
notify(`已${e.target.checked ? '开启' : '关闭'}默认导出图片`, true)
|
||||
}}
|
||||
/>
|
||||
图片
|
||||
<span className="media-default-check" aria-hidden="true" />
|
||||
<span>图片</span>
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
@@ -385,7 +498,8 @@ export function ExportDefaultsSettingsForm({
|
||||
notify(`已${e.target.checked ? '开启' : '关闭'}默认导出语音`, true)
|
||||
}}
|
||||
/>
|
||||
语音
|
||||
<span className="media-default-check" aria-hidden="true" />
|
||||
<span>语音</span>
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
@@ -399,7 +513,8 @@ export function ExportDefaultsSettingsForm({
|
||||
notify(`已${e.target.checked ? '开启' : '关闭'}默认导出视频`, true)
|
||||
}}
|
||||
/>
|
||||
视频
|
||||
<span className="media-default-check" aria-hidden="true" />
|
||||
<span>视频</span>
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
@@ -413,7 +528,8 @@ export function ExportDefaultsSettingsForm({
|
||||
notify(`已${e.target.checked ? '开启' : '关闭'}默认导出表情包`, true)
|
||||
}}
|
||||
/>
|
||||
表情包
|
||||
<span className="media-default-check" aria-hidden="true" />
|
||||
<span>表情包</span>
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
@@ -427,7 +543,8 @@ export function ExportDefaultsSettingsForm({
|
||||
notify(`已${e.target.checked ? '开启' : '关闭'}默认导出文件`, true)
|
||||
}}
|
||||
/>
|
||||
文件
|
||||
<span className="media-default-check" aria-hidden="true" />
|
||||
<span>文件</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -15,6 +15,7 @@ interface JumpToDatePopoverProps {
|
||||
messageDateCounts?: Record<string, number>
|
||||
loadingDates?: boolean
|
||||
loadingDateCounts?: boolean
|
||||
maxDate?: Date
|
||||
}
|
||||
|
||||
const JumpToDatePopover: React.FC<JumpToDatePopoverProps> = ({
|
||||
@@ -29,7 +30,8 @@ const JumpToDatePopover: React.FC<JumpToDatePopoverProps> = ({
|
||||
hasLoadedMessageDates = false,
|
||||
messageDateCounts,
|
||||
loadingDates = false,
|
||||
loadingDateCounts = false
|
||||
loadingDateCounts = false,
|
||||
maxDate
|
||||
}) => {
|
||||
type CalendarViewMode = 'day' | 'month' | 'year'
|
||||
const getYearPageStart = (year: number): number => Math.floor(year / 12) * 12
|
||||
@@ -73,6 +75,14 @@ const JumpToDatePopover: React.FC<JumpToDatePopoverProps> = ({
|
||||
return messageDates.has(toDateKey(day))
|
||||
}
|
||||
|
||||
const isAfterMaxDate = (day: number): boolean => {
|
||||
if (!maxDate) return false
|
||||
const max = new Date(maxDate)
|
||||
max.setHours(23, 59, 59, 999)
|
||||
const candidate = new Date(calendarDate.getFullYear(), calendarDate.getMonth(), day, 0, 0, 0, 0)
|
||||
return candidate.getTime() > max.getTime()
|
||||
}
|
||||
|
||||
const isToday = (day: number): boolean => {
|
||||
const today = new Date()
|
||||
return day === today.getDate()
|
||||
@@ -102,6 +112,7 @@ const JumpToDatePopover: React.FC<JumpToDatePopoverProps> = ({
|
||||
|
||||
const handleDateClick = (day: number) => {
|
||||
if (hasLoadedMessageDates && !hasMessage(day)) return
|
||||
if (isAfterMaxDate(day)) return
|
||||
const targetDate = new Date(calendarDate.getFullYear(), calendarDate.getMonth(), day)
|
||||
setSelectedDate(targetDate)
|
||||
onSelect(targetDate)
|
||||
@@ -113,7 +124,7 @@ const JumpToDatePopover: React.FC<JumpToDatePopoverProps> = ({
|
||||
const classes = ['day-cell']
|
||||
if (isToday(day)) classes.push('today')
|
||||
if (isSelected(day)) classes.push('selected')
|
||||
if (hasLoadedMessageDates && !hasMessage(day)) classes.push('no-message')
|
||||
if ((hasLoadedMessageDates && !hasMessage(day)) || isAfterMaxDate(day)) classes.push('no-message')
|
||||
return classes.join(' ')
|
||||
}
|
||||
|
||||
@@ -225,6 +236,7 @@ const JumpToDatePopover: React.FC<JumpToDatePopoverProps> = ({
|
||||
if (day === null) return <div key={index} className="day-cell empty" />
|
||||
const dateKey = toDateKey(day)
|
||||
const hasMessageOnDay = hasMessage(day)
|
||||
const isDisabled = (hasLoadedMessageDates && !hasMessageOnDay) || isAfterMaxDate(day)
|
||||
const count = Number(messageDateCounts?.[dateKey] || 0)
|
||||
const showCount = count > 0
|
||||
const showCountLoading = hasMessageOnDay && loadingDateCounts && !showCount
|
||||
@@ -233,7 +245,7 @@ const JumpToDatePopover: React.FC<JumpToDatePopoverProps> = ({
|
||||
key={index}
|
||||
className={getDayClassName(day)}
|
||||
onClick={() => handleDateClick(day)}
|
||||
disabled={hasLoadedMessageDates && !hasMessageOnDay}
|
||||
disabled={isDisabled}
|
||||
type="button"
|
||||
>
|
||||
<span className="day-number">{day}</span>
|
||||
|
||||
@@ -5,6 +5,7 @@ import './WindowCloseDialog.scss'
|
||||
interface WindowCloseDialogProps {
|
||||
open: boolean
|
||||
canMinimizeToTray: boolean
|
||||
restoreMethod?: 'tray' | 'dock'
|
||||
onSelect: (action: 'tray' | 'quit', rememberChoice: boolean) => void
|
||||
onCancel: () => void
|
||||
}
|
||||
@@ -12,10 +13,12 @@ interface WindowCloseDialogProps {
|
||||
export default function WindowCloseDialog({
|
||||
open,
|
||||
canMinimizeToTray,
|
||||
restoreMethod = 'tray',
|
||||
onSelect,
|
||||
onCancel
|
||||
}: WindowCloseDialogProps) {
|
||||
const [rememberChoice, setRememberChoice] = useState(false)
|
||||
const isDockRestore = restoreMethod === 'dock'
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
@@ -57,7 +60,9 @@ export default function WindowCloseDialog({
|
||||
<h2 id="window-close-dialog-title">关闭 WeFlow</h2>
|
||||
<p>
|
||||
{canMinimizeToTray
|
||||
? '你可以保留后台进程与本地 API,或者直接完全退出应用。'
|
||||
? isDockRestore
|
||||
? '你可以隐藏主窗口并保留后台进程与本地 API,稍后可从 Dock 或重新打开应用恢复。'
|
||||
: '你可以保留后台进程与本地 API,或者直接完全退出应用。'
|
||||
: '当前系统托盘不可用,本次只能完全退出应用。'}
|
||||
</p>
|
||||
</div>
|
||||
@@ -73,8 +78,12 @@ export default function WindowCloseDialog({
|
||||
<Minimize2 size={18} />
|
||||
</span>
|
||||
<span className="window-close-dialog-option-text">
|
||||
<strong>最小化到系统托盘</strong>
|
||||
<span>继续保留后台进程和本地 API,稍后可从托盘恢复。</span>
|
||||
<strong>{isDockRestore ? '隐藏主窗口' : '最小化到系统托盘'}</strong>
|
||||
<span>
|
||||
{isDockRestore
|
||||
? '继续保留后台进程和本地 API,稍后可从 Dock 或重新打开应用恢复。'
|
||||
: '继续保留后台进程和本地 API,稍后可从托盘恢复。'}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -8,8 +8,10 @@ import {
|
||||
Info,
|
||||
Loader2,
|
||||
Mic,
|
||||
Newspaper,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Sparkles,
|
||||
Users
|
||||
} from 'lucide-react'
|
||||
import { Avatar } from '../../components/Avatar'
|
||||
@@ -21,9 +23,11 @@ export interface ChatHeaderProps {
|
||||
isGroupChat: boolean
|
||||
standaloneSessionWindow: boolean
|
||||
showGroupMembersPanel: boolean
|
||||
showGroupSummaryPanel: boolean
|
||||
showJumpPopover: boolean
|
||||
showInSessionSearch: boolean
|
||||
showDetailPanel: boolean
|
||||
aiGroupSummaryEnabled: boolean
|
||||
shouldHideStandaloneDetailButton: boolean
|
||||
isPrivateSnsSupported: boolean
|
||||
isExportActionBusy: boolean
|
||||
@@ -32,10 +36,13 @@ export interface ChatHeaderProps {
|
||||
isBatchTranscribing: boolean
|
||||
runningBatchVoiceTaskType?: BatchVoiceTaskType
|
||||
isBatchDecrypting: boolean
|
||||
isTriggeringSessionInsight: boolean
|
||||
isRefreshingMessages: boolean
|
||||
isLoadingMessages: boolean
|
||||
currentSessionId?: string | null
|
||||
jumpCalendarWrapRef: React.RefObject<HTMLDivElement | null>
|
||||
onTriggerSessionInsight: () => void
|
||||
onToggleGroupSummaryPanel: () => void
|
||||
onGroupAnalytics: () => void
|
||||
onToggleGroupMembersPanel: () => void
|
||||
onExportCurrentSession: () => void
|
||||
@@ -53,9 +60,11 @@ function ChatHeader({
|
||||
isGroupChat,
|
||||
standaloneSessionWindow,
|
||||
showGroupMembersPanel,
|
||||
showGroupSummaryPanel,
|
||||
showJumpPopover,
|
||||
showInSessionSearch,
|
||||
showDetailPanel,
|
||||
aiGroupSummaryEnabled,
|
||||
shouldHideStandaloneDetailButton,
|
||||
isPrivateSnsSupported,
|
||||
isExportActionBusy,
|
||||
@@ -64,10 +73,13 @@ function ChatHeader({
|
||||
isBatchTranscribing,
|
||||
runningBatchVoiceTaskType,
|
||||
isBatchDecrypting,
|
||||
isTriggeringSessionInsight,
|
||||
isRefreshingMessages,
|
||||
isLoadingMessages,
|
||||
currentSessionId,
|
||||
jumpCalendarWrapRef,
|
||||
onTriggerSessionInsight,
|
||||
onToggleGroupSummaryPanel,
|
||||
onGroupAnalytics,
|
||||
onToggleGroupMembersPanel,
|
||||
onExportCurrentSession,
|
||||
@@ -102,6 +114,26 @@ function ChatHeader({
|
||||
{isGroupChat && <div className="header-subtitle">群聊</div>}
|
||||
</div>
|
||||
<div className="header-actions">
|
||||
<button
|
||||
className={`icon-btn session-insight-btn${isTriggeringSessionInsight ? ' triggering' : ''}`}
|
||||
onClick={onTriggerSessionInsight}
|
||||
disabled={!currentSessionId || isTriggeringSessionInsight}
|
||||
title={isTriggeringSessionInsight ? '正在生成 AI 见解' : '立即触发当前聊天 AI 见解'}
|
||||
aria-label="立即触发当前聊天 AI 见解"
|
||||
>
|
||||
{isTriggeringSessionInsight ? <Loader2 size={18} className="spin" /> : <Sparkles size={18} />}
|
||||
</button>
|
||||
{isGroupChat && aiGroupSummaryEnabled && (
|
||||
<button
|
||||
className={`icon-btn group-summary-btn ${showGroupSummaryPanel ? 'active' : ''}`}
|
||||
onClick={onToggleGroupSummaryPanel}
|
||||
disabled={!currentSessionId}
|
||||
title="AI 群聊总结"
|
||||
aria-label="AI 群聊总结"
|
||||
>
|
||||
<Newspaper size={18} />
|
||||
</button>
|
||||
)}
|
||||
{!standaloneSessionWindow && isGroupChat && (
|
||||
<button className="icon-btn group-analytics-btn" onClick={onGroupAnalytics} title="群聊分析">
|
||||
<BarChart3 size={18} />
|
||||
@@ -203,9 +235,11 @@ function areEqual(prev: ChatHeaderProps, next: ChatHeaderProps) {
|
||||
prev.isGroupChat === next.isGroupChat &&
|
||||
prev.standaloneSessionWindow === next.standaloneSessionWindow &&
|
||||
prev.showGroupMembersPanel === next.showGroupMembersPanel &&
|
||||
prev.showGroupSummaryPanel === next.showGroupSummaryPanel &&
|
||||
prev.showJumpPopover === next.showJumpPopover &&
|
||||
prev.showInSessionSearch === next.showInSessionSearch &&
|
||||
prev.showDetailPanel === next.showDetailPanel &&
|
||||
prev.aiGroupSummaryEnabled === next.aiGroupSummaryEnabled &&
|
||||
prev.shouldHideStandaloneDetailButton === next.shouldHideStandaloneDetailButton &&
|
||||
prev.isPrivateSnsSupported === next.isPrivateSnsSupported &&
|
||||
prev.isExportActionBusy === next.isExportActionBusy &&
|
||||
@@ -214,10 +248,13 @@ function areEqual(prev: ChatHeaderProps, next: ChatHeaderProps) {
|
||||
prev.isBatchTranscribing === next.isBatchTranscribing &&
|
||||
prev.runningBatchVoiceTaskType === next.runningBatchVoiceTaskType &&
|
||||
prev.isBatchDecrypting === next.isBatchDecrypting &&
|
||||
prev.isTriggeringSessionInsight === next.isTriggeringSessionInsight &&
|
||||
prev.isRefreshingMessages === next.isRefreshingMessages &&
|
||||
prev.isLoadingMessages === next.isLoadingMessages &&
|
||||
prev.currentSessionId === next.currentSessionId &&
|
||||
prev.jumpCalendarWrapRef === next.jumpCalendarWrapRef &&
|
||||
prev.onTriggerSessionInsight === next.onTriggerSessionInsight &&
|
||||
prev.onToggleGroupSummaryPanel === next.onToggleGroupSummaryPanel &&
|
||||
prev.onGroupAnalytics === next.onGroupAnalytics &&
|
||||
prev.onToggleGroupMembersPanel === next.onToggleGroupMembersPanel &&
|
||||
prev.onExportCurrentSession === next.onExportCurrentSession &&
|
||||
|
||||
@@ -24,6 +24,7 @@ export interface ChatMessageBubbleProps {
|
||||
isSelected?: boolean
|
||||
onContextMenu?: (event: React.MouseEvent, message: Message) => void
|
||||
onToggleSelection?: (messageKey: string, isShiftKey?: boolean) => void
|
||||
actionNode?: React.ReactNode
|
||||
children: React.ReactNode
|
||||
portal?: React.ReactNode
|
||||
}
|
||||
@@ -57,6 +58,7 @@ function ChatMessageBubble({
|
||||
isSelected,
|
||||
onContextMenu,
|
||||
onToggleSelection,
|
||||
actionNode,
|
||||
children,
|
||||
portal
|
||||
}: ChatMessageBubbleProps) {
|
||||
@@ -92,12 +94,20 @@ function ChatMessageBubble({
|
||||
</div>
|
||||
<div className="bubble-body">
|
||||
{isGroupChat && !isSent && (
|
||||
<div className="sender-name">
|
||||
{resolvedSenderName || '群成员'}
|
||||
<div className="sender-line">
|
||||
<div className="sender-name">
|
||||
{resolvedSenderName || '群成员'}
|
||||
</div>
|
||||
{actionNode}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
{!isGroupChat && !isSent && actionNode ? (
|
||||
<div className="message-action-inline">
|
||||
{actionNode}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{isSelectionMode && isSent && <SelectionCheckbox checked={isSelected} side="right" />}
|
||||
@@ -131,6 +141,7 @@ function areEqual(prev: ChatMessageBubbleProps, next: ChatMessageBubbleProps) {
|
||||
prev.isSelected === next.isSelected &&
|
||||
prev.onContextMenu === next.onContextMenu &&
|
||||
prev.onToggleSelection === next.onToggleSelection &&
|
||||
prev.actionNode === next.actionNode &&
|
||||
prev.children === next.children &&
|
||||
prev.portal === next.portal
|
||||
)
|
||||
|
||||
@@ -1747,6 +1747,29 @@
|
||||
}
|
||||
}
|
||||
|
||||
.session-insight-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 20px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: var(--bg-tertiary);
|
||||
|
||||
&.success {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
&.error {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.message-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
@@ -1922,6 +1945,10 @@
|
||||
|
||||
.message-wrapper.new-message {
|
||||
animation: messagePop 0.35s ease-out;
|
||||
|
||||
.message-bubble:not(.system) .bubble-content {
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary) 45%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes messagePop {
|
||||
@@ -3542,6 +3569,271 @@
|
||||
}
|
||||
}
|
||||
|
||||
.group-summary-panel {
|
||||
width: clamp(320px, 30vw, 420px);
|
||||
min-width: 320px;
|
||||
max-width: 420px;
|
||||
|
||||
.group-summary-controls {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.group-summary-date-row {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
label {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
input,
|
||||
.group-summary-date-trigger {
|
||||
height: 32px;
|
||||
min-width: 0;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
background: var(--card-bg);
|
||||
color: var(--text-primary);
|
||||
padding: 0 9px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.group-summary-date-picker {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.group-summary-date-trigger {
|
||||
width: 100%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.16s ease;
|
||||
|
||||
svg {
|
||||
color: var(--text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&.open {
|
||||
border-color: color-mix(in srgb, var(--primary) 36%, var(--border-color));
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.group-summary-calendar-popover {
|
||||
right: auto;
|
||||
left: 0;
|
||||
top: calc(100% + 8px);
|
||||
width: min(312px, calc(100vw - 32px));
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.group-summary-icon-btn,
|
||||
.group-summary-code-btn {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 78%, transparent);
|
||||
border-radius: 8px;
|
||||
background: var(--card-bg);
|
||||
color: var(--text-secondary);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.16s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--primary);
|
||||
background: var(--bg-hover);
|
||||
border-color: color-mix(in srgb, var(--primary) 34%, var(--border-color));
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.group-summary-range-tabs {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
|
||||
button {
|
||||
height: 30px;
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 78%, transparent);
|
||||
border-radius: 8px;
|
||||
background: var(--card-bg);
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.16s ease;
|
||||
|
||||
&.active {
|
||||
color: var(--primary);
|
||||
border-color: color-mix(in srgb, var(--primary) 42%, var(--border-color));
|
||||
background: color-mix(in srgb, var(--primary) 10%, var(--card-bg));
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.group-summary-generate-btn {
|
||||
height: 34px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: transform 0.16s ease, opacity 0.16s ease;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.group-summary-rule-hint,
|
||||
.group-summary-count {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.group-summary-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.group-summary-record {
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 74%, transparent);
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 10px;
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.group-summary-record-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.group-summary-period {
|
||||
display: block;
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.group-summary-meta {
|
||||
display: block;
|
||||
margin-top: 3px;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.group-summary-topic-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.group-summary-topic {
|
||||
border-radius: 8px;
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 70%, transparent);
|
||||
background: color-mix(in srgb, var(--card-bg) 88%, transparent);
|
||||
padding: 9px;
|
||||
|
||||
h5 {
|
||||
margin: 0 0 7px;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.35;
|
||||
}
|
||||
}
|
||||
|
||||
.group-summary-topic-row {
|
||||
display: grid;
|
||||
grid-template-columns: 72px 1fr;
|
||||
gap: 8px;
|
||||
padding: 5px 0;
|
||||
border-top: 1px dashed color-mix(in srgb, var(--border-color) 72%, transparent);
|
||||
|
||||
span {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
word-break: break-word;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.group-summary-log-modal {
|
||||
width: min(860px, calc(100vw - 32px));
|
||||
max-height: min(760px, calc(100vh - 32px));
|
||||
|
||||
.detail-content {
|
||||
max-height: calc(100vh - 120px);
|
||||
}
|
||||
}
|
||||
|
||||
.group-summary-log-pre {
|
||||
margin: 0;
|
||||
max-height: 260px;
|
||||
overflow: auto;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--card-bg) 86%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 72%, transparent);
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
@keyframes detailCardEnter {
|
||||
from {
|
||||
opacity: 0;
|
||||
@@ -5828,6 +6120,245 @@
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.sender-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
max-width: 100%;
|
||||
min-height: 18px;
|
||||
margin-bottom: 5px;
|
||||
|
||||
.sender-name {
|
||||
min-width: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.message-action-inline {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin: 6px 0 0 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.message-insight-trigger {
|
||||
height: 18px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--text-tertiary);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 0 5px;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
opacity: 0;
|
||||
transform: translateX(3px);
|
||||
cursor: pointer;
|
||||
transition: opacity 0.16s ease, color 0.16s ease, background 0.16s ease;
|
||||
-webkit-app-region: no-drag;
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--primary);
|
||||
background: color-mix(in srgb, var(--primary) 10%, transparent);
|
||||
}
|
||||
|
||||
&.compact {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
color: var(--primary);
|
||||
background: transparent;
|
||||
transform: none;
|
||||
|
||||
svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.message-wrapper-with-selection:hover .message-insight-trigger,
|
||||
.message-insight-trigger:focus-visible {
|
||||
opacity: 0.78;
|
||||
}
|
||||
|
||||
.message-wrapper-with-selection:hover .message-insight-trigger.compact,
|
||||
.message-insight-trigger.compact:focus-visible {
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.message-insight-trigger.compact:hover {
|
||||
opacity: 1;
|
||||
background: color-mix(in srgb, var(--primary) 10%, transparent);
|
||||
}
|
||||
|
||||
.message-insight-trigger:focus-visible {
|
||||
outline: 2px solid color-mix(in srgb, var(--primary) 42%, transparent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.message-insight-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 4100;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.message-insight-card {
|
||||
position: fixed;
|
||||
z-index: 4101;
|
||||
width: min(336px, calc(100vw - 16px));
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
box-shadow: 0 16px 42px rgba(0, 0, 0, 0.18);
|
||||
overflow: hidden;
|
||||
animation: messageInsightPop 0.14s ease-out;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.message-insight-card-header {
|
||||
height: 38px;
|
||||
padding: 0 10px 0 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: var(--bg-tertiary);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
|
||||
svg {
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
.message-insight-refresh {
|
||||
margin-left: auto;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
color: var(--primary);
|
||||
background: color-mix(in srgb, var(--primary) 10%, transparent);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.62;
|
||||
}
|
||||
}
|
||||
|
||||
.message-insight-card-body {
|
||||
min-height: 132px;
|
||||
padding: 13px 14px 14px;
|
||||
}
|
||||
|
||||
.message-insight-loading,
|
||||
.message-insight-error {
|
||||
min-height: 104px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.message-insight-error {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
|
||||
button {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-primary);
|
||||
color: var(--primary);
|
||||
padding: 5px 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.message-insight-text {
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
line-height: 1.62;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.message-insight-divider {
|
||||
height: 1px;
|
||||
margin: 12px 0;
|
||||
background: var(--border-color);
|
||||
}
|
||||
|
||||
.message-insight-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.message-insight-tag {
|
||||
max-width: 100%;
|
||||
border-radius: 6px;
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
padding: 4px 7px;
|
||||
font-size: 12px;
|
||||
line-height: 1.3;
|
||||
word-break: break-word;
|
||||
|
||||
&.mood {
|
||||
color: #8a5a00;
|
||||
background: rgba(245, 158, 11, 0.13);
|
||||
}
|
||||
|
||||
&.intent {
|
||||
color: #225f5c;
|
||||
background: rgba(91, 147, 144, 0.14);
|
||||
}
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes messageInsightPop {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
// Ambient Reply dark mode / alternate adjustments handled via CSS variables
|
||||
|
||||
.link-message,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1177,10 +1177,18 @@
|
||||
}
|
||||
|
||||
.export-defaults-modal-actions {
|
||||
padding: 0 18px 16px;
|
||||
padding: 12px 18px 16px;
|
||||
border-top: 1px solid color-mix(in srgb, var(--border-color) 64%, transparent);
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.export-defaults-close-action {
|
||||
min-width: 96px;
|
||||
min-height: 38px;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.task-center-card-label {
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
@@ -1932,10 +1940,11 @@
|
||||
--contacts-main-col-width: calc(var(--contacts-avatar-col-width) + var(--contacts-column-gap) + var(--contacts-name-text-width));
|
||||
--contacts-left-sticky-width: calc(var(--contacts-select-col-width) + var(--contacts-main-col-width) + var(--contacts-column-gap));
|
||||
--contacts-message-col-width: 94px;
|
||||
--contacts-latest-time-col-width: 128px;
|
||||
--contacts-media-col-width: 58px;
|
||||
--contacts-action-col-width: 126px;
|
||||
--contacts-actions-sticky-width: 160px;
|
||||
--contacts-table-min-width: 1120px;
|
||||
--contacts-table-min-width: 1248px;
|
||||
overflow: hidden;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
@@ -2184,6 +2193,58 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.contacts-list-header-latest-time {
|
||||
width: var(--contacts-latest-time-col-width);
|
||||
min-width: var(--contacts-latest-time-col-width);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.contacts-list-header-sortable {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 4px 6px;
|
||||
margin: 0;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
gap: 4px;
|
||||
transition: background-color 0.12s ease, color 0.12s ease;
|
||||
|
||||
&:hover {
|
||||
background: color-mix(in srgb, var(--primary) 10%, transparent);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--primary);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
.contacts-list-header-sort-icon {
|
||||
color: inherit;
|
||||
flex-shrink: 0;
|
||||
|
||||
&.muted {
|
||||
color: var(--text-tertiary);
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.contacts-list-header-media {
|
||||
width: var(--contacts-media-col-width);
|
||||
min-width: var(--contacts-media-col-width);
|
||||
@@ -2501,6 +2562,37 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.row-latest-time {
|
||||
width: var(--contacts-latest-time-col-width);
|
||||
min-width: var(--contacts-latest-time-col-width);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
text-align: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.row-latest-time-value {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
color: var(--text-secondary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 14px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 100%;
|
||||
|
||||
&.muted {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
}
|
||||
|
||||
.row-media-metric {
|
||||
width: var(--contacts-media-col-width);
|
||||
min-width: var(--contacts-media-col-width);
|
||||
@@ -5027,6 +5119,7 @@
|
||||
--contacts-name-text-width: 10em;
|
||||
--contacts-main-col-width: calc(var(--contacts-avatar-col-width) + var(--contacts-column-gap) + var(--contacts-name-text-width));
|
||||
--contacts-message-col-width: 94px;
|
||||
--contacts-latest-time-col-width: 120px;
|
||||
--contacts-media-col-width: 56px;
|
||||
--contacts-action-col-width: 126px;
|
||||
}
|
||||
@@ -5054,6 +5147,10 @@
|
||||
min-width: var(--contacts-message-col-width);
|
||||
}
|
||||
|
||||
.table-wrap .row-latest-time {
|
||||
min-width: var(--contacts-latest-time-col-width);
|
||||
}
|
||||
|
||||
.table-wrap .row-media-metric {
|
||||
min-width: var(--contacts-media-col-width);
|
||||
}
|
||||
@@ -5753,7 +5850,8 @@
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.close-icon-btn {
|
||||
.automation-modal .close-icon-btn,
|
||||
.automation-editor-modal .close-icon-btn {
|
||||
border: none;
|
||||
background: color-mix(in srgb, var(--text-tertiary) 8%, transparent);
|
||||
color: var(--text-secondary);
|
||||
|
||||
@@ -4,6 +4,9 @@ import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'
|
||||
import { createPortal } from 'react-dom'
|
||||
import {
|
||||
Aperture,
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
ArrowUpDown,
|
||||
Calendar,
|
||||
Check,
|
||||
CheckSquare,
|
||||
@@ -656,6 +659,41 @@ const formatYmdHmDateTime = (timestamp?: number): string => {
|
||||
return `${y}-${m}-${day} ${h}:${min}`
|
||||
}
|
||||
|
||||
const formatLatestMessageTimeFromSeconds = (
|
||||
timestamp?: number,
|
||||
now: number = Date.now()
|
||||
): { text: string; title: string } => {
|
||||
if (!timestamp || !Number.isFinite(timestamp) || timestamp <= 0) {
|
||||
return { text: '--', title: '' }
|
||||
}
|
||||
const ms = timestamp * 1000
|
||||
const absolute = formatYmdHmDateTime(ms)
|
||||
const diff = Math.max(0, now - ms)
|
||||
const minute = 60 * 1000
|
||||
const hour = 60 * minute
|
||||
const day = 24 * hour
|
||||
if (diff < minute) {
|
||||
return { text: '刚刚', title: absolute }
|
||||
}
|
||||
if (diff < hour) {
|
||||
const minutes = Math.max(1, Math.floor(diff / minute))
|
||||
return { text: `${minutes} 分钟前`, title: absolute }
|
||||
}
|
||||
if (diff < day) {
|
||||
const hours = Math.max(1, Math.floor(diff / hour))
|
||||
return { text: `${hours} 小时前`, title: absolute }
|
||||
}
|
||||
return { text: absolute, title: absolute }
|
||||
}
|
||||
|
||||
type ContactsSortKey = 'messageCount' | 'latestMessageTime'
|
||||
type ContactsSortOrder = 'desc' | 'asc'
|
||||
interface ContactsSortConfig {
|
||||
key: ContactsSortKey | null
|
||||
order: ContactsSortOrder | null
|
||||
}
|
||||
const DEFAULT_CONTACTS_SORT_CONFIG: ContactsSortConfig = { key: null, order: null }
|
||||
|
||||
const isSingleContactSession = (sessionId: string): boolean => {
|
||||
const normalized = String(sessionId || '').trim()
|
||||
if (!normalized) return false
|
||||
@@ -2269,6 +2307,18 @@ function ExportPage() {
|
||||
const [sessionMutualFriendsDialogTarget, setSessionMutualFriendsDialogTarget] = useState<SessionSnsTimelineTarget | null>(null)
|
||||
const [sessionMutualFriendsSearch, setSessionMutualFriendsSearch] = useState('')
|
||||
const [backgroundTasks, setBackgroundTasks] = useState<BackgroundTaskRecord[]>([])
|
||||
const [contactsSortConfig, setContactsSortConfig] = useState<ContactsSortConfig>(DEFAULT_CONTACTS_SORT_CONFIG)
|
||||
|
||||
const toggleContactsSort = useCallback((key: ContactsSortKey) => {
|
||||
setContactsSortConfig(prev => {
|
||||
if (prev.key !== key) {
|
||||
return { key, order: 'desc' }
|
||||
}
|
||||
if (prev.order === 'desc') return { key, order: 'asc' }
|
||||
if (prev.order === 'asc') return DEFAULT_CONTACTS_SORT_CONFIG
|
||||
return { key, order: 'desc' }
|
||||
})
|
||||
}, [])
|
||||
|
||||
const [exportFolder, setExportFolder] = useState('')
|
||||
const [writeLayout, setWriteLayout] = useState<configService.ExportWriteLayout>('B')
|
||||
@@ -4364,7 +4414,7 @@ function ExportPage() {
|
||||
try {
|
||||
if (prioritizedSessionIds.length > 0) {
|
||||
patchSessionLoadTraceStage(prioritizedSessionIds, 'messageCount', 'loading')
|
||||
const priorityResult = await window.electronAPI.chat.getSessionMessageCounts(prioritizedSessionIds)
|
||||
const priorityResult = await window.electronAPI.chat.getSessionMessageCounts(prioritizedSessionIds, { bypassSessionCache: true, preferHintCache: false })
|
||||
if (isStale()) return { ...accumulatedCounts }
|
||||
if (priorityResult.success) {
|
||||
applyCounts(priorityResult.counts)
|
||||
@@ -4381,7 +4431,7 @@ function ExportPage() {
|
||||
|
||||
if (remainingSessionIds.length > 0) {
|
||||
patchSessionLoadTraceStage(remainingSessionIds, 'messageCount', 'loading')
|
||||
const remainingResult = await window.electronAPI.chat.getSessionMessageCounts(remainingSessionIds)
|
||||
const remainingResult = await window.electronAPI.chat.getSessionMessageCounts(remainingSessionIds, { bypassSessionCache: true, preferHintCache: false })
|
||||
if (isStale()) return { ...accumulatedCounts }
|
||||
if (remainingResult.success) {
|
||||
applyCounts(remainingResult.counts)
|
||||
@@ -6661,34 +6711,47 @@ function ExportPage() {
|
||||
)
|
||||
})
|
||||
|
||||
const indexedContacts = contacts.map((contact, index) => ({
|
||||
contact,
|
||||
index,
|
||||
count: (() => {
|
||||
const counted = normalizeMessageCount(sessionMessageCounts[contact.username])
|
||||
if (typeof counted === 'number') return counted
|
||||
const hinted = normalizeMessageCount(sessionRowByUsername.get(contact.username)?.messageCountHint)
|
||||
return hinted
|
||||
})()
|
||||
}))
|
||||
const indexedContacts = contacts.map((contact, index) => {
|
||||
const sessionRow = sessionRowByUsername.get(contact.username)
|
||||
const counted = normalizeMessageCount(sessionMessageCounts[contact.username])
|
||||
const hinted = normalizeMessageCount(sessionRow?.messageCountHint)
|
||||
const count = typeof counted === 'number' ? counted : hinted
|
||||
const rowTs = sessionRow?.lastTimestamp || sessionRow?.sortTimestamp
|
||||
const latestTime = typeof rowTs === 'number' && rowTs > 0 ? rowTs : undefined
|
||||
return { contact, index, count, latestTime }
|
||||
})
|
||||
|
||||
const compareNullable = (a: number | undefined, b: number | undefined, order: ContactsSortOrder): number => {
|
||||
const aHas = typeof a === 'number' && Number.isFinite(a)
|
||||
const bHas = typeof b === 'number' && Number.isFinite(b)
|
||||
if (aHas && bHas) {
|
||||
const diff = (a as number) - (b as number)
|
||||
return order === 'desc' ? -diff : diff
|
||||
}
|
||||
if (aHas) return -1
|
||||
if (bHas) return 1
|
||||
return 0
|
||||
}
|
||||
|
||||
const sortKey = contactsSortConfig.key
|
||||
const sortOrder = contactsSortConfig.order ?? 'desc'
|
||||
|
||||
indexedContacts.sort((a, b) => {
|
||||
const aHasCount = typeof a.count === 'number'
|
||||
const bHasCount = typeof b.count === 'number'
|
||||
if (aHasCount && bHasCount) {
|
||||
const diff = (b.count as number) - (a.count as number)
|
||||
if (sortKey === 'latestMessageTime') {
|
||||
const diff = compareNullable(a.latestTime, b.latestTime, sortOrder)
|
||||
if (diff !== 0) return diff
|
||||
} else if (sortKey === 'messageCount') {
|
||||
const diff = compareNullable(a.count, b.count, sortOrder)
|
||||
if (diff !== 0) return diff
|
||||
} else {
|
||||
const diff = compareNullable(a.count, b.count, 'desc')
|
||||
if (diff !== 0) return diff
|
||||
} else if (aHasCount) {
|
||||
return -1
|
||||
} else if (bHasCount) {
|
||||
return 1
|
||||
}
|
||||
// 无统计值或同分时保持原顺序,避免列表频繁跳动。
|
||||
return a.index - b.index
|
||||
})
|
||||
|
||||
return indexedContacts.map(item => item.contact)
|
||||
}, [contactsList, activeTab, searchKeyword, sessionMessageCounts, sessionRowByUsername])
|
||||
}, [contactsList, activeTab, searchKeyword, sessionMessageCounts, sessionRowByUsername, contactsSortConfig])
|
||||
|
||||
const keywordMatchedContactUsernameSet = useMemo(() => {
|
||||
const keyword = searchKeyword.trim().toLowerCase()
|
||||
@@ -6897,7 +6960,7 @@ function ExportPage() {
|
||||
useEffect(() => {
|
||||
contactsVirtuosoRef.current?.scrollToIndex({ index: 0, align: 'start' })
|
||||
setIsContactsListAtTop(true)
|
||||
}, [activeTab, searchKeyword])
|
||||
}, [activeTab, searchKeyword, contactsSortConfig])
|
||||
|
||||
const collectVisibleSessionMetricTargets = useCallback((sourceContacts: ContactInfo[]): string[] => {
|
||||
if (sourceContacts.length === 0) return []
|
||||
@@ -7613,12 +7676,29 @@ function ExportPage() {
|
||||
scheduleSessionMutualFriendsWorker()
|
||||
}
|
||||
|
||||
// 记录刷新前的会话时间戳
|
||||
const oldTimestamps = new Map(
|
||||
sessionsRef.current.map(s => [s.username, s.lastTimestamp || s.sortTimestamp || 0])
|
||||
)
|
||||
|
||||
await Promise.all([
|
||||
loadContactsList({ scopeKey }),
|
||||
loadSnsStats({ full: true }),
|
||||
loadSnsUserPostCounts({ force: true })
|
||||
])
|
||||
|
||||
// 找出有变动的会话(最后消息时间变化)
|
||||
const changedSessions = sessionsRef.current.filter(session => {
|
||||
const oldTs = oldTimestamps.get(session.username) || 0
|
||||
const newTs = session.lastTimestamp || session.sortTimestamp || 0
|
||||
return newTs > oldTs
|
||||
})
|
||||
|
||||
// 只对有变动的会话重新加载消息数量
|
||||
if (changedSessions.length > 0) {
|
||||
await loadSessionMessageCounts(changedSessions, activeTabRef.current, { scopeKey })
|
||||
}
|
||||
|
||||
const currentDetailSessionId = showSessionDetailPanel
|
||||
? String(sessionDetail?.wxid || '').trim()
|
||||
: ''
|
||||
@@ -8391,6 +8471,15 @@ function ExportPage() {
|
||||
const hintedMessages = normalizeMessageCount(matchedSession?.messageCountHint)
|
||||
const displayedMessageCount = countedMessages ?? hintedMessages
|
||||
const mediaMetric = sessionContentMetrics[contact.username]
|
||||
const rowLatestTs = matchedSession?.lastTimestamp || matchedSession?.sortTimestamp
|
||||
const resolvedLatestTs = typeof rowLatestTs === 'number' && rowLatestTs > 0 ? rowLatestTs : undefined
|
||||
const latestTimeInfo = formatLatestMessageTimeFromSeconds(resolvedLatestTs, nowTick)
|
||||
const latestTimeState: { state: 'value'; text: string; title: string } | { state: 'loading' } | { state: 'na'; text: '--' } =
|
||||
!canExport
|
||||
? (isSessionBindingPending ? { state: 'loading' } : { state: 'na', text: '--' })
|
||||
: (typeof resolvedLatestTs === 'number' && resolvedLatestTs > 0
|
||||
? { state: 'value', text: latestTimeInfo.text, title: latestTimeInfo.title }
|
||||
: { state: 'na', text: '--' })
|
||||
const messageCountState: { state: 'value'; text: string } | { state: 'loading' } | { state: 'na'; text: '--' } =
|
||||
!canExport
|
||||
? (isSessionBindingPending ? { state: 'loading' } : { state: 'na', text: '--' })
|
||||
@@ -8506,6 +8595,18 @@ function ExportPage() {
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="row-latest-time">
|
||||
{latestTimeState.state === 'loading'
|
||||
? <Loader2 size={12} className="spin row-media-metric-icon" aria-label="最新消息时间加载中" />
|
||||
: (
|
||||
<span
|
||||
className={`row-latest-time-value ${latestTimeState.state === 'value' ? '' : 'muted'}`}
|
||||
title={latestTimeState.state === 'value' ? latestTimeState.title : undefined}
|
||||
>
|
||||
{latestTimeState.text}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="row-media-metric">
|
||||
<strong className="row-media-metric-value">
|
||||
{emojiMetric.state === 'loading'
|
||||
@@ -9250,7 +9351,7 @@ function ExportPage() {
|
||||
<div className="export-defaults-modal-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-btn"
|
||||
className="secondary-btn export-defaults-close-action"
|
||||
onClick={() => setIsExportDefaultsModalOpen(false)}
|
||||
>
|
||||
关闭
|
||||
@@ -9454,7 +9555,46 @@ function ExportPage() {
|
||||
<span className="contacts-list-header-main-label">{contactsHeaderMainLabel}</span>
|
||||
</span>
|
||||
</span>
|
||||
<span className="contacts-list-header-count">总消息数</span>
|
||||
<button
|
||||
type="button"
|
||||
className={`contacts-list-header-count contacts-list-header-sortable ${contactsSortConfig.key === 'messageCount' ? 'is-active' : ''}`}
|
||||
onClick={() => toggleContactsSort('messageCount')}
|
||||
title={
|
||||
contactsSortConfig.key !== 'messageCount'
|
||||
? '按总消息数降序排列'
|
||||
: contactsSortConfig.order === 'desc'
|
||||
? '切换为按总消息数升序'
|
||||
: '取消排序(恢复默认)'
|
||||
}
|
||||
>
|
||||
<span>总消息数</span>
|
||||
{contactsSortConfig.key === 'messageCount'
|
||||
? (contactsSortConfig.order === 'asc'
|
||||
? <ArrowUp size={12} className="contacts-list-header-sort-icon" />
|
||||
: <ArrowDown size={12} className="contacts-list-header-sort-icon" />)
|
||||
: <ArrowUpDown size={12} className="contacts-list-header-sort-icon muted" />
|
||||
}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`contacts-list-header-latest-time contacts-list-header-sortable ${contactsSortConfig.key === 'latestMessageTime' ? 'is-active' : ''}`}
|
||||
onClick={() => toggleContactsSort('latestMessageTime')}
|
||||
title={
|
||||
contactsSortConfig.key !== 'latestMessageTime'
|
||||
? '按最新消息时间降序排列'
|
||||
: contactsSortConfig.order === 'desc'
|
||||
? '切换为按最新消息时间升序'
|
||||
: '取消排序(恢复默认)'
|
||||
}
|
||||
>
|
||||
<span>最新消息时间</span>
|
||||
{contactsSortConfig.key === 'latestMessageTime'
|
||||
? (contactsSortConfig.order === 'asc'
|
||||
? <ArrowUp size={12} className="contacts-list-header-sort-icon" />
|
||||
: <ArrowDown size={12} className="contacts-list-header-sort-icon" />)
|
||||
: <ArrowUpDown size={12} className="contacts-list-header-sort-icon muted" />
|
||||
}
|
||||
</button>
|
||||
<span className="contacts-list-header-media">表情包</span>
|
||||
<span className="contacts-list-header-media">语音</span>
|
||||
<span className="contacts-list-header-media">图片</span>
|
||||
|
||||
@@ -265,6 +265,25 @@
|
||||
color: #5b55a0;
|
||||
background: rgba(99, 102, 241, 0.12);
|
||||
}
|
||||
|
||||
&.manual {
|
||||
color: #0f766e;
|
||||
background: rgba(20, 184, 166, 0.13);
|
||||
}
|
||||
}
|
||||
|
||||
.insight-source-pill {
|
||||
padding: 5px 8px;
|
||||
border-radius: 999px;
|
||||
background: rgba(91, 147, 144, 0.1);
|
||||
color: var(--primary);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
|
||||
&.message_analysis {
|
||||
background: rgba(245, 158, 11, 0.13);
|
||||
color: #8a5a00;
|
||||
}
|
||||
}
|
||||
|
||||
.insight-time {
|
||||
@@ -282,6 +301,43 @@
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.message-analysis-target {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.message-analysis-target-label {
|
||||
flex: 0 0 auto;
|
||||
color: var(--text-tertiary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.message-analysis-target-text {
|
||||
min-width: 0;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.message-analysis-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 7px;
|
||||
|
||||
span {
|
||||
border-radius: 6px;
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
padding: 4px 7px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.insight-filter-panel {
|
||||
width: var(--insight-panel-width);
|
||||
flex-shrink: 0;
|
||||
@@ -376,6 +432,28 @@
|
||||
}
|
||||
}
|
||||
|
||||
.insight-source-tabs {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
|
||||
button {
|
||||
min-height: 34px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
|
||||
&.active {
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
background: rgba(91, 147, 144, 0.08);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.insight-custom-dates {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
InsightRecordContactFacet,
|
||||
InsightRecordFilters,
|
||||
InsightRecordListResult,
|
||||
InsightRecordSourceType,
|
||||
InsightRecordSummary,
|
||||
InsightRecordTriggerReason
|
||||
} from '../types/electron'
|
||||
@@ -15,6 +16,7 @@ import './InsightInboxPage.scss'
|
||||
const INSIGHT_AVATAR_URL = './assets/insight/AI_Insight.png'
|
||||
|
||||
type DateFilterMode = 'all' | 'today' | 'week' | 'custom'
|
||||
type SourceFilterMode = InsightRecordSourceType | 'all'
|
||||
|
||||
function getStartOfDay(date: Date): number {
|
||||
const next = new Date(date)
|
||||
@@ -62,16 +64,23 @@ function formatGroupDate(timestamp: number): string {
|
||||
}
|
||||
|
||||
function getTriggerLabel(reason: InsightRecordTriggerReason): string {
|
||||
if (reason === 'message_analysis') return '深度解析'
|
||||
if (reason === 'silence') return '沉默提醒'
|
||||
if (reason === 'test') return '测试见解'
|
||||
if (reason === 'manual') return '手动触发'
|
||||
return '活跃分析'
|
||||
}
|
||||
|
||||
function getSourceLabel(sourceType?: InsightRecordSourceType): string {
|
||||
return sourceType === 'message_analysis' ? '深度解析' : 'AI 见解'
|
||||
}
|
||||
|
||||
function buildLogText(record: InsightRecord): string {
|
||||
const log = record.log
|
||||
return [
|
||||
const lines = [
|
||||
`时间:${new Date(record.createdAt).toLocaleString('zh-CN')}`,
|
||||
`联系人:${record.displayName} (${record.sessionId})`,
|
||||
`来源:${getSourceLabel(record.sourceType)}`,
|
||||
`触发类型:${getTriggerLabel(record.triggerReason)}`,
|
||||
`接口地址:${log.endpoint}`,
|
||||
`模型:${log.model}`,
|
||||
@@ -90,7 +99,23 @@ function buildLogText(record: InsightRecord): string {
|
||||
'',
|
||||
'最终见解:',
|
||||
log.finalInsight
|
||||
].join('\n')
|
||||
]
|
||||
|
||||
if (record.sourceType === 'message_analysis') {
|
||||
lines.splice(8, 0,
|
||||
`JSON Mode:${log.responseFormatJson ? '启用' : '未启用'}`,
|
||||
`JSON Mode 降级:${log.responseFormatFallback ? '是' : '否'}`,
|
||||
`降级原因:${log.responseFormatFallbackReason || '无'}`,
|
||||
`上下文:请求 ${log.contextStats?.requested ?? log.contextCount} 条,前 ${log.contextStats?.beforeTarget ?? 0} 条,后 ${log.contextStats?.afterTarget ?? 0} 条`,
|
||||
`上下文读取异常:${log.contextStats?.readError || '无'}`
|
||||
)
|
||||
lines.splice(4, 0,
|
||||
`目标消息:${record.messageInsight?.targetSenderName || log.targetMessage?.senderName || ''}:${record.messageInsight?.targetTextPreview || log.targetMessage?.textPreview || ''}`,
|
||||
`目标定位:localId=${record.messageInsight?.targetLocalId || log.targetMessage?.localId || 0}, createTime=${record.messageInsight?.targetCreateTime || log.targetMessage?.createTime || 0}, key=${record.messageInsight?.targetMessageKey || log.targetMessage?.messageKey || ''}`
|
||||
)
|
||||
}
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
export default function InsightInboxPage() {
|
||||
@@ -101,6 +126,7 @@ export default function InsightInboxPage() {
|
||||
const [keyword, setKeyword] = useState('')
|
||||
const [contactSearch, setContactSearch] = useState('')
|
||||
const [selectedSessionId, setSelectedSessionId] = useState('')
|
||||
const [sourceType, setSourceType] = useState<SourceFilterMode>('all')
|
||||
const [dateMode, setDateMode] = useState<DateFilterMode>('all')
|
||||
const [customStart, setCustomStart] = useState(formatDateInput(new Date()))
|
||||
const [customEnd, setCustomEnd] = useState(formatDateInput(new Date()))
|
||||
@@ -133,11 +159,12 @@ export default function InsightInboxPage() {
|
||||
const filters = useMemo<InsightRecordFilters>(() => ({
|
||||
keyword: keyword.trim() || undefined,
|
||||
sessionId: selectedSessionId || undefined,
|
||||
sourceType,
|
||||
startTime: dateRange.startTime,
|
||||
endTime: dateRange.endTime,
|
||||
limit: 200,
|
||||
offset: 0
|
||||
}), [dateRange.endTime, dateRange.startTime, keyword, selectedSessionId])
|
||||
}), [dateRange.endTime, dateRange.startTime, keyword, selectedSessionId, sourceType])
|
||||
|
||||
const loadRecords = useCallback(async () => {
|
||||
setLoading(true)
|
||||
@@ -200,6 +227,16 @@ export default function InsightInboxPage() {
|
||||
}, [contactSearch, contacts])
|
||||
|
||||
const openChat = (record: InsightRecordSummary) => {
|
||||
if (record.sourceType === 'message_analysis' && record.messageInsight) {
|
||||
const query = new URLSearchParams({
|
||||
sessionId: record.sessionId,
|
||||
jumpSource: 'messageAnalysis',
|
||||
jumpLocalId: String(record.messageInsight.targetLocalId || 0),
|
||||
jumpCreateTime: String(record.messageInsight.targetCreateTime || 0)
|
||||
})
|
||||
navigate(`/chat?${query.toString()}`)
|
||||
return
|
||||
}
|
||||
navigate(`/chat?sessionId=${encodeURIComponent(record.sessionId)}`)
|
||||
}
|
||||
|
||||
@@ -305,6 +342,7 @@ export default function InsightInboxPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="insight-card-actions">
|
||||
<span className={`insight-source-pill ${record.sourceType || 'insight'}`}>{getSourceLabel(record.sourceType)}</span>
|
||||
<span className={`insight-trigger-pill ${record.triggerReason}`}>{getTriggerLabel(record.triggerReason)}</span>
|
||||
<span className="insight-time">{formatRecordTime(record.createdAt)}</span>
|
||||
<button className="insight-action-btn" onClick={() => openChat(record)} title="打开聊天">
|
||||
@@ -318,7 +356,22 @@ export default function InsightInboxPage() {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{record.sourceType === 'message_analysis' && record.messageInsight && (
|
||||
<div className="message-analysis-target">
|
||||
<span className="message-analysis-target-label">目标消息</span>
|
||||
<span className="message-analysis-target-text">
|
||||
{record.messageInsight.targetSenderName}:{record.messageInsight.targetTextPreview}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<p className="insight-body">{record.insight}</p>
|
||||
{record.sourceType === 'message_analysis' && record.messageInsight && (
|
||||
<div className="message-analysis-tags">
|
||||
<span>情绪:{record.messageInsight.analysis.emotion}</span>
|
||||
<span>意图:{record.messageInsight.analysis.intent}</span>
|
||||
<span>话题:{record.messageInsight.analysis.topic}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
@@ -347,6 +400,28 @@ export default function InsightInboxPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="insight-filter-widget">
|
||||
<div className="insight-widget-title">
|
||||
<Sparkles size={14} />
|
||||
<span>来源类型</span>
|
||||
</div>
|
||||
<div className="insight-source-tabs">
|
||||
{[
|
||||
{ value: 'all', label: '全部' },
|
||||
{ value: 'insight', label: 'AI 见解' },
|
||||
{ value: 'message_analysis', label: '深度解析' }
|
||||
].map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
className={sourceType === option.value ? 'active' : ''}
|
||||
onClick={() => setSourceType(option.value as SourceFilterMode)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="insight-filter-widget">
|
||||
<div className="insight-widget-title">
|
||||
<CalendarDays size={14} />
|
||||
@@ -440,9 +515,44 @@ export default function InsightInboxPage() {
|
||||
`Max Tokens: ${logRecord.log.maxTokens}`,
|
||||
`Temperature: ${logRecord.log.temperature}`,
|
||||
`Duration: ${logRecord.log.durationMs}ms`,
|
||||
`Trigger: ${getTriggerLabel(logRecord.triggerReason)}`
|
||||
`Source: ${getSourceLabel(logRecord.sourceType)}`,
|
||||
`Trigger: ${getTriggerLabel(logRecord.triggerReason)}`,
|
||||
...(logRecord.sourceType === 'message_analysis'
|
||||
? [
|
||||
`JSON Mode: ${logRecord.log.responseFormatJson ? 'enabled' : 'disabled'}`,
|
||||
`JSON Fallback: ${logRecord.log.responseFormatFallback ? 'yes' : 'no'}`,
|
||||
`Fallback Reason: ${logRecord.log.responseFormatFallbackReason || 'none'}`
|
||||
]
|
||||
: [])
|
||||
].join('\n')}</pre>
|
||||
</section>
|
||||
{logRecord.sourceType === 'message_analysis' && (
|
||||
<section>
|
||||
<h4>深度解析目标</h4>
|
||||
<pre>{[
|
||||
`Sender: ${logRecord.messageInsight?.targetSenderName || logRecord.log.targetMessage?.senderName || ''}`,
|
||||
`Preview: ${logRecord.messageInsight?.targetTextPreview || logRecord.log.targetMessage?.textPreview || ''}`,
|
||||
`LocalId: ${logRecord.messageInsight?.targetLocalId || logRecord.log.targetMessage?.localId || 0}`,
|
||||
`CreateTime: ${logRecord.messageInsight?.targetCreateTime || logRecord.log.targetMessage?.createTime || 0}`,
|
||||
`MessageKey: ${logRecord.messageInsight?.targetMessageKey || logRecord.log.targetMessage?.messageKey || ''}`,
|
||||
`Context Requested: ${logRecord.log.contextStats?.requested ?? logRecord.log.contextCount}`,
|
||||
`Context Before: ${logRecord.log.contextStats?.beforeTarget ?? 0}`,
|
||||
`Context After: ${logRecord.log.contextStats?.afterTarget ?? 0}`,
|
||||
`Context Error: ${logRecord.log.contextStats?.readError || 'none'}`
|
||||
].join('\n')}</pre>
|
||||
</section>
|
||||
)}
|
||||
{logRecord.sourceType === 'message_analysis' && logRecord.log.parsedAnalysis && (
|
||||
<section>
|
||||
<h4>解析字段</h4>
|
||||
<pre>{[
|
||||
`explicitText: ${logRecord.log.parsedAnalysis.explicitText}`,
|
||||
`emotion: ${logRecord.log.parsedAnalysis.emotion}`,
|
||||
`intent: ${logRecord.log.parsedAnalysis.intent}`,
|
||||
`topic: ${logRecord.log.parsedAnalysis.topic}`
|
||||
].join('\n')}</pre>
|
||||
</section>
|
||||
)}
|
||||
<section>
|
||||
<h4>System Prompt</h4>
|
||||
<pre>{logRecord.log.systemPrompt}</pre>
|
||||
|
||||
@@ -770,12 +770,12 @@ function MyFootprintPage() {
|
||||
<>
|
||||
<section className="kpi-grid">
|
||||
<button type="button" className="kpi-card" onClick={() => setTimelineMode('private')}>
|
||||
<span className="kpi-label">有聊天的人数</span>
|
||||
<span className="kpi-label">收到消息的人数</span>
|
||||
<strong>{data.summary.private_inbound_people}</strong>
|
||||
<small>回复了其中 {data.summary.private_replied_people} 人</small>
|
||||
</button>
|
||||
<button type="button" className="kpi-card" onClick={() => setTimelineMode('private')}>
|
||||
<span className="kpi-label">我有回复的人数</span>
|
||||
<span className="kpi-label">发送消息的人数</span>
|
||||
<strong>{data.summary.private_outbound_people}</strong>
|
||||
<small>回复率 {formatPercent(data.summary.private_reply_rate)}</small>
|
||||
</button>
|
||||
|
||||
@@ -3390,14 +3390,17 @@
|
||||
}
|
||||
|
||||
&.insight-social-tab {
|
||||
--insight-moments-column-width: 76px;
|
||||
--insight-social-column-width: minmax(220px, 300px);
|
||||
--insight-status-column-width: 82px;
|
||||
--insight-social-list-grid: minmax(0, 1fr) var(--insight-moments-column-width) var(--insight-social-column-width) var(--insight-status-column-width);
|
||||
--insight-moments-column-width: 44px;
|
||||
--insight-profile-column-width: 78px;
|
||||
--insight-social-column-width: minmax(184px, 230px);
|
||||
--insight-status-column-width: 70px;
|
||||
--insight-social-list-grid: minmax(0, 1fr) var(--insight-moments-column-width) var(--insight-profile-column-width) var(--insight-social-column-width) var(--insight-status-column-width);
|
||||
|
||||
.anti-revoke-list-header {
|
||||
grid-template-columns: var(--insight-social-list-grid);
|
||||
gap: 14px;
|
||||
gap: 8px;
|
||||
padding-left: 14px;
|
||||
padding-right: 10px;
|
||||
|
||||
.insight-moments-column-title {
|
||||
display: flex;
|
||||
@@ -3405,6 +3408,12 @@
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.insight-profile-column-title {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.insight-social-column-title {
|
||||
min-width: 0;
|
||||
color: var(--text-tertiary);
|
||||
@@ -3420,11 +3429,19 @@
|
||||
display: grid;
|
||||
grid-template-columns: var(--insight-social-list-grid);
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
gap: 8px;
|
||||
padding-left: 14px;
|
||||
padding-right: 10px;
|
||||
}
|
||||
|
||||
.anti-revoke-row-main {
|
||||
min-width: 0;
|
||||
gap: 8px;
|
||||
|
||||
.anti-revoke-check {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.insight-moments-cell {
|
||||
@@ -3435,6 +3452,92 @@
|
||||
min-height: 30px;
|
||||
}
|
||||
|
||||
.insight-profile-cell {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 30px;
|
||||
}
|
||||
|
||||
.insight-profile-status-btn {
|
||||
position: relative;
|
||||
width: 74px;
|
||||
height: 28px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 84%, transparent);
|
||||
background: color-mix(in srgb, var(--bg-secondary) 90%, var(--bg-primary) 10%);
|
||||
color: var(--text-secondary);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.16s ease, background 0.16s ease, color 0.16s ease, opacity 0.16s ease;
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.48;
|
||||
}
|
||||
|
||||
&:not(:disabled)::after {
|
||||
content: attr(data-tooltip);
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: calc(100% + 8px);
|
||||
transform: translateX(-50%) translateY(2px);
|
||||
z-index: 8;
|
||||
max-width: 180px;
|
||||
width: max-content;
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
background: color-mix(in srgb, var(--text-primary) 92%, #000 8%);
|
||||
color: var(--bg-primary);
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity 0.12s ease, transform 0.12s ease, visibility 0.12s ease;
|
||||
}
|
||||
|
||||
&:not(:disabled):hover::after,
|
||||
&:not(:disabled):focus-visible::after {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
|
||||
.profile-status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--text-tertiary) 86%, transparent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&.ready {
|
||||
color: var(--primary);
|
||||
border-color: color-mix(in srgb, var(--primary) 35%, var(--border-color));
|
||||
background: color-mix(in srgb, var(--primary) 10%, var(--bg-secondary));
|
||||
}
|
||||
|
||||
&.running {
|
||||
color: color-mix(in srgb, var(--primary) 70%, var(--text-primary) 30%);
|
||||
border-color: color-mix(in srgb, var(--primary) 30%, var(--border-color));
|
||||
background: color-mix(in srgb, var(--primary) 9%, var(--bg-secondary));
|
||||
}
|
||||
|
||||
&.failed {
|
||||
color: color-mix(in srgb, var(--danger) 72%, var(--text-primary) 28%);
|
||||
border-color: color-mix(in srgb, var(--danger) 24%, var(--border-color));
|
||||
background: color-mix(in srgb, var(--danger) 8%, var(--bg-secondary));
|
||||
}
|
||||
}
|
||||
|
||||
.insight-moments-toggle {
|
||||
position: relative;
|
||||
width: 18px;
|
||||
@@ -3489,24 +3592,33 @@
|
||||
}
|
||||
|
||||
.insight-social-binding-cell {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.insight-social-binding-controls {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 8px 10px;
|
||||
grid-template-columns: minmax(92px, 1fr) auto;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
|
||||
.insight-social-binding-input-wrap {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.binding-platform-chip {
|
||||
flex-shrink: 0;
|
||||
border-radius: 999px;
|
||||
padding: 2px 7px;
|
||||
padding: 2px 6px;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 84%, transparent);
|
||||
@@ -3536,12 +3648,18 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-self: flex-end;
|
||||
gap: 8px;
|
||||
gap: 4px;
|
||||
|
||||
.btn {
|
||||
min-width: 0;
|
||||
padding-left: 8px;
|
||||
padding-right: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.insight-social-binding-feedback {
|
||||
grid-column: 1 / span 2;
|
||||
min-height: 18px;
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
|
||||
.binding-feedback {
|
||||
@@ -3563,6 +3681,11 @@
|
||||
align-items: flex-end;
|
||||
max-width: none;
|
||||
min-width: 0;
|
||||
|
||||
.status-badge {
|
||||
padding-left: 8px;
|
||||
padding-right: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3605,6 +3728,7 @@
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
|
||||
.insight-moments-column-title,
|
||||
.insight-profile-column-title,
|
||||
.insight-social-column-title {
|
||||
display: none;
|
||||
}
|
||||
@@ -3617,12 +3741,14 @@
|
||||
}
|
||||
|
||||
.insight-moments-cell,
|
||||
.insight-profile-cell,
|
||||
.insight-social-binding-cell,
|
||||
.anti-revoke-row-status {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.insight-moments-cell {
|
||||
.insight-moments-cell,
|
||||
.insight-profile-cell {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import { useMemo } from 'react'
|
||||
import { useAppStore } from '../stores/appStore'
|
||||
import { useChatStore } from '../stores/chatStore'
|
||||
import { useThemeStore, themes } from '../stores/themeStore'
|
||||
import { useAnalyticsStore } from '../stores/analyticsStore'
|
||||
import { dialog } from '../services/ipc'
|
||||
import * as configService from '../services/config'
|
||||
import groupSummaryPrompt from '../../shared/groupSummaryPrompt.json'
|
||||
import type { ChatSession, ContactInfo } from '../types/models'
|
||||
import type { InsightProfileStatus } from '../types/electron'
|
||||
import {
|
||||
Eye, EyeOff, FolderSearch, FolderOpen, Search, Copy,
|
||||
RotateCcw, Trash2, Plug, Check, Sun, Moon, Monitor,
|
||||
@@ -32,9 +35,11 @@ type SettingsTab =
|
||||
| 'aiCommon'
|
||||
| 'insight'
|
||||
| 'aiFootprint'
|
||||
| 'aiGroupSummary'
|
||||
| 'aiMessageInsight'
|
||||
| 'autoDownload'
|
||||
|
||||
const tabs: { id: Exclude<SettingsTab, 'insight' | 'aiFootprint'>; label: string; icon: React.ElementType }[] = [
|
||||
const tabs: { id: Exclude<SettingsTab, 'insight' | 'aiFootprint' | 'aiMessageInsight'>; label: string; icon: React.ElementType }[] = [
|
||||
{ id: 'appearance', label: '外观', icon: Palette },
|
||||
{ id: 'notification', label: '通知', icon: Bell },
|
||||
{ id: 'antiRevoke', label: '防撤回', icon: RotateCcw },
|
||||
@@ -56,16 +61,19 @@ const filteredTabs = tabs.filter(tab => {
|
||||
return true
|
||||
})
|
||||
|
||||
const aiTabs: Array<{ id: Extract<SettingsTab, 'aiCommon' | 'insight' | 'aiFootprint'>; label: string }> = [
|
||||
const aiTabs: Array<{ id: Extract<SettingsTab, 'aiCommon' | 'insight' | 'aiFootprint' | 'aiGroupSummary' | 'aiMessageInsight'>; label: string }> = [
|
||||
{ id: 'aiCommon', label: '基础配置' },
|
||||
{ id: 'insight', label: 'AI 见解' },
|
||||
{ id: 'aiFootprint', label: 'AI 足迹' }
|
||||
{ id: 'aiFootprint', label: 'AI 足迹' },
|
||||
{ id: 'aiGroupSummary', label: '群聊总结' },
|
||||
{ id: 'aiMessageInsight', label: '消息解析' }
|
||||
]
|
||||
|
||||
const isMac = navigator.userAgent.toLowerCase().includes('mac')
|
||||
const isLinux = navigator.userAgent.toLowerCase().includes('linux')
|
||||
const isWindows = !isMac && !isLinux
|
||||
const MAC_KEY_FAQ_URL = 'https://github.com/hicccc77/WeFlow/blob/main/docs/MAC-KEY-FAQ.md'
|
||||
const DEFAULT_GROUP_SUMMARY_SYSTEM_PROMPT = String(groupSummaryPrompt.defaultSystemPrompt || '').trim()
|
||||
|
||||
const dbDirName = isMac ? '2.0b4.0.9 目录' : 'xwechat_files 目录'
|
||||
const dbPathPlaceholder = isMac
|
||||
@@ -325,8 +333,18 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
const [weiboBindingDrafts, setWeiboBindingDrafts] = useState<Record<string, string>>({})
|
||||
const [weiboBindingErrors, setWeiboBindingErrors] = useState<Record<string, string>>({})
|
||||
const [weiboBindingLoadingSessionId, setWeiboBindingLoadingSessionId] = useState<string | null>(null)
|
||||
const [aiInsightProfileStatuses, setAiInsightProfileStatuses] = useState<Record<string, InsightProfileStatus>>({})
|
||||
const [aiInsightProfileActiveSessionId, setAiInsightProfileActiveSessionId] = useState<string | null>(null)
|
||||
const [aiFootprintEnabled, setAiFootprintEnabled] = useState(false)
|
||||
const [aiFootprintSystemPrompt, setAiFootprintSystemPrompt] = useState('')
|
||||
const [aiGroupSummaryEnabled, setAiGroupSummaryEnabled] = useState(false)
|
||||
const [aiGroupSummaryIntervalHours, setAiGroupSummaryIntervalHours] = useState(4)
|
||||
const [aiGroupSummarySystemPrompt, setAiGroupSummarySystemPrompt] = useState('')
|
||||
const [aiGroupSummaryFilterList, setAiGroupSummaryFilterList] = useState<string[]>([])
|
||||
const [aiGroupSummaryFilterSearchKeyword, setAiGroupSummaryFilterSearchKeyword] = useState('')
|
||||
const [aiMessageInsightEnabled, setAiMessageInsightEnabled] = useState(false)
|
||||
const [aiMessageInsightContextCount, setAiMessageInsightContextCount] = useState(50)
|
||||
const [aiMessageInsightSystemPrompt, setAiMessageInsightSystemPrompt] = useState('')
|
||||
|
||||
// 自动下载图片
|
||||
const [autoDownloadStatus, setAutoDownloadStatus] = useState<{ isHooked: boolean; pid: number | null; supported: boolean } | null>(null)
|
||||
@@ -372,7 +390,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
}, [location.state])
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'aiCommon' || activeTab === 'insight' || activeTab === 'aiFootprint') {
|
||||
if (activeTab === 'aiCommon' || activeTab === 'insight' || activeTab === 'aiFootprint' || activeTab === 'aiGroupSummary' || activeTab === 'aiMessageInsight') {
|
||||
setAiGroupExpanded(true)
|
||||
}
|
||||
}, [activeTab])
|
||||
@@ -590,6 +608,13 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
const savedAiInsightWeiboBindings = await configService.getAiInsightWeiboBindings()
|
||||
const savedAiFootprintEnabled = await configService.getAiFootprintEnabled()
|
||||
const savedAiFootprintSystemPrompt = await configService.getAiFootprintSystemPrompt()
|
||||
const savedAiGroupSummaryEnabled = await configService.getAiGroupSummaryEnabled()
|
||||
const savedAiGroupSummaryIntervalHours = await configService.getAiGroupSummaryIntervalHours()
|
||||
const savedAiGroupSummarySystemPrompt = await configService.getAiGroupSummarySystemPrompt()
|
||||
const savedAiGroupSummaryFilterList = await configService.getAiGroupSummaryFilterList()
|
||||
const savedAiMessageInsightEnabled = await configService.getAiMessageInsightEnabled()
|
||||
const savedAiMessageInsightContextCount = await configService.getAiMessageInsightContextCount()
|
||||
const savedAiMessageInsightSystemPrompt = await configService.getAiMessageInsightSystemPrompt()
|
||||
|
||||
setAiInsightEnabled(savedAiInsightEnabled)
|
||||
setAiModelApiBaseUrl(savedAiModelApiBaseUrl)
|
||||
@@ -616,6 +641,13 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
setAiInsightWeiboBindings(savedAiInsightWeiboBindings)
|
||||
setAiFootprintEnabled(savedAiFootprintEnabled)
|
||||
setAiFootprintSystemPrompt(savedAiFootprintSystemPrompt)
|
||||
setAiGroupSummaryEnabled(savedAiGroupSummaryEnabled)
|
||||
setAiGroupSummaryIntervalHours(savedAiGroupSummaryIntervalHours)
|
||||
setAiGroupSummarySystemPrompt(savedAiGroupSummarySystemPrompt)
|
||||
setAiGroupSummaryFilterList(savedAiGroupSummaryFilterList)
|
||||
setAiMessageInsightEnabled(savedAiMessageInsightEnabled)
|
||||
setAiMessageInsightContextCount(savedAiMessageInsightContextCount)
|
||||
setAiMessageInsightSystemPrompt(savedAiMessageInsightSystemPrompt)
|
||||
|
||||
} catch (e: any) {
|
||||
console.error('加载配置失败:', e)
|
||||
@@ -2833,37 +2865,41 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
showMessage('已清空主动推送过滤列表', true)
|
||||
}
|
||||
|
||||
const sessionFilterOptionMap = new Map<string, SessionFilterOption>()
|
||||
const { sessionFilterOptionMap, sessionFilterOptions } = useMemo(() => {
|
||||
const optionMap = new Map<string, SessionFilterOption>()
|
||||
|
||||
for (const session of chatSessions) {
|
||||
if (session.username.toLowerCase().includes('placeholder_foldgroup')) continue
|
||||
sessionFilterOptionMap.set(session.username, {
|
||||
username: session.username,
|
||||
displayName: session.displayName || session.username,
|
||||
avatarUrl: session.avatarUrl,
|
||||
type: getSessionFilterType(session)
|
||||
})
|
||||
}
|
||||
for (const session of chatSessions) {
|
||||
if (session.username.toLowerCase().includes('placeholder_foldgroup')) continue
|
||||
optionMap.set(session.username, {
|
||||
username: session.username,
|
||||
displayName: session.displayName || session.username,
|
||||
avatarUrl: session.avatarUrl,
|
||||
type: getSessionFilterType(session)
|
||||
})
|
||||
}
|
||||
|
||||
for (const contact of messagePushContactOptions) {
|
||||
if (!contact.username) continue
|
||||
if (contact.type !== 'friend' && contact.type !== 'group' && contact.type !== 'official' && contact.type !== 'former_friend') continue
|
||||
const existing = sessionFilterOptionMap.get(contact.username)
|
||||
sessionFilterOptionMap.set(contact.username, {
|
||||
username: contact.username,
|
||||
displayName: existing?.displayName || contact.displayName || contact.remark || contact.nickname || contact.username,
|
||||
avatarUrl: existing?.avatarUrl || contact.avatarUrl,
|
||||
type: getSessionFilterType(contact)
|
||||
})
|
||||
}
|
||||
for (const contact of messagePushContactOptions) {
|
||||
if (!contact.username) continue
|
||||
if (contact.type !== 'friend' && contact.type !== 'group' && contact.type !== 'official' && contact.type !== 'former_friend') continue
|
||||
const existing = optionMap.get(contact.username)
|
||||
optionMap.set(contact.username, {
|
||||
username: contact.username,
|
||||
displayName: existing?.displayName || contact.displayName || contact.remark || contact.nickname || contact.username,
|
||||
avatarUrl: existing?.avatarUrl || contact.avatarUrl,
|
||||
type: getSessionFilterType(contact)
|
||||
})
|
||||
}
|
||||
|
||||
const sessionFilterOptions = Array.from(sessionFilterOptionMap.values())
|
||||
.sort((a, b) => {
|
||||
const aSession = chatSessions.find(session => session.username === a.username)
|
||||
const bSession = chatSessions.find(session => session.username === b.username)
|
||||
return Number(bSession?.sortTimestamp || bSession?.lastTimestamp || 0) -
|
||||
Number(aSession?.sortTimestamp || aSession?.lastTimestamp || 0)
|
||||
})
|
||||
const options = Array.from(optionMap.values())
|
||||
.sort((a, b) => {
|
||||
const aSession = chatSessions.find(session => session.username === a.username)
|
||||
const bSession = chatSessions.find(session => session.username === b.username)
|
||||
return Number(bSession?.sortTimestamp || bSession?.lastTimestamp || 0) -
|
||||
Number(aSession?.sortTimestamp || aSession?.lastTimestamp || 0)
|
||||
})
|
||||
|
||||
return { sessionFilterOptionMap: optionMap, sessionFilterOptions: options }
|
||||
}, [chatSessions, messagePushContactOptions])
|
||||
|
||||
const getSessionFilterOptionInfo = (username: string) => {
|
||||
return sessionFilterOptionMap.get(username) || {
|
||||
@@ -2903,6 +2939,15 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
messagePushFilterSearchKeyword
|
||||
)
|
||||
|
||||
const groupSummaryFilterOptions = sessionFilterOptions.filter((session) => session.type === 'group')
|
||||
const groupSummaryAvailableSessions = groupSummaryFilterOptions.filter((session) => {
|
||||
const keyword = aiGroupSummaryFilterSearchKeyword.trim().toLowerCase()
|
||||
if (aiGroupSummaryFilterList.includes(session.username)) return false
|
||||
if (!keyword) return true
|
||||
return String(session.displayName || '').toLowerCase().includes(keyword) ||
|
||||
session.username.toLowerCase().includes(keyword)
|
||||
})
|
||||
|
||||
const handleAddAllNotificationFilterSessions = async () => {
|
||||
const usernames = notificationAvailableSessions.map(session => session.username)
|
||||
if (usernames.length === 0) return
|
||||
@@ -3231,6 +3276,120 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
await configService.setAiInsightWeiboBindings(nextBindings)
|
||||
if (!silent) showMessage('已清除微博绑定', true)
|
||||
}
|
||||
|
||||
const refreshAiInsightProfileStatuses = async (sessionIds?: string[]) => {
|
||||
const ids = normalizeSessionIds(
|
||||
sessionIds || sessionFilterOptions
|
||||
.filter((session) => session.type === 'private')
|
||||
.map((session) => session.username)
|
||||
)
|
||||
if (ids.length === 0) {
|
||||
setAiInsightProfileStatuses({})
|
||||
setAiInsightProfileActiveSessionId(null)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const result = await window.electronAPI.insight.listProfileStatuses(ids)
|
||||
if (!result.success) return
|
||||
setAiInsightProfileStatuses(result.statuses || {})
|
||||
setAiInsightProfileActiveSessionId(result.activeTask?.sessionId || null)
|
||||
} catch (error) {
|
||||
console.warn('刷新 AI 画像状态失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleGenerateInsightProfile = async (session: SessionFilterOption) => {
|
||||
const sessionId = session.username
|
||||
const currentStatus = aiInsightProfileStatuses[sessionId]
|
||||
if (currentStatus?.status === 'running') {
|
||||
try {
|
||||
const result = await window.electronAPI.insight.cancelProfile(sessionId)
|
||||
showMessage(result.message || '已请求取消画像任务', result.success)
|
||||
} catch (e: any) {
|
||||
showMessage(`取消画像失败:${e?.message || String(e)}`, false)
|
||||
} finally {
|
||||
setTimeout(() => { void refreshAiInsightProfileStatuses() }, 500)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (aiInsightProfileActiveSessionId && aiInsightProfileActiveSessionId !== sessionId) return
|
||||
|
||||
setAiInsightProfileStatuses((prev) => ({
|
||||
...prev,
|
||||
[sessionId]: {
|
||||
sessionId,
|
||||
status: 'running',
|
||||
phase: '正在初始化画像...',
|
||||
updatedAt: Date.now()
|
||||
}
|
||||
}))
|
||||
setAiInsightProfileActiveSessionId(sessionId)
|
||||
try {
|
||||
const result = await window.electronAPI.insight.generateProfile({
|
||||
sessionId,
|
||||
displayName: session.displayName || session.username,
|
||||
avatarUrl: session.avatarUrl
|
||||
})
|
||||
showMessage(result.message || (result.success ? '画像完成' : '画像失败'), result.success)
|
||||
} catch (e: any) {
|
||||
showMessage(`画像失败:${e?.message || String(e)}`, false)
|
||||
} finally {
|
||||
await refreshAiInsightProfileStatuses()
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab !== 'insight') return
|
||||
const ids = sessionFilterOptions
|
||||
.filter((session) => session.type === 'private')
|
||||
.map((session) => session.username)
|
||||
if (ids.length === 0) return
|
||||
void refreshAiInsightProfileStatuses(ids)
|
||||
const timer = window.setInterval(() => {
|
||||
void refreshAiInsightProfileStatuses(ids)
|
||||
}, 2500)
|
||||
return () => window.clearInterval(timer)
|
||||
}, [activeTab, sessionFilterOptions])
|
||||
|
||||
const getInsightProfileButtonMeta = (sessionId: string) => {
|
||||
const status = aiInsightProfileStatuses[sessionId]
|
||||
const activeOther = Boolean(aiInsightProfileActiveSessionId && aiInsightProfileActiveSessionId !== sessionId)
|
||||
if (status?.status === 'running') {
|
||||
return {
|
||||
className: 'running',
|
||||
label: '取消',
|
||||
title: status.phase || '画像生成中,点击取消',
|
||||
disabled: false,
|
||||
icon: <Loader2 size={13} style={{ animation: 'spin 1s linear infinite' }} />
|
||||
}
|
||||
}
|
||||
if (status?.status === 'ready') {
|
||||
return {
|
||||
className: 'ready',
|
||||
label: '已画像',
|
||||
title: activeOther ? '其他联系人正在画像中' : '点击以重新画像',
|
||||
disabled: activeOther,
|
||||
icon: <Check size={13} />
|
||||
}
|
||||
}
|
||||
if (status?.status === 'failed') {
|
||||
return {
|
||||
className: 'failed',
|
||||
label: '失败',
|
||||
title: activeOther ? '其他联系人正在画像中' : (status.error || '画像失败,点击重试'),
|
||||
disabled: activeOther,
|
||||
icon: <XCircle size={13} />
|
||||
}
|
||||
}
|
||||
return {
|
||||
className: 'none',
|
||||
label: '未画像',
|
||||
title: activeOther ? '其他联系人正在画像中' : '点击进行画像',
|
||||
disabled: activeOther,
|
||||
icon: <i className="profile-status-dot" aria-hidden="true" />
|
||||
}
|
||||
}
|
||||
const renderInsightTab = () => (
|
||||
<div className="tab-content">
|
||||
{/* 总开关 */}
|
||||
@@ -3805,6 +3964,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
<div className="anti-revoke-list-header">
|
||||
<span>对话({filteredSessions.length})</span>
|
||||
<span className="insight-moments-column-title">朋友圈</span>
|
||||
<span className="insight-profile-column-title">画像</span>
|
||||
<span className="insight-social-column-title">社交平台(微博)</span>
|
||||
<span className="anti-revoke-status-column-title">状态</span>
|
||||
</div>
|
||||
@@ -3816,6 +3976,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
const weiboDraftValue = getWeiboBindingDraftValue(session.username)
|
||||
const isBindingLoading = weiboBindingLoadingSessionId === session.username
|
||||
const weiboBindingError = weiboBindingErrors[session.username]
|
||||
const profileButtonMeta = getInsightProfileButtonMeta(session.username)
|
||||
return (
|
||||
<div
|
||||
key={session.username}
|
||||
@@ -3866,37 +4027,56 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
<span className="binding-feedback muted">-</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="insight-profile-cell">
|
||||
{isPrivateSession ? (
|
||||
<button
|
||||
type="button"
|
||||
className={`insight-profile-status-btn ${profileButtonMeta.className}`}
|
||||
title={profileButtonMeta.title}
|
||||
data-tooltip={profileButtonMeta.title}
|
||||
disabled={profileButtonMeta.disabled}
|
||||
onClick={() => void handleGenerateInsightProfile(session)}
|
||||
>
|
||||
{profileButtonMeta.icon}
|
||||
<span>{profileButtonMeta.label}</span>
|
||||
</button>
|
||||
) : (
|
||||
<span className="binding-feedback muted">-</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="insight-social-binding-cell">
|
||||
{isPrivateSession ? (
|
||||
<>
|
||||
<div className="insight-social-binding-input-wrap">
|
||||
<span className="binding-platform-chip">微博</span>
|
||||
<input
|
||||
type="text"
|
||||
className="insight-social-binding-input"
|
||||
value={weiboDraftValue}
|
||||
placeholder="填写数字 UID"
|
||||
onChange={(e) => updateWeiboBindingDraft(session.username, e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="insight-social-binding-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={() => void handleSaveWeiboBinding(session.username, session.displayName || session.username)}
|
||||
disabled={isBindingLoading || !weiboDraftValue.trim()}
|
||||
>
|
||||
{isBindingLoading ? '绑定中...' : (weiboBinding ? '更新' : '绑定')}
|
||||
</button>
|
||||
{weiboBinding && (
|
||||
<div className="insight-social-binding-controls">
|
||||
<div className="insight-social-binding-input-wrap">
|
||||
<span className="binding-platform-chip">微博</span>
|
||||
<input
|
||||
type="text"
|
||||
className="insight-social-binding-input"
|
||||
value={weiboDraftValue}
|
||||
placeholder="填写数字 UID"
|
||||
onChange={(e) => updateWeiboBindingDraft(session.username, e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="insight-social-binding-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={() => void handleClearWeiboBinding(session.username)}
|
||||
onClick={() => void handleSaveWeiboBinding(session.username, session.displayName || session.username)}
|
||||
disabled={isBindingLoading || !weiboDraftValue.trim()}
|
||||
>
|
||||
清除
|
||||
{isBindingLoading ? '绑定中...' : (weiboBinding ? '更新' : '绑定')}
|
||||
</button>
|
||||
)}
|
||||
{weiboBinding && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={() => void handleClearWeiboBinding(session.username)}
|
||||
>
|
||||
清除
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="insight-social-binding-feedback">
|
||||
{weiboBindingError ? (
|
||||
@@ -4021,6 +4201,320 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
</div>
|
||||
)
|
||||
|
||||
const renderAiGroupSummaryTab = () => {
|
||||
const groupSummaryPromptDisplayValue = aiGroupSummarySystemPrompt || DEFAULT_GROUP_SUMMARY_SYSTEM_PROMPT
|
||||
|
||||
const addToFilterList = async (username: string) => {
|
||||
if (!username.endsWith('@chatroom') || aiGroupSummaryFilterList.includes(username)) return
|
||||
const next = [...aiGroupSummaryFilterList, username]
|
||||
setAiGroupSummaryFilterList(next)
|
||||
await configService.setAiGroupSummaryFilterList(next)
|
||||
showMessage('已添加到群聊总结作用域', true)
|
||||
}
|
||||
|
||||
const removeFromFilterList = async (username: string) => {
|
||||
const next = aiGroupSummaryFilterList.filter((item) => item !== username)
|
||||
setAiGroupSummaryFilterList(next)
|
||||
await configService.setAiGroupSummaryFilterList(next)
|
||||
showMessage('已从群聊总结作用域移除', true)
|
||||
}
|
||||
|
||||
const addAllFiltered = async () => {
|
||||
const usernames = groupSummaryAvailableSessions.map((session) => session.username)
|
||||
if (usernames.length === 0) return
|
||||
const next = Array.from(new Set([...aiGroupSummaryFilterList, ...usernames]))
|
||||
setAiGroupSummaryFilterList(next)
|
||||
await configService.setAiGroupSummaryFilterList(next)
|
||||
showMessage(`已添加 ${usernames.length} 个群聊`, true)
|
||||
}
|
||||
|
||||
const clearFilterList = async () => {
|
||||
if (aiGroupSummaryFilterList.length === 0) return
|
||||
setAiGroupSummaryFilterList([])
|
||||
await configService.setAiGroupSummaryFilterList([])
|
||||
showMessage('已清空群聊总结作用域', true)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="tab-content">
|
||||
<div className="form-group">
|
||||
<label>AI 群聊总结</label>
|
||||
<span className="form-hint">
|
||||
开启后,群聊页顶部会显示 AI 总结按钮;自动总结只对下面作用域内的群聊生效。未选择任何群聊时不会自动消耗 token。
|
||||
</span>
|
||||
<div className="log-toggle-line">
|
||||
<span className="log-status">{aiGroupSummaryEnabled ? '已开启' : '已关闭'}</span>
|
||||
<label className="switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={aiGroupSummaryEnabled}
|
||||
onChange={async (e) => {
|
||||
const val = e.target.checked
|
||||
setAiGroupSummaryEnabled(val)
|
||||
await configService.setAiGroupSummaryEnabled(val)
|
||||
showMessage(val ? 'AI 群聊总结已开启' : 'AI 群聊总结已关闭', true)
|
||||
}}
|
||||
/>
|
||||
<span className="switch-slider" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="divider" />
|
||||
|
||||
<div className="form-group">
|
||||
<label>自动总结间隔</label>
|
||||
<span className="form-hint">
|
||||
按本地系统时间从当天 00:00 开始切分完整时间段,到点总结上一段。时段内可总结消息少于 5 条时会跳过。
|
||||
</span>
|
||||
<div className="push-filter-type-tabs" style={{ marginTop: 10 }}>
|
||||
{[1, 2, 4, 8, 12, 24].map((hours) => (
|
||||
<button
|
||||
key={hours}
|
||||
type="button"
|
||||
className={`push-filter-type-tab ${aiGroupSummaryIntervalHours === hours ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setAiGroupSummaryIntervalHours(hours)
|
||||
scheduleConfigSave('aiGroupSummaryIntervalHours', () => configService.setAiGroupSummaryIntervalHours(hours))
|
||||
}}
|
||||
>
|
||||
{hours} 小时
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12 }}>
|
||||
<label style={{ marginBottom: 0 }}>群聊总结提示词</label>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={async () => {
|
||||
setAiGroupSummarySystemPrompt('')
|
||||
await configService.setAiGroupSummarySystemPrompt('')
|
||||
}}
|
||||
>
|
||||
恢复默认
|
||||
</button>
|
||||
</div>
|
||||
<span className="form-hint">
|
||||
群聊总结专用提示词。留空时使用内置默认提示词。
|
||||
</span>
|
||||
<textarea
|
||||
className="field-input ai-prompt-textarea"
|
||||
rows={10}
|
||||
style={{ width: '100%', resize: 'vertical', marginTop: 8 }}
|
||||
value={groupSummaryPromptDisplayValue}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value
|
||||
setAiGroupSummarySystemPrompt(val)
|
||||
scheduleConfigSave('aiGroupSummarySystemPrompt', () => configService.setAiGroupSummarySystemPrompt(val))
|
||||
}}
|
||||
/>
|
||||
<span className="form-hint" style={{ color: 'var(--danger, #ef4444)', marginTop: 8, display: 'block' }}>
|
||||
该提示词控制 JSON 输出结构和总结解析路径,不建议随意修改,否则可能导致总结失败或内容错位。
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="divider" />
|
||||
|
||||
<div className="form-group">
|
||||
<label>自动总结作用域群聊</label>
|
||||
<span className="form-hint">
|
||||
仅控制自动总结范围。手动点击群聊页 AI 总结按钮不受作用域限制;未选择任何群聊时自动总结不会运行。
|
||||
</span>
|
||||
|
||||
{aiGroupSummaryFilterList.length === 0 && (
|
||||
<div className="api-docs" style={{ marginTop: 12 }}>
|
||||
<div className="api-item">
|
||||
<p className="api-desc">当前未选择作用域群聊,自动群聊总结不会触发。</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="notification-filter-container" style={{ marginTop: 12 }}>
|
||||
<div className="filter-panel">
|
||||
<div className="filter-panel-header">
|
||||
<span>可选群聊</span>
|
||||
{groupSummaryAvailableSessions.length > 0 && (
|
||||
<button type="button" className="filter-panel-action" onClick={() => { void addAllFiltered() }}>
|
||||
全选当前
|
||||
</button>
|
||||
)}
|
||||
<div className="filter-search-box">
|
||||
<Search size={14} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索群聊..."
|
||||
value={aiGroupSummaryFilterSearchKeyword}
|
||||
onChange={(e) => setAiGroupSummaryFilterSearchKeyword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="filter-panel-list">
|
||||
{groupSummaryAvailableSessions.length > 0 ? (
|
||||
groupSummaryAvailableSessions.map(session => (
|
||||
<div
|
||||
key={session.username}
|
||||
className="filter-panel-item"
|
||||
onClick={() => { void addToFilterList(session.username) }}
|
||||
>
|
||||
<Avatar src={session.avatarUrl} name={session.displayName || session.username} size={28} />
|
||||
<span className="filter-item-name">{session.displayName || session.username}</span>
|
||||
<span className="filter-item-type">群聊</span>
|
||||
<span className="filter-item-action">+</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="filter-panel-empty">
|
||||
{aiGroupSummaryFilterSearchKeyword ? '没有匹配的群聊' : '暂无可添加的群聊'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="filter-panel">
|
||||
<div className="filter-panel-header">
|
||||
<span>作用域群聊</span>
|
||||
{aiGroupSummaryFilterList.length > 0 && (
|
||||
<span className="filter-panel-count">{aiGroupSummaryFilterList.length}</span>
|
||||
)}
|
||||
{aiGroupSummaryFilterList.length > 0 && (
|
||||
<button type="button" className="filter-panel-action" onClick={() => { void clearFilterList() }}>
|
||||
全不选
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="filter-panel-list">
|
||||
{aiGroupSummaryFilterList.length > 0 ? (
|
||||
aiGroupSummaryFilterList.map(username => {
|
||||
const info = getSessionFilterOptionInfo(username)
|
||||
return (
|
||||
<div
|
||||
key={username}
|
||||
className="filter-panel-item selected"
|
||||
onClick={() => { void removeFromFilterList(username) }}
|
||||
>
|
||||
<Avatar src={info.avatarUrl} name={info.displayName} size={28} />
|
||||
<span className="filter-item-name">{info.displayName}</span>
|
||||
<span className="filter-item-type">群聊</span>
|
||||
<span className="filter-item-action">×</span>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<div className="filter-panel-empty">尚未添加任何群聊</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const renderAiMessageInsightTab = () => (
|
||||
<div className="tab-content">
|
||||
{(() => {
|
||||
const DEFAULT_MESSAGE_INSIGHT_PROMPT = `你是一个克制、准确的聊天语义分析助手。你的任务是把用户选中的一句聊天消息做深度解析,帮助用户理解对方未明说的含义。
|
||||
|
||||
严格要求:
|
||||
1. 必须且只能输出合法的纯 JSON。
|
||||
2. 禁止输出解释说明、前言后语,禁止使用 Markdown 或代码块。
|
||||
3. 不要编造上下文没有支持的信息;不确定时用谨慎表述。
|
||||
4. explicit_text 用自然中文说明这句话可能想表达的真实含义,80字以内。
|
||||
5. emotion、intent、topic 必须是短标签。
|
||||
|
||||
JSON 输出格式:
|
||||
{
|
||||
"explicit_text": "暗示转明示,80字以内",
|
||||
"emotion": "2-6字情绪标签",
|
||||
"intent": "2-8字意图标签",
|
||||
"topic": "2-8字话题标签"
|
||||
}`
|
||||
const displayValue = aiMessageInsightSystemPrompt || DEFAULT_MESSAGE_INSIGHT_PROMPT
|
||||
return (
|
||||
<>
|
||||
<div className="form-group">
|
||||
<label>消息深度解析</label>
|
||||
<span className="form-hint">
|
||||
开启后,在聊天页悬停对方文本消息时显示深度解析入口。点击后按需调用 AI,解析结果会保存到灵感信箱。
|
||||
</span>
|
||||
<div className="log-toggle-line">
|
||||
<span className="log-status">{aiMessageInsightEnabled ? '已开启' : '已关闭'}</span>
|
||||
<label className="switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={aiMessageInsightEnabled}
|
||||
onChange={async (e) => {
|
||||
const val = e.target.checked
|
||||
setAiMessageInsightEnabled(val)
|
||||
await configService.setAiMessageInsightEnabled(val)
|
||||
}}
|
||||
/>
|
||||
<span className="switch-slider" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>发送上下文对话条数</label>
|
||||
<span className="form-hint">
|
||||
围绕选中消息向前、向后各取一半;一侧不足时自动由另一侧补齐。条数越多分析越准确,token 消耗也越多。
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
className="field-input"
|
||||
value={aiMessageInsightContextCount}
|
||||
min={1}
|
||||
max={200}
|
||||
onChange={(e) => {
|
||||
const val = Math.max(1, Math.min(200, parseInt(e.target.value, 10) || 50))
|
||||
setAiMessageInsightContextCount(val)
|
||||
scheduleConfigSave('aiMessageInsightContextCount', () => configService.setAiMessageInsightContextCount(val))
|
||||
}}
|
||||
style={{ width: 100 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 6 }}>
|
||||
<label style={{ marginBottom: 0 }}>消息解析提示词</label>
|
||||
<button
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={async () => {
|
||||
setAiMessageInsightSystemPrompt('')
|
||||
await configService.setAiMessageInsightSystemPrompt('')
|
||||
}}
|
||||
>
|
||||
恢复默认
|
||||
</button>
|
||||
</div>
|
||||
<span className="form-hint">
|
||||
消息解析专用提示词。留空时使用内置默认提示词。
|
||||
</span>
|
||||
<textarea
|
||||
className="field-input ai-prompt-textarea"
|
||||
rows={10}
|
||||
style={{ width: '100%', resize: 'vertical' }}
|
||||
value={displayValue}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value
|
||||
setAiMessageInsightSystemPrompt(val)
|
||||
scheduleConfigSave('aiMessageInsightSystemPrompt', () => configService.setAiMessageInsightSystemPrompt(val))
|
||||
}}
|
||||
/>
|
||||
<span className="form-hint" style={{ color: 'var(--danger, #ef4444)', marginTop: 8, display: 'block' }}>
|
||||
该提示词控制 JSON 输出结构和解析口径,不建议随意修改,否则可能导致解析失败或内容错位。
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
)
|
||||
|
||||
const renderApiTab = () => (
|
||||
<div className="tab-content">
|
||||
<div className="form-group">
|
||||
@@ -5049,7 +5543,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
row.push(
|
||||
<div key="ai-settings-group" className={`tab-group ${aiGroupExpanded ? 'expanded' : ''}`}>
|
||||
<button
|
||||
className={`tab-btn tab-group-trigger ${(activeTab === 'aiCommon' || activeTab === 'insight' || activeTab === 'aiFootprint') ? 'active' : ''}`}
|
||||
className={`tab-btn tab-group-trigger ${(activeTab === 'aiCommon' || activeTab === 'insight' || activeTab === 'aiFootprint' || activeTab === 'aiGroupSummary' || activeTab === 'aiMessageInsight') ? 'active' : ''}`}
|
||||
onClick={() => setAiGroupExpanded((prev) => !prev)}
|
||||
aria-expanded={aiGroupExpanded}
|
||||
>
|
||||
@@ -5091,6 +5585,8 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
{activeTab === 'aiCommon' && renderAiCommonTab()}
|
||||
{activeTab === 'insight' && renderInsightTab()}
|
||||
{activeTab === 'aiFootprint' && renderAiFootprintTab()}
|
||||
{activeTab === 'aiGroupSummary' && renderAiGroupSummaryTab()}
|
||||
{activeTab === 'aiMessageInsight' && renderAiMessageInsightTab()}
|
||||
{activeTab === 'autoDownload' && renderAutoDownloadTab()}
|
||||
{activeTab === 'updates' && renderUpdatesTab()}
|
||||
{activeTab === 'analytics' && renderAnalyticsTab()}
|
||||
|
||||
@@ -201,6 +201,7 @@
|
||||
padding: 12px 16px;
|
||||
margin-left: -4px;
|
||||
margin-right: -4px;
|
||||
overflow: hidden;
|
||||
|
||||
// 遮挡流程线
|
||||
&::before {
|
||||
@@ -212,7 +213,7 @@
|
||||
width: 20px;
|
||||
background: var(--primary);
|
||||
border-radius: 12px 0 0 12px;
|
||||
z-index: -1;
|
||||
z-index: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,6 +235,8 @@
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
|
||||
[data-mode="dark"] .welcome-sidebar & {
|
||||
border-color: rgba(255, 255, 255, 0.2); // 稍微调亮边框
|
||||
@@ -273,6 +276,8 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: transform 0.3s ease;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.nav-title {
|
||||
|
||||
@@ -120,6 +120,14 @@ export const CONFIG_KEYS = {
|
||||
// AI 足迹
|
||||
AI_FOOTPRINT_ENABLED: 'aiFootprintEnabled',
|
||||
AI_FOOTPRINT_SYSTEM_PROMPT: 'aiFootprintSystemPrompt',
|
||||
AI_GROUP_SUMMARY_ENABLED: 'aiGroupSummaryEnabled',
|
||||
AI_GROUP_SUMMARY_INTERVAL_HOURS: 'aiGroupSummaryIntervalHours',
|
||||
AI_GROUP_SUMMARY_SYSTEM_PROMPT: 'aiGroupSummarySystemPrompt',
|
||||
AI_GROUP_SUMMARY_FILTER_MODE: 'aiGroupSummaryFilterMode',
|
||||
AI_GROUP_SUMMARY_FILTER_LIST: 'aiGroupSummaryFilterList',
|
||||
AI_MESSAGE_INSIGHT_ENABLED: 'aiMessageInsightEnabled',
|
||||
AI_MESSAGE_INSIGHT_CONTEXT_COUNT: 'aiMessageInsightContextCount',
|
||||
AI_MESSAGE_INSIGHT_SYSTEM_PROMPT: 'aiMessageInsightSystemPrompt',
|
||||
AI_INSIGHT_DEBUG_LOG_ENABLED: 'aiInsightDebugLogEnabled',
|
||||
AUTO_DOWNLOAD_HIGH_RES: 'autoDownloadHighRes',
|
||||
AUTO_DOWNLOAD_WHITELIST: 'autoDownloadWhitelist'
|
||||
@@ -2175,6 +2183,97 @@ export async function setAiFootprintSystemPrompt(prompt: string): Promise<void>
|
||||
await config.set(CONFIG_KEYS.AI_FOOTPRINT_SYSTEM_PROMPT, prompt)
|
||||
}
|
||||
|
||||
// Legacy only: 群聊总结现在只使用 aiGroupSummaryFilterList 作为作用域白名单。
|
||||
export type AiGroupSummaryFilterMode = 'whitelist' | 'blacklist'
|
||||
|
||||
const AI_GROUP_SUMMARY_INTERVALS = new Set([1, 2, 4, 8, 12, 24])
|
||||
|
||||
const normalizeAiGroupSummaryFilterList = (value: unknown): string[] => {
|
||||
if (!Array.isArray(value)) return []
|
||||
return Array.from(new Set(
|
||||
value
|
||||
.map((item) => String(item || '').trim())
|
||||
.filter((item) => item.endsWith('@chatroom'))
|
||||
))
|
||||
}
|
||||
|
||||
export async function getAiGroupSummaryEnabled(): Promise<boolean> {
|
||||
const value = await config.get(CONFIG_KEYS.AI_GROUP_SUMMARY_ENABLED)
|
||||
return value === true
|
||||
}
|
||||
|
||||
export async function setAiGroupSummaryEnabled(enabled: boolean): Promise<void> {
|
||||
await config.set(CONFIG_KEYS.AI_GROUP_SUMMARY_ENABLED, enabled)
|
||||
}
|
||||
|
||||
export async function getAiGroupSummaryIntervalHours(): Promise<number> {
|
||||
const value = Number(await config.get(CONFIG_KEYS.AI_GROUP_SUMMARY_INTERVAL_HOURS))
|
||||
const normalized = Number.isFinite(value) ? Math.floor(value) : 4
|
||||
return AI_GROUP_SUMMARY_INTERVALS.has(normalized) ? normalized : 4
|
||||
}
|
||||
|
||||
export async function setAiGroupSummaryIntervalHours(hours: number): Promise<void> {
|
||||
const normalized = Math.floor(Number(hours) || 4)
|
||||
await config.set(CONFIG_KEYS.AI_GROUP_SUMMARY_INTERVAL_HOURS, AI_GROUP_SUMMARY_INTERVALS.has(normalized) ? normalized : 4)
|
||||
}
|
||||
|
||||
export async function getAiGroupSummarySystemPrompt(): Promise<string> {
|
||||
const value = await config.get(CONFIG_KEYS.AI_GROUP_SUMMARY_SYSTEM_PROMPT)
|
||||
return typeof value === 'string' ? value : ''
|
||||
}
|
||||
|
||||
export async function setAiGroupSummarySystemPrompt(prompt: string): Promise<void> {
|
||||
await config.set(CONFIG_KEYS.AI_GROUP_SUMMARY_SYSTEM_PROMPT, prompt)
|
||||
}
|
||||
|
||||
export async function getAiGroupSummaryFilterMode(): Promise<AiGroupSummaryFilterMode> {
|
||||
const value = await config.get(CONFIG_KEYS.AI_GROUP_SUMMARY_FILTER_MODE)
|
||||
return value === 'blacklist' ? 'blacklist' : 'whitelist'
|
||||
}
|
||||
|
||||
export async function setAiGroupSummaryFilterMode(mode: AiGroupSummaryFilterMode): Promise<void> {
|
||||
await config.set(CONFIG_KEYS.AI_GROUP_SUMMARY_FILTER_MODE, mode === 'blacklist' ? 'blacklist' : 'whitelist')
|
||||
}
|
||||
|
||||
export async function getAiGroupSummaryFilterList(): Promise<string[]> {
|
||||
const value = await config.get(CONFIG_KEYS.AI_GROUP_SUMMARY_FILTER_LIST)
|
||||
return normalizeAiGroupSummaryFilterList(value)
|
||||
}
|
||||
|
||||
export async function setAiGroupSummaryFilterList(list: string[]): Promise<void> {
|
||||
await config.set(CONFIG_KEYS.AI_GROUP_SUMMARY_FILTER_LIST, normalizeAiGroupSummaryFilterList(list))
|
||||
}
|
||||
|
||||
export async function getAiMessageInsightEnabled(): Promise<boolean> {
|
||||
const value = await config.get(CONFIG_KEYS.AI_MESSAGE_INSIGHT_ENABLED)
|
||||
return value === true
|
||||
}
|
||||
|
||||
export async function setAiMessageInsightEnabled(enabled: boolean): Promise<void> {
|
||||
await config.set(CONFIG_KEYS.AI_MESSAGE_INSIGHT_ENABLED, enabled)
|
||||
}
|
||||
|
||||
export async function getAiMessageInsightContextCount(): Promise<number> {
|
||||
const value = await config.get(CONFIG_KEYS.AI_MESSAGE_INSIGHT_CONTEXT_COUNT)
|
||||
const numeric = Number(value)
|
||||
if (!Number.isFinite(numeric)) return 50
|
||||
return Math.max(1, Math.min(200, Math.floor(numeric)))
|
||||
}
|
||||
|
||||
export async function setAiMessageInsightContextCount(count: number): Promise<void> {
|
||||
const normalized = Number.isFinite(count) ? Math.max(1, Math.min(200, Math.floor(count))) : 50
|
||||
await config.set(CONFIG_KEYS.AI_MESSAGE_INSIGHT_CONTEXT_COUNT, normalized)
|
||||
}
|
||||
|
||||
export async function getAiMessageInsightSystemPrompt(): Promise<string> {
|
||||
const value = await config.get(CONFIG_KEYS.AI_MESSAGE_INSIGHT_SYSTEM_PROMPT)
|
||||
return typeof value === 'string' ? value : ''
|
||||
}
|
||||
|
||||
export async function setAiMessageInsightSystemPrompt(prompt: string): Promise<void> {
|
||||
await config.set(CONFIG_KEYS.AI_MESSAGE_INSIGHT_SYSTEM_PROMPT, prompt)
|
||||
}
|
||||
|
||||
export async function getAiInsightDebugLogEnabled(): Promise<boolean> {
|
||||
const value = await config.get(CONFIG_KEYS.AI_INSIGHT_DEBUG_LOG_ENABLED)
|
||||
return value === true
|
||||
|
||||
191
src/types/electron.d.ts
vendored
191
src/types/electron.d.ts
vendored
@@ -21,7 +21,24 @@ export interface SocialSaveWeiboCookieResult {
|
||||
error?: string
|
||||
}
|
||||
|
||||
export type InsightRecordTriggerReason = 'activity' | 'silence' | 'test'
|
||||
export type InsightRecordTriggerReason = 'activity' | 'silence' | 'test' | 'manual' | 'message_analysis'
|
||||
export type InsightRecordSourceType = 'insight' | 'message_analysis'
|
||||
|
||||
export interface MessageInsightAnalysis {
|
||||
explicitText: string
|
||||
emotion: string
|
||||
intent: string
|
||||
topic: string
|
||||
}
|
||||
|
||||
export interface MessageInsightTarget {
|
||||
targetLocalId: number
|
||||
targetCreateTime: number
|
||||
targetMessageKey: string
|
||||
targetSenderName: string
|
||||
targetTextPreview: string
|
||||
analysis: MessageInsightAnalysis
|
||||
}
|
||||
|
||||
export interface InsightRecordLog {
|
||||
endpoint: string
|
||||
@@ -37,10 +54,28 @@ export interface InsightRecordLog {
|
||||
finalInsight: string
|
||||
durationMs: number
|
||||
createdAt: number
|
||||
responseFormatJson?: boolean
|
||||
responseFormatFallback?: boolean
|
||||
responseFormatFallbackReason?: string
|
||||
targetMessage?: {
|
||||
localId: number
|
||||
createTime: number
|
||||
messageKey: string
|
||||
senderName: string
|
||||
textPreview: string
|
||||
}
|
||||
contextStats?: {
|
||||
requested: number
|
||||
beforeTarget: number
|
||||
afterTarget: number
|
||||
readError?: string
|
||||
}
|
||||
parsedAnalysis?: MessageInsightAnalysis
|
||||
}
|
||||
|
||||
export interface InsightRecordSummary {
|
||||
id: string
|
||||
sourceType: InsightRecordSourceType
|
||||
createdAt: number
|
||||
sessionId: string
|
||||
displayName: string
|
||||
@@ -48,6 +83,7 @@ export interface InsightRecordSummary {
|
||||
triggerReason: InsightRecordTriggerReason
|
||||
insight: string
|
||||
read: boolean
|
||||
messageInsight?: MessageInsightTarget
|
||||
}
|
||||
|
||||
export interface InsightRecord extends InsightRecordSummary {
|
||||
@@ -67,6 +103,7 @@ export interface InsightRecordFilters {
|
||||
sessionId?: string
|
||||
startTime?: number
|
||||
endTime?: number
|
||||
sourceType?: InsightRecordSourceType | 'all'
|
||||
limit?: number
|
||||
offset?: number
|
||||
}
|
||||
@@ -87,6 +124,108 @@ export interface InsightRecordResult {
|
||||
error?: string
|
||||
}
|
||||
|
||||
export type InsightProfileStatusValue = 'none' | 'ready' | 'running' | 'failed'
|
||||
|
||||
export interface InsightProfileStatus {
|
||||
sessionId: string
|
||||
status: InsightProfileStatusValue
|
||||
updatedAt?: number
|
||||
error?: string
|
||||
phase?: string
|
||||
busy?: boolean
|
||||
}
|
||||
|
||||
export interface InsightProfileStatusListResult {
|
||||
success: boolean
|
||||
statuses: Record<string, InsightProfileStatus>
|
||||
activeTask?: {
|
||||
sessionId: string
|
||||
displayName: string
|
||||
phase: string
|
||||
startedAt: number
|
||||
}
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface InsightProfileGenerateResult {
|
||||
success: boolean
|
||||
message: string
|
||||
cancelled?: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
export type GroupSummaryTriggerType = 'auto' | 'manual'
|
||||
|
||||
export interface GroupSummaryTopic {
|
||||
title: string
|
||||
participants: string[]
|
||||
keyPoints: string[]
|
||||
conclusion: string
|
||||
}
|
||||
|
||||
export interface GroupSummaryLog {
|
||||
endpoint: string
|
||||
model: string
|
||||
temperature: number
|
||||
triggerType: GroupSummaryTriggerType
|
||||
periodStart: number
|
||||
periodEnd: number
|
||||
messageCount: number
|
||||
readableMessageCount: number
|
||||
systemPrompt: string
|
||||
userPrompt: string
|
||||
rawOutput: string
|
||||
finalSummary: string
|
||||
durationMs: number
|
||||
createdAt: number
|
||||
responseFormatJson?: boolean
|
||||
responseFormatFallback?: boolean
|
||||
responseFormatFallbackReason?: string
|
||||
parsedTopics?: GroupSummaryTopic[]
|
||||
}
|
||||
|
||||
export interface GroupSummaryRecordSummary {
|
||||
id: string
|
||||
createdAt: number
|
||||
sessionId: string
|
||||
displayName: string
|
||||
avatarUrl?: string
|
||||
triggerType: GroupSummaryTriggerType
|
||||
periodStart: number
|
||||
periodEnd: number
|
||||
messageCount: number
|
||||
readableMessageCount: number
|
||||
topics: GroupSummaryTopic[]
|
||||
summaryText: string
|
||||
}
|
||||
|
||||
export interface GroupSummaryRecord extends GroupSummaryRecordSummary {
|
||||
accountScope: string
|
||||
rawOutput: string
|
||||
log: GroupSummaryLog
|
||||
}
|
||||
|
||||
export interface GroupSummaryRecordFilters {
|
||||
sessionId?: string
|
||||
startTime?: number
|
||||
endTime?: number
|
||||
limit?: number
|
||||
offset?: number
|
||||
}
|
||||
|
||||
export interface GroupSummaryRecordListResult {
|
||||
success: boolean
|
||||
records: GroupSummaryRecordSummary[]
|
||||
total: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface GroupSummaryRecordResult {
|
||||
success: boolean
|
||||
record?: GroupSummaryRecord
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface BackupProgress {
|
||||
phase: 'preparing' | 'scanning' | 'exporting' | 'packing' | 'inspecting' | 'restoring' | 'done' | 'failed'
|
||||
message: string
|
||||
@@ -169,6 +308,11 @@ export interface BackupManifest {
|
||||
}
|
||||
}
|
||||
|
||||
export type CloseConfirmPayload = {
|
||||
canMinimizeToTray: boolean
|
||||
restoreMethod?: 'tray' | 'dock'
|
||||
}
|
||||
|
||||
export interface ElectronAPI {
|
||||
window: {
|
||||
minimize: () => void
|
||||
@@ -176,7 +320,7 @@ export interface ElectronAPI {
|
||||
isMaximized: () => Promise<boolean>
|
||||
onMaximizeStateChanged: (callback: (isMaximized: boolean) => void) => () => void
|
||||
close: () => void
|
||||
onCloseConfirmRequested: (callback: (payload: { canMinimizeToTray: boolean }) => void) => () => void
|
||||
onCloseConfirmRequested: (callback: (payload: CloseConfirmPayload) => void) => () => void
|
||||
respondCloseConfirm: (action: 'tray' | 'quit' | 'cancel') => Promise<boolean>
|
||||
openAgreementWindow: () => Promise<boolean>
|
||||
completeOnboarding: () => Promise<boolean>
|
||||
@@ -365,7 +509,7 @@ export interface ElectronAPI {
|
||||
}
|
||||
error?: string
|
||||
}>
|
||||
getSessionMessageCounts: (sessionIds: string[]) => Promise<{
|
||||
getSessionMessageCounts: (sessionIds: string[], options?: { preferHintCache?: boolean; bypassSessionCache?: boolean }) => Promise<{
|
||||
success: boolean
|
||||
counts?: Record<string, number>
|
||||
error?: string
|
||||
@@ -1320,6 +1464,18 @@ export interface ElectronAPI {
|
||||
markRecordRead: (id: string) => Promise<{ success: boolean; error?: string }>
|
||||
clearRecords: (filters?: InsightRecordFilters) => Promise<{ success: boolean; removed: number; error?: string }>
|
||||
triggerTest: () => Promise<{ success: boolean; message: string }>
|
||||
triggerSessionInsight: (payload: {
|
||||
sessionId: string
|
||||
displayName?: string
|
||||
avatarUrl?: string
|
||||
}) => Promise<{ success: boolean; message: string; recordId?: string; insight?: string; skipped?: boolean; notificationEnabled?: boolean }>
|
||||
listProfileStatuses: (sessionIds: string[]) => Promise<InsightProfileStatusListResult>
|
||||
generateProfile: (payload: {
|
||||
sessionId: string
|
||||
displayName?: string
|
||||
avatarUrl?: string
|
||||
}) => Promise<InsightProfileGenerateResult>
|
||||
cancelProfile: (sessionId?: string) => Promise<{ success: boolean; message: string }>
|
||||
generateFootprintInsight: (payload: {
|
||||
rangeLabel: string
|
||||
summary: {
|
||||
@@ -1333,6 +1489,35 @@ export interface ElectronAPI {
|
||||
privateSegments?: Array<{ displayName?: string; session_id?: string; incoming_count?: number; outgoing_count?: number; message_count?: number; replied?: boolean }>
|
||||
mentionGroups?: Array<{ displayName?: string; session_id?: string; count?: number }>
|
||||
}) => Promise<{ success: boolean; message: string; insight?: string }>
|
||||
generateMessageInsight: (payload: {
|
||||
sessionId: string
|
||||
displayName?: string
|
||||
avatarUrl?: string
|
||||
targetLocalId?: number
|
||||
targetCreateTime?: number
|
||||
targetMessageKey?: string
|
||||
targetText: string
|
||||
targetSenderName?: string
|
||||
contextCount?: number
|
||||
forceRefresh?: boolean
|
||||
}) => Promise<{ success: boolean; message: string; cached?: boolean; recordId?: string; data?: MessageInsightAnalysis }>
|
||||
}
|
||||
groupSummary: {
|
||||
listRecords: (filters?: GroupSummaryRecordFilters) => Promise<GroupSummaryRecordListResult>
|
||||
getRecord: (id: string) => Promise<GroupSummaryRecordResult>
|
||||
triggerManual: (payload: {
|
||||
sessionId: string
|
||||
displayName?: string
|
||||
avatarUrl?: string
|
||||
startTime: number
|
||||
endTime: number
|
||||
}) => Promise<{ success: boolean; message: string; recordId?: string; record?: GroupSummaryRecordSummary; skipped?: boolean; skippedReason?: string }>
|
||||
triggerDay: (payload: {
|
||||
sessionId: string
|
||||
displayName?: string
|
||||
avatarUrl?: string
|
||||
date: string
|
||||
}) => Promise<{ success: boolean; message: string; generated: number; skipped: number; records: GroupSummaryRecordSummary[] }>
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user