mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-17 19:47:08 +08:00
feat: 完善群聊日报模板与图片理解
This commit is contained in:
@@ -81,7 +81,14 @@ const areMessagesEquivalent = (left: Message[], right: Message[]): boolean => {
|
||||
type GroupSnapshot = {
|
||||
roomId: string
|
||||
memberCount: number
|
||||
members: { wxid: string; nickname: string; avatar: string }[]
|
||||
members: {
|
||||
wxid: string
|
||||
nickname: string
|
||||
groupNickname: string
|
||||
wechatNickname: string
|
||||
remark: string
|
||||
avatar: string
|
||||
}[]
|
||||
}
|
||||
|
||||
type GroupMemberMeta = { nickname: string; avatar: string }
|
||||
@@ -1137,6 +1144,12 @@ function App(): React.ReactElement {
|
||||
onRevealReport={reportGeneration.revealReport}
|
||||
onViewResult={openReportResult}
|
||||
hasReportResult={generatedReports.length > 0}
|
||||
templateId={reportGeneration.templateId}
|
||||
onTemplateIdChange={reportGeneration.setTemplateId}
|
||||
memberNamePreference={reportGeneration.memberNamePreference}
|
||||
onMemberNamePreferenceChange={reportGeneration.setMemberNamePreference}
|
||||
reportTimeoutSeconds={reportGeneration.reportTimeoutSeconds}
|
||||
onReportTimeoutSecondsChange={reportGeneration.setReportTimeoutSeconds}
|
||||
/>
|
||||
<ReportTaskStatusPanel
|
||||
phase={reportGeneration.phase}
|
||||
|
||||
@@ -3020,6 +3020,10 @@ body {
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
.ai-report-body > .report-section + .report-section {
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
.report-empty-state,
|
||||
.report-config-section,
|
||||
.report-privacy-note,
|
||||
@@ -3040,6 +3044,39 @@ body {
|
||||
font: 700 15px/20px var(--wxex-font);
|
||||
}
|
||||
|
||||
.report-timeout-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.report-timeout-section p {
|
||||
margin: 6px 0 0;
|
||||
color: var(--wxex-text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.report-timeout-section label {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--wxex-text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.report-timeout-section input {
|
||||
width: 96px;
|
||||
height: 36px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--wxex-border);
|
||||
border-radius: var(--wxex-radius-md);
|
||||
background: #fff;
|
||||
color: var(--wxex-text-primary);
|
||||
font: 500 13px var(--wxex-font);
|
||||
}
|
||||
|
||||
.report-empty-state p,
|
||||
.report-privacy-note p {
|
||||
margin: 6px 0 0;
|
||||
@@ -4390,3 +4427,185 @@ body {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================ */
|
||||
/* 日报模板选择器 + 预览器 */
|
||||
/* ============================================================ */
|
||||
.report-section-desc {
|
||||
margin: 0 0 12px;
|
||||
color: var(--wxex-text-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.report-template-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.report-template-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
border: 1.5px solid var(--wxex-border);
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
transition: border-color 0.2s, background 0.2s;
|
||||
}
|
||||
|
||||
.report-template-item.active {
|
||||
border-color: var(--wxex-primary, #07c160);
|
||||
background: #f0fbf3;
|
||||
}
|
||||
|
||||
.report-template-item.disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.report-template-item > label {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.report-template-item input[type='radio'] {
|
||||
accent-color: var(--wxex-primary, #07c160);
|
||||
}
|
||||
|
||||
.report-template-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.report-template-title {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--wxex-text-primary, #1f2933);
|
||||
}
|
||||
|
||||
.report-template-tagline {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--wxex-text-secondary, #485465);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.report-template-preview-btn {
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--wxex-border);
|
||||
background: #fff;
|
||||
color: var(--wxex-text-primary, #1f2933);
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, color 0.2s, border-color 0.2s;
|
||||
}
|
||||
|
||||
.report-template-preview-btn:hover {
|
||||
background: var(--wxex-primary, #07c160);
|
||||
color: #fff;
|
||||
border-color: var(--wxex-primary, #07c160);
|
||||
}
|
||||
|
||||
/* 预览遮罩 */
|
||||
.report-template-preview-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(15, 23, 42, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 999;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.report-template-preview-card {
|
||||
background: #f3f5f7;
|
||||
border-radius: 18px;
|
||||
padding: 18px;
|
||||
width: min(380px, 100%);
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 16px 60px rgba(15, 23, 42, 0.25);
|
||||
}
|
||||
|
||||
.report-template-preview-card h4 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 17px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.report-template-preview-card p.muted {
|
||||
margin: 0 0 14px;
|
||||
font-size: 12px;
|
||||
color: var(--wxex-text-secondary, #485465);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.report-template-preview-frame {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--wxex-border);
|
||||
}
|
||||
|
||||
.fake-card {
|
||||
background: #f7faf9;
|
||||
border-radius: 10px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.fake-hero {
|
||||
background: linear-gradient(135deg, #edf9f1 0%, #f7fbf8 100%);
|
||||
}
|
||||
|
||||
.fake-title {
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
color: #076c39;
|
||||
}
|
||||
|
||||
.fake-sub {
|
||||
margin-top: 2px;
|
||||
font-size: 11px;
|
||||
color: #485465;
|
||||
}
|
||||
|
||||
.fake-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.fake-bar {
|
||||
width: 4px;
|
||||
height: 14px;
|
||||
border-radius: 2px;
|
||||
background: var(--wxex-primary, #07c160);
|
||||
}
|
||||
|
||||
.fake-section-title {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: #1f2933;
|
||||
}
|
||||
|
||||
.report-template-preview-close {
|
||||
margin-top: 14px;
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border-radius: 10px;
|
||||
border: none;
|
||||
background: var(--wxex-primary, #07c160);
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -1,124 +1,111 @@
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Message, Contact } from '../../../shared/types'
|
||||
import { VoicePlayer } from './VoicePlayer'
|
||||
import { RichMessageBubble } from './RichMessageBubble'
|
||||
import { ImageBubble } from './ImageBubble'
|
||||
import {
|
||||
buildGroupReportInput,
|
||||
GROUP_REPORT_SYSTEM_PROMPT,
|
||||
parseGroupDailyReport
|
||||
} from '../utils/group-report'
|
||||
import type { ReportMode } from '../../../shared/group-report'
|
||||
import { ChatHeader } from './chat/ChatHeader'
|
||||
import { ChatStatusBar } from './chat/ChatStatusBar'
|
||||
import { DataTrustBar } from './chat/DataTrustBar'
|
||||
import { EmptyConversationState } from './chat/EmptyConversationState'
|
||||
import { ExportRange } from './chat/ExportMenu'
|
||||
import { MessageList } from './chat/MessageList'
|
||||
|
||||
interface ChatWindowProps {
|
||||
contact: Contact | null
|
||||
messages: Message[]
|
||||
isLoadingMessages?: boolean
|
||||
contentFilter?: string
|
||||
dateRange?: string
|
||||
onContentFilterChange?: (keyword: string) => void
|
||||
onRefresh?: () => void
|
||||
onRefreshData?: () => void
|
||||
onCreateGroupReport?: () => void
|
||||
isAiLoading?: boolean
|
||||
}
|
||||
|
||||
type SummaryDateRange = 'today' | 'yesterday' | '7days'
|
||||
type SummaryMessageType = 'text' | 'image' | 'sticker' | 'video' | 'voice' | 'share' | 'system'
|
||||
const MAX_RENDERED_MESSAGES = 600
|
||||
const DATE_RANGE_LABELS: Record<string, string> = {
|
||||
today: '今天',
|
||||
yesterday: '昨日',
|
||||
'7': '7 天',
|
||||
'30': '30 天',
|
||||
all: '全部'
|
||||
}
|
||||
|
||||
const SUMMARY_DATE_OPTIONS: { value: SummaryDateRange; label: string }[] = [
|
||||
{ value: 'today', label: '今天' },
|
||||
{ value: 'yesterday', label: '昨日' },
|
||||
{ value: '7days', label: '最近 7 天' }
|
||||
]
|
||||
const formatClock = (date: Date): string =>
|
||||
`${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
|
||||
|
||||
const SUMMARY_TYPE_OPTIONS: {
|
||||
value: SummaryMessageType
|
||||
label: string
|
||||
messageTypes: string[]
|
||||
}[] = [
|
||||
{ value: 'text', label: '文本', messageTypes: ['普通文本'] },
|
||||
{ value: 'image', label: '图片', messageTypes: ['图片'] },
|
||||
{ value: 'sticker', label: '表情包', messageTypes: ['表情包'] },
|
||||
{ value: 'video', label: '视频', messageTypes: ['视频'] },
|
||||
{ value: 'voice', label: '语音', messageTypes: ['语音'] },
|
||||
{ value: 'share', label: '分享/引用', messageTypes: ['分享消息', '名片', '位置', '通话'] },
|
||||
{ value: 'system', label: '系统消息', messageTypes: ['系统消息'] }
|
||||
]
|
||||
const formatRangeDate = (date: Date, now: Date): string => {
|
||||
const clock = formatClock(date)
|
||||
if (date.getFullYear() === now.getFullYear()) {
|
||||
return `${date.getMonth() + 1} 月 ${date.getDate()} 日 ${clock}`
|
||||
}
|
||||
return `${date.getFullYear()} 年 ${date.getMonth() + 1} 月 ${date.getDate()} 日 ${clock}`
|
||||
}
|
||||
|
||||
const getSummaryDateRange = (range: SummaryDateRange): { startTime: number; endTime: number } => {
|
||||
const getChatHeaderRangeLabel = (range: string): string => {
|
||||
const now = new Date()
|
||||
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime() / 1000
|
||||
const endTime = Math.floor(Date.now() / 1000)
|
||||
if (range === 'yesterday') {
|
||||
return { startTime: startOfToday - 86400, endTime: startOfToday - 1 }
|
||||
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
const endOfYesterday = new Date(startOfToday.getTime() - 60_000)
|
||||
|
||||
if (range === 'today') return `今天 00:00—现在`
|
||||
if (range === 'yesterday') return `昨天 00:00—${formatClock(endOfYesterday)}`
|
||||
if (range === '7') {
|
||||
const start = new Date(Date.now() - 7 * 86400000)
|
||||
return `${formatRangeDate(start, now)}—现在`
|
||||
}
|
||||
if (range === '7days') {
|
||||
return { startTime: startOfToday - 6 * 86400, endTime }
|
||||
if (range === '30') {
|
||||
const start = new Date(Date.now() - 30 * 86400000)
|
||||
return `${formatRangeDate(start, now)}—现在`
|
||||
}
|
||||
return { startTime: startOfToday, endTime }
|
||||
if (range === 'all') return '全部记录'
|
||||
return DATE_RANGE_LABELS[range] || '当前范围'
|
||||
}
|
||||
|
||||
const ChatWindow: React.FC<ChatWindowProps> = ({
|
||||
contact,
|
||||
messages,
|
||||
isLoadingMessages,
|
||||
contentFilter,
|
||||
dateRange = 'today',
|
||||
onContentFilterChange,
|
||||
onRefresh,
|
||||
onRefreshData
|
||||
onRefreshData,
|
||||
onCreateGroupReport,
|
||||
isAiLoading = false
|
||||
}) => {
|
||||
const isGroupChat = Boolean(
|
||||
contact?.type === 'group' || contact?.m_nsUsrName?.endsWith('@chatroom')
|
||||
)
|
||||
const messageListRef = useRef<HTMLDivElement>(null)
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
const [generatedImage, setGeneratedImage] = useState<string | null>(null)
|
||||
const [reportPaths, setReportPaths] = useState<{ htmlPath: string; pngPath: string } | null>(null)
|
||||
const [previewImage, setPreviewImage] = useState<string | null>(null)
|
||||
const [imageScale, setImageScale] = useState(0.75)
|
||||
const [imageRotation, setImageRotation] = useState(0)
|
||||
const [imageOffset, setImageOffset] = useState({ x: 0, y: 0 })
|
||||
const imageViewerStageRef = useRef<HTMLDivElement>(null)
|
||||
const imageDragRef = useRef<{ x: number; y: number; offsetX: number; offsetY: number } | null>(
|
||||
null
|
||||
)
|
||||
const [showAvatar, setShowAvatar] = useState(true)
|
||||
const [isAtLatest, setIsAtLatest] = useState(true)
|
||||
|
||||
// AI Settings
|
||||
const [showSettingsModal, setShowSettingsModal] = useState(false)
|
||||
const [apiKey, setApiKey] = useState(() => localStorage.getItem('ai_api_key') || '')
|
||||
const [baseURL, setBaseURL] = useState(
|
||||
() => localStorage.getItem('ai_base_url') || 'https://api.deepseek.com'
|
||||
)
|
||||
const [model, setModel] = useState(() => localStorage.getItem('ai_model') || 'deepseek-v4-flash')
|
||||
const [summaryDateRange, setSummaryDateRange] = useState<SummaryDateRange>('today')
|
||||
const [summaryMessageTypes, setSummaryMessageTypes] = useState<SummaryMessageType[]>(['text'])
|
||||
const [reportMode, setReportMode] = useState<ReportMode>(
|
||||
() => (localStorage.getItem('group_report_mode') as ReportMode) || 'compact'
|
||||
)
|
||||
|
||||
const handleSaveSettings = (): void => {
|
||||
if (!summaryMessageTypes.length) {
|
||||
alert('请至少选择一种消息类型')
|
||||
return
|
||||
}
|
||||
localStorage.setItem('ai_api_key', apiKey)
|
||||
localStorage.setItem('ai_base_url', baseURL)
|
||||
localStorage.setItem('ai_model', model)
|
||||
localStorage.setItem('group_report_mode', reportMode)
|
||||
setShowSettingsModal(false)
|
||||
AIChat()
|
||||
}
|
||||
|
||||
const toggleSummaryMessageType = (type: SummaryMessageType): void => {
|
||||
setSummaryMessageTypes((current) =>
|
||||
current.includes(type) ? current.filter((item) => item !== type) : [...current, type]
|
||||
)
|
||||
}
|
||||
|
||||
const scrollToBottom = (): void => {
|
||||
const scrollToBottom = useCallback((): void => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'auto' })
|
||||
}
|
||||
setIsAtLatest(true)
|
||||
}, [])
|
||||
|
||||
const handleMessageListScroll = useCallback((event: React.UIEvent<HTMLDivElement>): void => {
|
||||
const target = event.currentTarget
|
||||
const distanceToBottom = target.scrollHeight - target.scrollTop - target.clientHeight
|
||||
setIsAtLatest(distanceToBottom <= 24)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom()
|
||||
}, [messages])
|
||||
const frame = window.requestAnimationFrame(() => scrollToBottom())
|
||||
return () => window.cancelAnimationFrame(frame)
|
||||
}, [messages, scrollToBottom])
|
||||
|
||||
const openImagePreview = (imageUrl: string): void => {
|
||||
setPreviewImage(imageUrl)
|
||||
setImageScale(0.75)
|
||||
setImageScale(1)
|
||||
setImageRotation(0)
|
||||
setImageOffset({ x: 0, y: 0 })
|
||||
}
|
||||
@@ -129,17 +116,18 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
||||
}
|
||||
|
||||
const zoomImage = (delta: number): void => {
|
||||
setImageScale((prev) => Math.min(3, Math.max(0.25, Number((prev + delta).toFixed(2)))))
|
||||
setImageScale((prev) => Math.min(8, Math.max(0.1, Number((prev + delta).toFixed(2)))))
|
||||
}
|
||||
|
||||
const resetImageTransform = (): void => {
|
||||
setImageScale(0.75)
|
||||
setImageScale(1)
|
||||
setImageRotation(0)
|
||||
setImageOffset({ x: 0, y: 0 })
|
||||
}
|
||||
|
||||
const handleViewerWheel = (event: React.WheelEvent): void => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
zoomImage(event.deltaY > 0 ? -0.1 : 0.1)
|
||||
}
|
||||
|
||||
@@ -166,7 +154,25 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
||||
imageDragRef.current = null
|
||||
}
|
||||
|
||||
const handleExport = (days: number | 'all'): void => {
|
||||
useEffect(() => {
|
||||
if (!previewImage) return
|
||||
|
||||
const previousOverflow = document.body.style.overflow
|
||||
document.body.style.overflow = 'hidden'
|
||||
|
||||
const stage = imageViewerStageRef.current
|
||||
const preventBackgroundWheel = (event: WheelEvent): void => {
|
||||
event.preventDefault()
|
||||
}
|
||||
stage?.addEventListener('wheel', preventBackgroundWheel, { passive: false })
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow
|
||||
stage?.removeEventListener('wheel', preventBackgroundWheel)
|
||||
}
|
||||
}, [previewImage])
|
||||
|
||||
const handleExport = (days: ExportRange): void => {
|
||||
if (!messages.length) return
|
||||
|
||||
let filtered = messages
|
||||
@@ -225,67 +231,6 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
||||
document.body.removeChild(link)
|
||||
}
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const AIChat = async (): Promise<void> => {
|
||||
if (!contact) return
|
||||
if (!summaryMessageTypes.length) {
|
||||
alert('请至少选择一种消息类型')
|
||||
return
|
||||
}
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const { startTime, endTime } = getSummaryDateRange(summaryDateRange)
|
||||
const rangeMessages = await window.api.getMessages(contact.md5, startTime, endTime)
|
||||
const allowedTypes = new Set(
|
||||
SUMMARY_TYPE_OPTIONS.filter((option) => summaryMessageTypes.includes(option.value)).flatMap(
|
||||
(option) => option.messageTypes
|
||||
)
|
||||
)
|
||||
const reportMessages = rangeMessages.filter((message) => allowedTypes.has(message.type))
|
||||
if (!reportMessages.length) throw new Error('当前条件下没有可总结的消息')
|
||||
|
||||
const input = await buildGroupReportInput(reportMessages, contact, isGroupChat, reportMode)
|
||||
console.log('🚀 ~ AIChat ~ input:', input)
|
||||
console.log('🚀 ~ AIChat ~ input.prompt:', input.prompt)
|
||||
const result = await window.api.aiChat(
|
||||
[
|
||||
{ role: 'system', content: GROUP_REPORT_SYSTEM_PROMPT },
|
||||
{ role: 'user', content: input.prompt }
|
||||
],
|
||||
{ apiKey, model, baseURL }
|
||||
)
|
||||
|
||||
if (!result.success || !result.data) throw new Error(result.error || 'AI 请求失败')
|
||||
const report = parseGroupDailyReport(
|
||||
result.data,
|
||||
input.topSpeakers,
|
||||
input.activeTimeline,
|
||||
input.voiceLeaderboard,
|
||||
input.metadata,
|
||||
input.media
|
||||
)
|
||||
const exported = await window.api.exportGroupReport({ report, metadata: input.metadata })
|
||||
if (!exported.success || !exported.imageDataUrl || !exported.htmlPath || !exported.pngPath) {
|
||||
throw new Error(exported.error || '日报文件生成失败')
|
||||
}
|
||||
setGeneratedImage(exported.imageDataUrl)
|
||||
setReportPaths({ htmlPath: exported.htmlPath, pngPath: exported.pngPath })
|
||||
} catch (error) {
|
||||
console.error('AI Call Failed:', error)
|
||||
alert(`AI 日报生成失败:${error instanceof Error ? error.message : String(error)}`)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
const handleCopyImage = async (): Promise<void> => {
|
||||
if (!generatedImage) return
|
||||
const result = await window.api.copyImage(generatedImage)
|
||||
if (result.success) {
|
||||
alert('复制成功')
|
||||
}
|
||||
}
|
||||
|
||||
const filteredMessages = React.useMemo(() => {
|
||||
return messages.filter((msg) => {
|
||||
const filterTypes = (import.meta.env.VITE_FILTER_MSG_TYPES || '')
|
||||
@@ -297,194 +242,53 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
||||
return typeMatch && contentMatch
|
||||
})
|
||||
}, [messages, contentFilter])
|
||||
const hiddenMessageCount = Math.max(0, filteredMessages.length - MAX_RENDERED_MESSAGES)
|
||||
const renderedMessages = React.useMemo(
|
||||
() => filteredMessages.slice(-MAX_RENDERED_MESSAGES),
|
||||
[filteredMessages]
|
||||
)
|
||||
|
||||
if (!contact) {
|
||||
return (
|
||||
<div className="chat-window">
|
||||
<div className="empty-state">选择一条消息</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (!contact) return <EmptyConversationState />
|
||||
|
||||
const dateRangeLabel = getChatHeaderRangeLabel(dateRange)
|
||||
|
||||
return (
|
||||
<div className="chat-window">
|
||||
<div className="chat-header">
|
||||
<h2>{contact.m_nsNickName}</h2>
|
||||
<div className="window-controls"></div>
|
||||
</div>
|
||||
|
||||
<div className="message-list wechat-message-list">
|
||||
{filteredMessages.map((msg) => {
|
||||
const isMine = msg.from === 'assistant'
|
||||
const isSystem = msg.from === 'system' || msg.type === '系统消息'
|
||||
const displayName = isMine
|
||||
? '我'
|
||||
: isGroupChat
|
||||
? msg.name || msg.from
|
||||
: contact.m_nsNickName
|
||||
const avatarSrc = isMine ? msg.img : msg.img || contact.avatar
|
||||
const isVoice = msg.type === '语音'
|
||||
const isImage = msg.type === '图片'
|
||||
const isRichMedia = ['名片', '位置', '分享消息', '通话', '表情包', '系统消息'].includes(
|
||||
msg.type
|
||||
)
|
||||
|
||||
if (isSystem) {
|
||||
return (
|
||||
<div key={msg.id} className="wechat-system-message-row">
|
||||
<div className="wechat-system-message">{msg.content}</div>
|
||||
<div className="wechat-system-message-meta">{msg.datetime}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={msg.id} className={`wechat-message-row ${isMine ? 'mine' : 'other'}`}>
|
||||
{!isMine && showAvatar && (
|
||||
<div className="message-avatar">
|
||||
{avatarSrc ? (
|
||||
<img src={avatarSrc} alt={displayName} referrerPolicy="no-referrer" />
|
||||
) : (
|
||||
(displayName || '?').charAt(0)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="message-stack">
|
||||
{!isMine && isGroupChat && <div className="message-sender-name">{displayName}</div>}
|
||||
<div
|
||||
className={`message-bubble ${isVoice ? 'voice-bubble' : ''} ${isImage ? 'image-message-bubble' : ''}`}
|
||||
>
|
||||
{isVoice && msg.sessionId ? (
|
||||
<VoicePlayer
|
||||
sessionId={msg.sessionId}
|
||||
localId={msg.localId || 0}
|
||||
createTime={msg.createTime || 0}
|
||||
/>
|
||||
) : isImage && msg.contentData && msg.contentData.type === 'image' ? (
|
||||
<ImageBubble
|
||||
imageMd5={msg.contentData.md5}
|
||||
imageDatName={msg.contentData.datName}
|
||||
sessionId={msg.sessionId}
|
||||
onImageClick={openImagePreview}
|
||||
/>
|
||||
) : isRichMedia && msg.contentData ? (
|
||||
<RichMessageBubble contentData={msg.contentData} />
|
||||
) : (
|
||||
<div className="message-text">{msg.content}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="message-meta">
|
||||
<span>{msg.datetime}</span>
|
||||
<span>{msg.type}</span>
|
||||
</div>
|
||||
</div>
|
||||
{isMine && showAvatar && (
|
||||
<div className="message-avatar mine-avatar">
|
||||
{avatarSrc ? <img src={avatarSrc} alt="我" referrerPolicy="no-referrer" /> : '我'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
<div className="chat-toolbar">
|
||||
<label
|
||||
style={{ marginRight: '10px', display: 'flex', alignItems: 'center', cursor: 'pointer' }}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showAvatar}
|
||||
onChange={(e) => setShowAvatar(e.target.checked)}
|
||||
style={{ marginRight: '5px' }}
|
||||
/>
|
||||
显示头像
|
||||
</label>
|
||||
<button className="toolbar-btn" onClick={onRefresh}>
|
||||
🔄 刷新聊天记录
|
||||
</button>
|
||||
<button className="toolbar-btn" onClick={onRefreshData}>
|
||||
🔄 刷新数据
|
||||
</button>
|
||||
<button className="toolbar-btn" onClick={() => handleExport('all')}>
|
||||
📤 导出全部
|
||||
</button>
|
||||
<button className="toolbar-btn" onClick={() => handleExport(0)}>
|
||||
🕒 导出今日
|
||||
</button>
|
||||
<button className="toolbar-btn" onClick={() => handleExport(1)}>
|
||||
📅 导出昨日
|
||||
</button>
|
||||
<button className="toolbar-btn" onClick={() => handleExport(7)}>
|
||||
📅 导出近7天
|
||||
</button>
|
||||
<button className="toolbar-btn" onClick={() => handleExport(30)}>
|
||||
📅 导出近30天
|
||||
</button>
|
||||
<button className="toolbar-btn" onClick={() => setShowSettingsModal(true)}>
|
||||
🤖 AI总结群聊
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 加载模态框 */}
|
||||
{isLoading && (
|
||||
<div className="modal-overlay">
|
||||
<div className="modal-content" style={{ textAlign: 'center', minWidth: '200px' }}>
|
||||
<div style={{ fontSize: '40px', marginBottom: '20px' }}>🤖</div>
|
||||
<div style={{ fontSize: '16px', color: '#333' }}>正在生成群聊日报...</div>
|
||||
<div style={{ fontSize: '12px', color: '#999', marginTop: '10px' }}>
|
||||
正在分析记录、处理头像并生成 HTML 和长图
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 图片预览模态框 */}
|
||||
{generatedImage && (
|
||||
<div className="modal-overlay" onClick={() => setGeneratedImage(null)}>
|
||||
<div className="modal-content image-preview-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="report-preview-frame">
|
||||
<div className="report-preview-scroller">
|
||||
<img
|
||||
src={generatedImage}
|
||||
alt="Generated Summary"
|
||||
className="report-preview-image"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="report-preview-actions">
|
||||
<button
|
||||
onClick={handleCopyImage}
|
||||
style={{
|
||||
padding: '8px 15px',
|
||||
cursor: 'pointer',
|
||||
backgroundColor: '#4CAF50',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px'
|
||||
}}
|
||||
>
|
||||
📋 复制图片
|
||||
</button>
|
||||
{reportPaths && (
|
||||
<button
|
||||
onClick={() => window.api.revealGroupReport(reportPaths.pngPath)}
|
||||
style={{ padding: '5px 10px', cursor: 'pointer' }}
|
||||
>
|
||||
在文件夹中显示
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setGeneratedImage(null)}
|
||||
style={{ padding: '5px 10px', cursor: 'pointer' }}
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<ChatHeader
|
||||
contact={contact}
|
||||
isGroupChat={isGroupChat}
|
||||
dateRangeLabel={dateRangeLabel}
|
||||
loadedCount={messages.length}
|
||||
filteredCount={filteredMessages.length}
|
||||
contentFilter={contentFilter || ''}
|
||||
isAiLoading={isAiLoading}
|
||||
canExport={messages.length > 0}
|
||||
onContentFilterChange={onContentFilterChange || (() => undefined)}
|
||||
onRefresh={onRefresh}
|
||||
onRefreshData={onRefreshData}
|
||||
onExport={handleExport}
|
||||
onOpenAiSettings={onCreateGroupReport || (() => undefined)}
|
||||
/>
|
||||
<DataTrustBar messageCount={messages.length} />
|
||||
<MessageList
|
||||
contact={contact}
|
||||
messages={renderedMessages}
|
||||
hiddenMessageCount={hiddenMessageCount}
|
||||
isLoadingMessages={isLoadingMessages}
|
||||
isGroupChat={isGroupChat}
|
||||
showAvatar={showAvatar}
|
||||
listRef={messageListRef}
|
||||
bottomRef={messagesEndRef}
|
||||
onScroll={handleMessageListScroll}
|
||||
onImageClick={openImagePreview}
|
||||
/>
|
||||
<ChatStatusBar
|
||||
count={renderedMessages.length}
|
||||
showAvatar={showAvatar}
|
||||
isAtLatest={isAtLatest}
|
||||
onShowAvatarChange={setShowAvatar}
|
||||
onJumpToLatest={scrollToBottom}
|
||||
/>
|
||||
|
||||
{previewImage && (
|
||||
<div className="image-viewer-overlay" onClick={closeImagePreview}>
|
||||
@@ -515,6 +319,7 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
ref={imageViewerStageRef}
|
||||
className="image-viewer-stage"
|
||||
onWheel={handleViewerWheel}
|
||||
onMouseDown={handleViewerMouseDown}
|
||||
@@ -534,126 +339,6 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AI Settings Modal */}
|
||||
{showSettingsModal && (
|
||||
<div className="modal-overlay" onClick={() => setShowSettingsModal(false)}>
|
||||
<div className="modal-content ai-settings-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<h3>AI 设置</h3>
|
||||
<div className="ai-filter-section">
|
||||
<div className="ai-filter-label">时间范围</div>
|
||||
<div className="ai-date-options">
|
||||
{SUMMARY_DATE_OPTIONS.map((option) => (
|
||||
<label
|
||||
key={option.value}
|
||||
className={summaryDateRange === option.value ? 'selected' : ''}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="summary-date-range"
|
||||
value={option.value}
|
||||
checked={summaryDateRange === option.value}
|
||||
onChange={() => setSummaryDateRange(option.value)}
|
||||
/>
|
||||
{option.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ai-filter-section">
|
||||
<div className="ai-filter-label">报告模式</div>
|
||||
<div className="ai-date-options">
|
||||
<label className={reportMode === 'compact' ? 'selected' : ''}>
|
||||
<input
|
||||
type="radio"
|
||||
name="report-mode"
|
||||
value="compact"
|
||||
checked={reportMode === 'compact'}
|
||||
onChange={() => setReportMode('compact')}
|
||||
/>
|
||||
精简版(推荐)
|
||||
</label>
|
||||
<label className={reportMode === 'full' ? 'selected' : ''}>
|
||||
<input
|
||||
type="radio"
|
||||
name="report-mode"
|
||||
value="full"
|
||||
checked={reportMode === 'full'}
|
||||
onChange={() => setReportMode('full')}
|
||||
/>
|
||||
完整版
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ai-filter-section">
|
||||
<div className="ai-filter-label">消息类型</div>
|
||||
<div className="ai-type-options">
|
||||
{SUMMARY_TYPE_OPTIONS.map((option) => (
|
||||
<label key={option.value}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={summaryMessageTypes.includes(option.value)}
|
||||
onChange={() => toggleSummaryMessageType(option.value)}
|
||||
/>
|
||||
{option.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group" style={{ marginBottom: '15px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '5px' }}>模型服务:</label>
|
||||
<select
|
||||
value={model}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
style={{ width: '100%', padding: '8px' }}
|
||||
>
|
||||
<option value="deepseek-v4-pro">DeepSeek V4 Pro</option>
|
||||
<option value="deepseek-v4-flash">DeepSeek V4 Flash</option>
|
||||
<option value="gpt-4o">GPT-4o</option>
|
||||
<option value="gpt-4o-mini">GPT-4o Mini</option>
|
||||
<option value="gpt-4-turbo">GPT-4 Turbo</option>
|
||||
<option value="claude-3-5-sonnet-20240620">Claude 3.5 Sonnet</option>
|
||||
<option value="moonshot-v1-8k">Moonshot V1</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group" style={{ marginBottom: '15px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '5px' }}>Base URL:</label>
|
||||
<input
|
||||
type="text"
|
||||
value={baseURL}
|
||||
onChange={(e) => setBaseURL(e.target.value)}
|
||||
placeholder="https://api.deepseek.com"
|
||||
style={{ width: '95%', padding: '8px' }}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group" style={{ marginBottom: '20px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '5px' }}>API Key:</label>
|
||||
<input
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
placeholder="Enter your API Key"
|
||||
style={{ width: '95%', padding: '8px' }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '10px' }}>
|
||||
<button onClick={() => setShowSettingsModal(false)}>取消</button>
|
||||
<button
|
||||
onClick={handleSaveSettings}
|
||||
style={{
|
||||
backgroundColor: '#4CAF50',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
padding: '8px 15px',
|
||||
borderRadius: '4px'
|
||||
}}
|
||||
>
|
||||
生成总结
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Contact } from '../../../../shared/types'
|
||||
import {
|
||||
AiModelConfig,
|
||||
RangeMessageState,
|
||||
ReportMemberNamePreference,
|
||||
ReportGenerationPhase,
|
||||
ReportPaths
|
||||
} from '../../hooks/useGroupReportGeneration'
|
||||
@@ -11,7 +12,9 @@ import { MessageTypeSelector } from './MessageTypeSelector'
|
||||
import { ModelSummary } from './ModelSummary'
|
||||
import { ReportDensitySelector } from './ReportDensitySelector'
|
||||
import { ReportRangeSelector } from './ReportRangeSelector'
|
||||
import { ReportMemberNameSelector } from './ReportMemberNameSelector'
|
||||
import { ReportSectionSelector } from './ReportSectionSelector'
|
||||
import { ReportTemplateId, ReportTemplateSelector } from './ReportTemplateSelector'
|
||||
|
||||
interface AiReportWorkspaceProps {
|
||||
sourceContact: Contact | null
|
||||
@@ -36,6 +39,12 @@ interface AiReportWorkspaceProps {
|
||||
onRevealReport: () => Promise<{ success: boolean; error?: string }>
|
||||
onViewResult: () => void
|
||||
hasReportResult: boolean
|
||||
templateId: ReportTemplateId
|
||||
onTemplateIdChange: (value: ReportTemplateId) => void
|
||||
memberNamePreference: ReportMemberNamePreference
|
||||
onMemberNamePreferenceChange: (value: ReportMemberNamePreference) => void
|
||||
reportTimeoutSeconds: number
|
||||
onReportTimeoutSecondsChange: (value: number) => void
|
||||
}
|
||||
|
||||
const rangeLabel = (range: SummaryDateRange): string => {
|
||||
@@ -76,7 +85,13 @@ export function AiReportWorkspace({
|
||||
onCopyImage,
|
||||
onRevealReport,
|
||||
onViewResult,
|
||||
hasReportResult
|
||||
hasReportResult,
|
||||
templateId,
|
||||
onTemplateIdChange,
|
||||
memberNamePreference,
|
||||
onMemberNamePreferenceChange,
|
||||
reportTimeoutSeconds,
|
||||
onReportTimeoutSecondsChange
|
||||
}: AiReportWorkspaceProps): React.ReactElement {
|
||||
const [actionStatus, setActionStatus] = useState('')
|
||||
const groupName = sourceContact?.m_nsNickName || sourceContact?.m_nsUsrName || '未选择群聊'
|
||||
@@ -144,7 +159,35 @@ export function AiReportWorkspace({
|
||||
/>
|
||||
<ReportSectionSelector />
|
||||
<ReportDensitySelector />
|
||||
<ReportTemplateSelector
|
||||
value={templateId}
|
||||
onChange={onTemplateIdChange}
|
||||
disabled={configDisabled}
|
||||
/>
|
||||
<ReportMemberNameSelector
|
||||
value={memberNamePreference}
|
||||
onChange={onMemberNamePreferenceChange}
|
||||
disabled={configDisabled}
|
||||
/>
|
||||
<ModelSummary config={modelConfig} onOpenSettings={onOpenModelSettings} />
|
||||
<section className="report-config-section report-timeout-section">
|
||||
<div>
|
||||
<h3>日报生成超时</h3>
|
||||
<p>大群聊和图片识别耗时较长,可单独设置本次日报最多等待时间。</p>
|
||||
</div>
|
||||
<label>
|
||||
<input
|
||||
type="number"
|
||||
min={30}
|
||||
max={1800}
|
||||
step={30}
|
||||
value={reportTimeoutSeconds}
|
||||
disabled={configDisabled}
|
||||
onChange={(event) => onReportTimeoutSecondsChange(Number(event.target.value))}
|
||||
/>
|
||||
<span>秒</span>
|
||||
</label>
|
||||
</section>
|
||||
<section className="report-privacy-note">
|
||||
<h3>隐私说明</h3>
|
||||
<p>微信数据库和聊天记录默认从本机读取。</p>
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import React from 'react'
|
||||
import { ReportMemberNamePreference } from '../../hooks/useGroupReportGeneration'
|
||||
|
||||
const OPTIONS: {
|
||||
value: ReportMemberNamePreference
|
||||
label: string
|
||||
description: string
|
||||
}[] = [
|
||||
{
|
||||
value: 'groupNickname',
|
||||
label: '群昵称',
|
||||
description: '优先使用成员在当前群设置的昵称。'
|
||||
},
|
||||
{
|
||||
value: 'wechatNickname',
|
||||
label: '微信昵称',
|
||||
description: '优先使用对方公开的微信昵称。'
|
||||
},
|
||||
{
|
||||
value: 'remark',
|
||||
label: '通讯录备注',
|
||||
description: '优先使用你为对方设置的备注。'
|
||||
}
|
||||
]
|
||||
|
||||
export function ReportMemberNameSelector({
|
||||
value,
|
||||
disabled,
|
||||
onChange
|
||||
}: {
|
||||
value: ReportMemberNamePreference
|
||||
disabled?: boolean
|
||||
onChange: (value: ReportMemberNamePreference) => void
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<section className="report-section">
|
||||
<h3>成员名称</h3>
|
||||
<p className="report-section-desc">选择日报中群成员的显示名称,默认使用群昵称。</p>
|
||||
<div className="report-template-list">
|
||||
{OPTIONS.map((option) => (
|
||||
<label
|
||||
className={`report-template-item ${value === option.value ? 'active' : ''} ${disabled ? 'disabled' : ''}`}
|
||||
key={option.value}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="report-member-name"
|
||||
checked={value === option.value}
|
||||
disabled={disabled}
|
||||
onChange={() => onChange(option.value)}
|
||||
/>
|
||||
<div className="report-template-body">
|
||||
<div className="report-template-title">{option.label}</div>
|
||||
<div className="report-template-tagline">{option.description}</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import React, { useState } from 'react'
|
||||
|
||||
export type ReportTemplateId = 'v1' | 'v2'
|
||||
|
||||
interface TemplateMeta {
|
||||
id: ReportTemplateId
|
||||
label: string
|
||||
tagline: string
|
||||
preview: { title: string; sections: string[] }
|
||||
}
|
||||
|
||||
const TEMPLATES: TemplateMeta[] = [
|
||||
{
|
||||
id: 'v1',
|
||||
label: '模板1 · 经典日报',
|
||||
tagline: '实用信息 / 重要消息 / 金句 / 问答 / 数据可视化',
|
||||
preview: {
|
||||
title: '经典日报',
|
||||
sections: [
|
||||
'今日讨论热点',
|
||||
'AI 识别的图片精选',
|
||||
'实用信息与资源',
|
||||
'重要消息汇总',
|
||||
'有趣对话或金句',
|
||||
'问题与解答',
|
||||
'群内数据可视化',
|
||||
'词云/关键词'
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'v2',
|
||||
label: '模板2 · 支持图片板块',
|
||||
tagline: '包含热点图片与上下文;需要模型服务商支持图片识别',
|
||||
preview: {
|
||||
title: '支持图片板块',
|
||||
sections: [
|
||||
'今日讨论热点',
|
||||
'重要消息',
|
||||
'待办事项和未解决问题',
|
||||
'今日名场面',
|
||||
'今日群数据',
|
||||
'关键词',
|
||||
'实用信息与资源',
|
||||
'问题与解答',
|
||||
'今日剧情时间线',
|
||||
'群聊反转现场',
|
||||
'AI 识别的图片精选',
|
||||
'今日群相册',
|
||||
'语音之最',
|
||||
'语音时长榜',
|
||||
'今日临时人设',
|
||||
'话题参与链路'
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
interface ReportTemplateSelectorProps {
|
||||
value: ReportTemplateId
|
||||
onChange: (value: ReportTemplateId) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export const ReportTemplateSelector: React.FC<ReportTemplateSelectorProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
disabled
|
||||
}) => {
|
||||
const [previewing, setPreviewing] = useState<TemplateMeta | null>(null)
|
||||
return (
|
||||
<section className="report-section">
|
||||
<h3>日报模板</h3>
|
||||
<p className="report-section-desc">
|
||||
选择日报呈现风格。模板2 支持图片板块,但需要 AI 模型服务商支持图片识别。
|
||||
</p>
|
||||
<div className="report-template-list">
|
||||
{TEMPLATES.map((tpl) => {
|
||||
const active = value === tpl.id
|
||||
return (
|
||||
<div
|
||||
key={tpl.id}
|
||||
className={`report-template-item ${active ? 'active' : ''} ${disabled ? 'disabled' : ''}`}
|
||||
>
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name="report-template"
|
||||
value={tpl.id}
|
||||
checked={active}
|
||||
disabled={disabled}
|
||||
onChange={() => onChange(tpl.id)}
|
||||
/>
|
||||
<div className="report-template-body">
|
||||
<div className="report-template-title">{tpl.label}</div>
|
||||
<div className="report-template-tagline">{tpl.tagline}</div>
|
||||
</div>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="report-template-preview-btn"
|
||||
onClick={() => setPreviewing(tpl)}
|
||||
>
|
||||
预览
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{previewing && (
|
||||
<div className="report-template-preview-mask" onClick={() => setPreviewing(null)}>
|
||||
<div className="report-template-preview-card" onClick={(e) => e.stopPropagation()}>
|
||||
<h4>{previewing.preview.title}</h4>
|
||||
<p className="muted">{previewing.tagline}</p>
|
||||
<div className="report-template-preview-frame">
|
||||
<div className="fake-card fake-hero">
|
||||
<div className="fake-title">群聊日报 · 预览</div>
|
||||
<div className="fake-sub">2026-xx-xx · 基于已加载记录</div>
|
||||
</div>
|
||||
{previewing.preview.sections.map((s) => (
|
||||
<div className="fake-card fake-section" key={s}>
|
||||
<div className="fake-bar" />
|
||||
<div className="fake-section-title">{s}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="report-template-preview-close"
|
||||
onClick={() => setPreviewing(null)}
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -174,13 +174,31 @@ export function AIProviderEditor({
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={model.capabilities.vision}
|
||||
onChange={(event) => {
|
||||
const vision = event.target.checked
|
||||
// OCR 是 vision 的派生能力:勾 vision 时自动带 OCR
|
||||
patchModel(index, {
|
||||
capabilities: {
|
||||
...model.capabilities,
|
||||
vision,
|
||||
ocr: vision ? true : model.capabilities.ocr
|
||||
}
|
||||
})
|
||||
}}
|
||||
/>
|
||||
图片理解
|
||||
</label>
|
||||
<label title="图片文字识别,跟随图片理解能力">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={model.capabilities.ocr}
|
||||
onChange={(event) =>
|
||||
patchModel(index, {
|
||||
capabilities: { ...model.capabilities, vision: event.target.checked }
|
||||
capabilities: { ...model.capabilities, ocr: event.target.checked }
|
||||
})
|
||||
}
|
||||
/>
|
||||
图片理解
|
||||
图片文字识别
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
@@ -296,5 +314,5 @@ export function AIProviderEditor({
|
||||
}
|
||||
|
||||
function emptyModel(): AIModelDefinition {
|
||||
return { name: '', id: '', capabilities: { chat: true, vision: false, longContext: false } }
|
||||
return { name: '', id: '', capabilities: { chat: true, vision: false, ocr: false, longContext: false } }
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ export function createProviderFromPreset(presetId = 'deepseek'): AIProviderConfi
|
||||
{
|
||||
name: preset.model || '默认模型',
|
||||
id: preset.model,
|
||||
capabilities: { chat: true, vision: false, longContext: false }
|
||||
capabilities: { chat: true, vision: false, ocr: false, longContext: false }
|
||||
}
|
||||
],
|
||||
defaultModel: preset.model,
|
||||
|
||||
@@ -10,8 +10,10 @@ import {
|
||||
SummaryDateRange,
|
||||
SummaryMessageType
|
||||
} from '../utils/group-report'
|
||||
import { ReportTemplateId } from '../components/reports/ReportTemplateSelector'
|
||||
|
||||
const REPORT_STEP_TIMEOUT_MS = 90_000
|
||||
const REPORT_MODEL_TIMEOUT_BUFFER_MS = 10_000
|
||||
|
||||
export type ReportGenerationPhase =
|
||||
| 'idle'
|
||||
@@ -29,8 +31,11 @@ export interface AiModelConfig {
|
||||
modelName: string
|
||||
configured: boolean
|
||||
status: 'untested' | 'connected' | 'error'
|
||||
timeoutMs?: number
|
||||
}
|
||||
|
||||
export type ReportMemberNamePreference = 'groupNickname' | 'wechatNickname' | 'remark'
|
||||
|
||||
export interface ReportPaths {
|
||||
htmlPath: string
|
||||
pngPath: string
|
||||
@@ -84,10 +89,14 @@ export interface RangeMessageState {
|
||||
error: string
|
||||
}
|
||||
|
||||
const withTimeout = async <T>(promise: Promise<T>, label: string): Promise<T> => {
|
||||
const withTimeout = async <T>(
|
||||
promise: Promise<T>,
|
||||
label: string,
|
||||
timeoutMs = REPORT_STEP_TIMEOUT_MS
|
||||
): Promise<T> => {
|
||||
let timer: number | undefined
|
||||
const timeout = new Promise<never>((_, reject) => {
|
||||
timer = window.setTimeout(() => reject(new Error(`${label} 超时`)), REPORT_STEP_TIMEOUT_MS)
|
||||
timer = window.setTimeout(() => reject(new Error(`${label} 超时`)), timeoutMs)
|
||||
})
|
||||
try {
|
||||
return await Promise.race([promise, timeout])
|
||||
@@ -127,14 +136,33 @@ const estimateTokenUsage = (
|
||||
}
|
||||
}
|
||||
|
||||
const applyGroupMemberNames = async (contact: Contact, messages: Message[]): Promise<Message[]> => {
|
||||
let memberMap = new Map<string, { nickname: string; avatar: string }>()
|
||||
const applyGroupMemberNames = async (
|
||||
contact: Contact,
|
||||
messages: Message[],
|
||||
preference: ReportMemberNamePreference
|
||||
): Promise<Message[]> => {
|
||||
let memberMap = new Map<
|
||||
string,
|
||||
{
|
||||
nickname: string
|
||||
groupNickname: string
|
||||
wechatNickname: string
|
||||
remark: string
|
||||
avatar: string
|
||||
}
|
||||
>()
|
||||
try {
|
||||
const snapshot = await withTimeout(window.api.getGroupSnapshot(contact.md5), '读取群成员')
|
||||
memberMap = new Map(
|
||||
(snapshot?.members || []).map((member) => [
|
||||
member.wxid,
|
||||
{ nickname: member.nickname || member.wxid, avatar: member.avatar || '' }
|
||||
{
|
||||
nickname: member.nickname || member.wxid,
|
||||
groupNickname: member.groupNickname || '',
|
||||
wechatNickname: member.wechatNickname || '',
|
||||
remark: member.remark || '',
|
||||
avatar: member.avatar || ''
|
||||
}
|
||||
])
|
||||
)
|
||||
} catch (error) {
|
||||
@@ -143,11 +171,17 @@ const applyGroupMemberNames = async (contact: Contact, messages: Message[]): Pro
|
||||
|
||||
if (!memberMap.size) return messages
|
||||
return messages.map((message) => {
|
||||
if (!isInternalName(message.name)) return message
|
||||
const senderId = String(message.senderId || message.name || '')
|
||||
const member = memberMap.get(senderId)
|
||||
if (!member?.nickname || isInternalName(member.nickname)) return message
|
||||
return { ...message, name: member.nickname, img: message.img || member.avatar }
|
||||
if (!member) return message
|
||||
const preferredNames: Record<ReportMemberNamePreference, string[]> = {
|
||||
groupNickname: [member.groupNickname, member.wechatNickname, member.remark, member.nickname],
|
||||
wechatNickname: [member.wechatNickname, member.groupNickname, member.remark, member.nickname],
|
||||
remark: [member.remark, member.groupNickname, member.wechatNickname, member.nickname]
|
||||
}
|
||||
const name = preferredNames[preference].find((value) => value && !isInternalName(value))
|
||||
if (!name) return message
|
||||
return { ...message, name, img: message.img || member.avatar }
|
||||
})
|
||||
}
|
||||
|
||||
@@ -174,6 +208,12 @@ export function useGroupReportGeneration({
|
||||
closeResult: () => void
|
||||
copyImage: () => Promise<{ success: boolean; error?: string }>
|
||||
revealReport: () => Promise<{ success: boolean; error?: string }>
|
||||
templateId: ReportTemplateId
|
||||
setTemplateId: (value: ReportTemplateId) => void
|
||||
memberNamePreference: ReportMemberNamePreference
|
||||
setMemberNamePreference: (value: ReportMemberNamePreference) => void
|
||||
reportTimeoutSeconds: number
|
||||
setReportTimeoutSeconds: (value: number) => void
|
||||
} {
|
||||
const [phase, setPhase] = useState<ReportGenerationPhase>('idle')
|
||||
const [error, setError] = useState('')
|
||||
@@ -181,11 +221,31 @@ export function useGroupReportGeneration({
|
||||
const [rangeState, setRangeState] = useState<RangeMessageState>({ status: 'idle', error: '' })
|
||||
const [generatedImage, setGeneratedImage] = useState<string | null>(null)
|
||||
const [reportPaths, setReportPaths] = useState<ReportPaths | null>(null)
|
||||
const [templateId, setTemplateId] = useState<ReportTemplateId>('v1')
|
||||
const [memberNamePreference, setMemberNamePreferenceState] =
|
||||
useState<ReportMemberNamePreference>(() => {
|
||||
const saved = localStorage.getItem('group_report_member_name_preference')
|
||||
return saved === 'wechatNickname' || saved === 'remark' ? saved : 'groupNickname'
|
||||
})
|
||||
const [reportTimeoutSeconds, setReportTimeoutSecondsState] = useState<number>(() => {
|
||||
const saved = Number(localStorage.getItem('group_report_timeout_seconds'))
|
||||
return Number.isFinite(saved) && saved >= 30 ? saved : 300
|
||||
})
|
||||
const [generationMetadata, setGenerationMetadata] = useState<ReportGenerationMetadata>({
|
||||
generationLogs: []
|
||||
})
|
||||
const rangeRequestIdRef = useRef(0)
|
||||
|
||||
const setMemberNamePreference = useCallback((value: ReportMemberNamePreference): void => {
|
||||
localStorage.setItem('group_report_member_name_preference', value)
|
||||
setMemberNamePreferenceState(value)
|
||||
}, [])
|
||||
const setReportTimeoutSeconds = useCallback((value: number): void => {
|
||||
const normalized = Math.max(30, Math.min(1800, Math.round(Number(value) || 300)))
|
||||
localStorage.setItem('group_report_timeout_seconds', String(normalized))
|
||||
setReportTimeoutSecondsState(normalized)
|
||||
}, [])
|
||||
|
||||
const isGenerating =
|
||||
phase === 'loadingMessages' ||
|
||||
phase === 'preparingInput' ||
|
||||
@@ -332,8 +392,12 @@ export function useGroupReportGeneration({
|
||||
|
||||
setPhase('preparingInput')
|
||||
const input = await trackStep('整理输入', async () => {
|
||||
const namedReportMessages = await applyGroupMemberNames(sourceContact, filteredMessages)
|
||||
return buildGroupReportInput(namedReportMessages, sourceContact, true)
|
||||
const namedReportMessages = await applyGroupMemberNames(
|
||||
sourceContact,
|
||||
filteredMessages,
|
||||
memberNamePreference
|
||||
)
|
||||
return buildGroupReportInput(namedReportMessages, sourceContact, true, 'full')
|
||||
})
|
||||
|
||||
setPhase('requestingModel')
|
||||
@@ -345,9 +409,11 @@ export function useGroupReportGeneration({
|
||||
withTimeout(
|
||||
window.api.aiChat(aiMessages, {
|
||||
providerId: modelConfig.providerId,
|
||||
modelId: modelConfig.model
|
||||
modelId: modelConfig.model,
|
||||
timeoutMs: reportTimeoutSeconds * 1000
|
||||
}),
|
||||
'AI 生成日报'
|
||||
'AI 生成日报',
|
||||
reportTimeoutSeconds * 1000 + REPORT_MODEL_TIMEOUT_BUFFER_MS
|
||||
)
|
||||
)
|
||||
if (!result.success || !result.data) throw new Error(result.error || 'AI 请求失败')
|
||||
@@ -357,11 +423,18 @@ export function useGroupReportGeneration({
|
||||
? result.usage
|
||||
: estimateTokenUsage(aiMessages, result.data)
|
||||
|
||||
const report = parseGroupDailyReport(result.data, input.topSpeakers, input.activeTimeline)
|
||||
const report = parseGroupDailyReport(
|
||||
result.data,
|
||||
input.topSpeakers,
|
||||
input.activeTimeline,
|
||||
input.voiceLeaderboard || [],
|
||||
input.metadata,
|
||||
input.media
|
||||
)
|
||||
|
||||
setPhase('exportingReport')
|
||||
const exported = await withTimeout(
|
||||
window.api.exportGroupReport({ report, metadata: input.metadata }),
|
||||
window.api.exportGroupReport({ report, metadata: input.metadata, templateId }),
|
||||
'日报图片导出'
|
||||
)
|
||||
if (!exported.success || !exported.imageDataUrl || !exported.htmlPath || !exported.pngPath) {
|
||||
@@ -399,7 +472,16 @@ export function useGroupReportGeneration({
|
||||
setError(errorMessage(generateError))
|
||||
setPhase('error')
|
||||
}
|
||||
}, [isGenerating, loadRangeMessages, modelConfig, sourceContact, summaryMessageTypes])
|
||||
}, [
|
||||
isGenerating,
|
||||
loadRangeMessages,
|
||||
memberNamePreference,
|
||||
modelConfig,
|
||||
reportTimeoutSeconds,
|
||||
sourceContact,
|
||||
summaryMessageTypes,
|
||||
templateId
|
||||
])
|
||||
|
||||
const clearError = useCallback((): void => {
|
||||
setError('')
|
||||
@@ -437,6 +519,12 @@ export function useGroupReportGeneration({
|
||||
clearError,
|
||||
closeResult,
|
||||
copyImage,
|
||||
revealReport
|
||||
revealReport,
|
||||
templateId,
|
||||
setTemplateId,
|
||||
memberNamePreference,
|
||||
setMemberNamePreference,
|
||||
reportTimeoutSeconds,
|
||||
setReportTimeoutSeconds
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
ReportMediaGalleryItem,
|
||||
ReportMode,
|
||||
ReportSpeakerRank,
|
||||
ReportVisionGalleryItem,
|
||||
ReportVoiceHighlight,
|
||||
ReportVoiceLeaderboardItem
|
||||
} from '../../../shared/group-report'
|
||||
@@ -32,7 +33,11 @@ export interface GroupReportFactsSnapshot {
|
||||
export const isInternalIdentifier = (value: string): boolean =>
|
||||
/@chatroom$/i.test(value) || /^wxid_/i.test(value) || /^[a-z0-9_-]{18,}$/i.test(value)
|
||||
|
||||
export const summarySender = (message: Message, contact: Contact | null, isGroup: boolean): string => {
|
||||
export const summarySender = (
|
||||
message: Message,
|
||||
contact: Contact | null,
|
||||
isGroup: boolean
|
||||
): string => {
|
||||
if (message.from === 'assistant') {
|
||||
const ownGroupNickname = message.name?.trim()
|
||||
if (isGroup && ownGroupNickname && !isInternalIdentifier(ownGroupNickname)) {
|
||||
@@ -176,7 +181,9 @@ const buildMediaSection = async (
|
||||
): Promise<{
|
||||
media: GroupDailyReport['media']
|
||||
voiceLeaderboard: ReportVoiceLeaderboardItem[]
|
||||
warnings: string[]
|
||||
}> => {
|
||||
const warnings: string[] = []
|
||||
const rawImageCandidates = messages
|
||||
.map((message, index) => {
|
||||
if (message.contentData?.type !== 'image') return null
|
||||
@@ -192,6 +199,7 @@ const buildMediaSection = async (
|
||||
note: context.note,
|
||||
stats: context.stats,
|
||||
replyCount: context.responseCount,
|
||||
participantCount: context.participantCount,
|
||||
score: context.responseCount * 3 + context.participantCount * 2 + 1
|
||||
}
|
||||
})
|
||||
@@ -199,6 +207,122 @@ const buildMediaSection = async (
|
||||
.sort((left, right) => right.score - left.score)
|
||||
.slice(0, 6)
|
||||
|
||||
// ============================================================
|
||||
// AI 图片理解(ImageInsightService 接入)
|
||||
// 通过 main 进程拿 Top 3 热点图 + 已缓存的 Insight;未缓存的并发调 AI
|
||||
// 失败不阻塞:任何错误只记日志,降级到原 gallery
|
||||
// ============================================================
|
||||
let visionGallery: ReportVisionGalleryItem[] = []
|
||||
try {
|
||||
const sessionId = messages.find((m) => m.sessionId)?.sessionId || (contact?.md5 ?? '')
|
||||
const startTime = messages.length ? parseTimestamp(messages[0]) : 0
|
||||
const endTime = messages.length ? parseTimestamp(messages[messages.length - 1]) : 0
|
||||
|
||||
// 从 renderer 已加载的消息中提取图片候选(复用 buildImageContext 已算的 replyCount)
|
||||
const imageInputs = rawImageCandidates.map((c) => {
|
||||
const srcMsg = messages.find((m) => m.id === c.sourceMessageIds[0]) || messages[0]
|
||||
return {
|
||||
messageId: c.sourceMessageIds[0] || '',
|
||||
md5: c.md5,
|
||||
datName: c.datName,
|
||||
sessionId: c.sessionId || sessionId,
|
||||
sender: c.sender,
|
||||
sentAt: parseTimestamp(srcMsg),
|
||||
responseCount: c.replyCount || 0,
|
||||
interactionCount: c.participantCount || 0
|
||||
}
|
||||
})
|
||||
|
||||
const candidatesResp = await window.api.imageListCandidates({
|
||||
sessionId,
|
||||
startTime,
|
||||
endTime,
|
||||
limit: 3,
|
||||
inputs: imageInputs
|
||||
})
|
||||
const candidates = candidatesResp.success ? candidatesResp.candidates : []
|
||||
console.log('[buildMediaSection] imageListCandidates returned', candidates.length, 'candidates')
|
||||
|
||||
// 对每个候选:缓存命中直接用,未命中并发调 imageAnalyze
|
||||
const analyzed = await Promise.all(
|
||||
candidates.map(async (candidate) => {
|
||||
if (candidate.insight) return candidate.insight
|
||||
// 未命中:解密图片拿 base64 → 调 AI
|
||||
try {
|
||||
const img = await window.api.getImage(
|
||||
candidate.md5,
|
||||
candidate.datName,
|
||||
candidate.sessionId
|
||||
)
|
||||
if (!img.success || !img.data) {
|
||||
warnings.push(
|
||||
`${candidate.sender} ${localTime(candidate.sentAt)} 的图片读取失败:${img.error || '未知错误'}`
|
||||
)
|
||||
return null
|
||||
}
|
||||
const analyzeResp = await window.api.imageAnalyze({
|
||||
imageHash: candidate.imageHash,
|
||||
imageDataUrl: img.data,
|
||||
messageId: candidate.messageId,
|
||||
sender: candidate.sender,
|
||||
sentAt: candidate.sentAt,
|
||||
sessionId: candidate.sessionId,
|
||||
force: false
|
||||
})
|
||||
if (!analyzeResp.success || !analyzeResp.insight) {
|
||||
warnings.push(
|
||||
`${candidate.sender} ${localTime(candidate.sentAt)} 的图片识别失败:${analyzeResp.error || '模型未返回识别结果'}`
|
||||
)
|
||||
return null
|
||||
}
|
||||
return analyzeResp.insight
|
||||
} catch (error) {
|
||||
console.warn('[buildMediaSection] image analyze failed:', error)
|
||||
warnings.push(
|
||||
`${candidate.sender} ${localTime(candidate.sentAt)} 的图片识别异常:${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
return null
|
||||
}
|
||||
})
|
||||
)
|
||||
visionGallery = analyzed
|
||||
.filter((it): it is NonNullable<typeof it> => Boolean(it))
|
||||
.map((it) => ({
|
||||
messageId: it.messageId,
|
||||
imageHash: it.imageHash,
|
||||
sender: it.sender,
|
||||
time: localTime(it.sentAt),
|
||||
description: it.description,
|
||||
ocrText: it.ocrText,
|
||||
tags: it.tags,
|
||||
category: it.category,
|
||||
importance: it.importance,
|
||||
sourceMessageIds: [it.messageId]
|
||||
}))
|
||||
// 为 visionGallery 加载原图 dataUrl(给 main 渲染用,不暴露给 LLM)
|
||||
if (visionGallery.length) {
|
||||
visionGallery = await Promise.all(
|
||||
visionGallery.map(async (item) => {
|
||||
const orig = rawImageCandidates.find((c) => c.sourceMessageIds[0] === item.messageId)
|
||||
if (!orig) return item
|
||||
try {
|
||||
const img = await window.api.getImage(orig.md5, orig.datName, orig.sessionId)
|
||||
if (img.success && img.data?.startsWith('data:image/')) {
|
||||
return { ...item, imageUrl: img.data }
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[buildMediaSection] preload image failed for', item.messageId, error)
|
||||
}
|
||||
return item
|
||||
})
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[buildMediaSection] vision flow failed, fallback to empty:', error)
|
||||
warnings.push(`图片识别流程失败:${error instanceof Error ? error.message : String(error)}`)
|
||||
visionGallery = []
|
||||
}
|
||||
|
||||
const imageCandidates = await Promise.all(
|
||||
rawImageCandidates.map(async (item) => {
|
||||
const result = await window.api.getImage(item.md5, item.datName, item.sessionId)
|
||||
@@ -221,7 +345,16 @@ const buildMediaSection = async (
|
||||
.filter((item): item is NonNullable<typeof item> => Boolean(item))
|
||||
.sort((left, right) => right.score - left.score)
|
||||
.slice(0, 4)
|
||||
.map(({ score: _score, ...item }) => item)
|
||||
.map((item) => ({
|
||||
sender: item.sender,
|
||||
time: item.time,
|
||||
imageUrl: item.imageUrl,
|
||||
note: item.note,
|
||||
stats: item.stats,
|
||||
inferenceLabel: item.inferenceLabel,
|
||||
sourceMessageIds: item.sourceMessageIds,
|
||||
replyCount: item.replyCount
|
||||
}))
|
||||
|
||||
const voiceMessages = messages
|
||||
.filter((message) => message.contentData?.type === 'voice')
|
||||
@@ -286,7 +419,9 @@ const buildMediaSection = async (
|
||||
}
|
||||
|
||||
const funBadges: ReportFunBadge[] = []
|
||||
const topSpeaker = Array.from(topSpeakersMap.entries()).sort((left, right) => right[1] - left[1])[0]
|
||||
const topSpeaker = Array.from(topSpeakersMap.entries()).sort(
|
||||
(left, right) => right[1] - left[1]
|
||||
)[0]
|
||||
if (topSpeaker) {
|
||||
funBadges.push({
|
||||
title: '高能输出王',
|
||||
@@ -312,10 +447,12 @@ const buildMediaSection = async (
|
||||
return {
|
||||
media: {
|
||||
gallery,
|
||||
visionGallery,
|
||||
voiceHighlights: voiceHighlights.slice(0, 2),
|
||||
funBadges: funBadges.slice(0, 3)
|
||||
},
|
||||
voiceLeaderboard
|
||||
voiceLeaderboard,
|
||||
warnings
|
||||
}
|
||||
}
|
||||
|
||||
@@ -330,11 +467,18 @@ const collectQuestionCandidates = (
|
||||
sender: summarySender(message, contact, isGroup),
|
||||
content: summaryContent(message)
|
||||
}))
|
||||
.filter((item) => /[??]$/.test(item.content) || item.content.includes('吗') || item.content.includes('怎么'))
|
||||
.filter(
|
||||
(item) =>
|
||||
/[??]$/.test(item.content) || item.content.includes('吗') || item.content.includes('怎么')
|
||||
)
|
||||
.slice(-6)
|
||||
.map((item) => `${item.sender}(${item.id}):${truncate(item.content, 32)}`)
|
||||
|
||||
const collectReplyFacts = (messages: Message[], contact: Contact | null, isGroup: boolean): string[] =>
|
||||
const collectReplyFacts = (
|
||||
messages: Message[],
|
||||
contact: Contact | null,
|
||||
isGroup: boolean
|
||||
): string[] =>
|
||||
messages
|
||||
.filter((message) => message.contentData?.type === 'quote' && message.contentData.quotedSender)
|
||||
.slice(0, 10)
|
||||
@@ -446,14 +590,35 @@ export const buildGroupReportFacts = async (
|
||||
mediaMessageCount: imageCount + voiceCount + stickerCount,
|
||||
timeSpan,
|
||||
generatedAt: new Date().toLocaleString('zh-CN', { hour12: false }),
|
||||
recordNote: `基于当前已加载的 ${transcriptRows.length} 条记录`,
|
||||
recordNote: `基于 WechatExplorer 已加载的 ${transcriptRows.length} 条记录`,
|
||||
footerNote: '基于已读取聊天记录生成;图片、表情等未解析内容默认只按类型与上下文参与日报。',
|
||||
heroParticipants: topSpeakers.slice(0, 4).map((speaker) => speaker.name),
|
||||
avatars,
|
||||
reportMode
|
||||
}
|
||||
|
||||
const { media, voiceLeaderboard } = await buildMediaSection(messages, contact, isGroup, speakerCounts)
|
||||
const { media, voiceLeaderboard, warnings } = await buildMediaSection(
|
||||
messages,
|
||||
contact,
|
||||
isGroup,
|
||||
speakerCounts
|
||||
)
|
||||
if (warnings.length) metadata.warnings = [...(metadata.warnings || []), ...warnings]
|
||||
if (imageCount > 0 && !media.visionGallery?.length) {
|
||||
metadata.footerNote = `图片识别未成功:${warnings[0] || '当前模型未返回图片理解结果'}。其余内容基于已读取聊天记录生成。`
|
||||
} else if (media.visionGallery?.length) {
|
||||
metadata.footerNote = `基于已读取聊天记录生成;其中 ${media.visionGallery.length} 张图片已由当前视觉模型识别。`
|
||||
}
|
||||
|
||||
if (
|
||||
transcriptRows.length > 0 &&
|
||||
transcriptRows.every((row) => row.content === '[图片]') &&
|
||||
!media.visionGallery?.length
|
||||
) {
|
||||
throw new Error(
|
||||
warnings[0] || '所选记录只有图片,但当前图片均未能识别,请检查图片解密密钥和模型视觉能力'
|
||||
)
|
||||
}
|
||||
|
||||
const factsPrompt = [
|
||||
`报告模式:${reportMode === 'compact' ? '精简版(30秒可读完)' : '完整版(保留更多上下文)'}`,
|
||||
@@ -462,6 +627,15 @@ export const buildGroupReportFacts = async (
|
||||
media.gallery.length
|
||||
? `图片观察:${media.gallery.map((item) => `${item.time} ${item.sender} 发图(${item.stats})`).join(';')}`
|
||||
: '',
|
||||
// AI 图片理解结果(由 ImageInsightService 提供,缓存命中或已调用 Vision)
|
||||
(media.visionGallery?.length ?? 0) > 0
|
||||
? `AI 图片识别摘要:${(media.visionGallery || [])
|
||||
.map(
|
||||
(it) =>
|
||||
`[${it.time} ${it.sender}] ${it.description}${it.ocrText ? `(OCR: ${it.ocrText})` : ''}${it.tags.length ? ` [${it.tags.join('/')}]` : ''}`
|
||||
)
|
||||
.join(';')}`
|
||||
: '',
|
||||
voiceLeaderboard.length
|
||||
? `语音榜:${voiceLeaderboard
|
||||
.slice(0, 3)
|
||||
|
||||
@@ -127,7 +127,15 @@ const REPORT_MODE_CONFIG: Record<ReportMode, ReportModeConfig> = {
|
||||
maxQa: 0,
|
||||
topicSummaryLength: 100,
|
||||
noteLength: 72,
|
||||
enabledSections: ['hero', 'topics', 'importantMessages', 'actions', 'moments', 'analytics', 'keywords']
|
||||
enabledSections: [
|
||||
'hero',
|
||||
'topics',
|
||||
'importantMessages',
|
||||
'actions',
|
||||
'moments',
|
||||
'analytics',
|
||||
'keywords'
|
||||
]
|
||||
},
|
||||
full: {
|
||||
maxTopics: 6,
|
||||
@@ -159,6 +167,7 @@ const REPORT_MODE_CONFIG: Record<ReportMode, ReportModeConfig> = {
|
||||
'qa',
|
||||
'storylines',
|
||||
'reversals',
|
||||
'vision',
|
||||
'gallery',
|
||||
'voices',
|
||||
'badges',
|
||||
@@ -174,6 +183,14 @@ export const buildGroupReportInput = async (
|
||||
reportMode: ReportMode
|
||||
): Promise<GroupReportInput> => {
|
||||
const facts = await buildGroupReportFacts(messages, contact, isGroup, reportMode)
|
||||
const desiredTopicCount =
|
||||
facts.metadata.messageCount >= 1000
|
||||
? '建议提炼 6-8 个互不重复的主要话题'
|
||||
: facts.metadata.messageCount >= 500
|
||||
? '建议提炼 5-6 个互不重复的主要话题'
|
||||
: facts.metadata.messageCount >= 200
|
||||
? '建议提炼 4-5 个互不重复的主要话题'
|
||||
: '按实际内容提炼 1-4 个主要话题,不要为了凑数编造'
|
||||
const transcript = facts.transcriptRows
|
||||
.map((row) => `[${row.id}] ${row.datetime} ${row.sender}:${row.content}`)
|
||||
.join('\n')
|
||||
@@ -185,6 +202,7 @@ export const buildGroupReportInput = async (
|
||||
消息数:${facts.metadata.messageCount}
|
||||
活跃人数:${facts.metadata.activeUsers}
|
||||
目标模式:${REPORT_MODE_LABEL[reportMode]}
|
||||
话题覆盖:${desiredTopicCount}
|
||||
完整性:仅基于当前应用已加载的记录。
|
||||
|
||||
媒体与结构化事实(可引用行为,不可猜测图片内容):
|
||||
@@ -372,7 +390,8 @@ const parseUnresolved = (root: Record<string, unknown>): ReportUnresolvedItem[]
|
||||
owner: item.owner ? normalizeName(item.owner) : undefined,
|
||||
status: normalizedStatus,
|
||||
note: asString(item.note),
|
||||
lastDiscussedAt: item.lastDiscussedAt === null ? null : asNullableString(item.lastDiscussedAt),
|
||||
lastDiscussedAt:
|
||||
item.lastDiscussedAt === null ? null : asNullableString(item.lastDiscussedAt),
|
||||
sourceMessageIds: asStrings(item.sourceMessageIds, 8),
|
||||
importance: asNumber(item.importance),
|
||||
confidence: asNumber(item.confidence)
|
||||
@@ -429,7 +448,8 @@ const parseParticipantChains = (root: Record<string, unknown>): ReportParticipan
|
||||
})
|
||||
.filter((item) => item.topic && item.chain.length)
|
||||
|
||||
const scoreByHeat = (heat: ReportHeat): number => (heat === '高' ? 0.95 : heat === '中' ? 0.75 : 0.55)
|
||||
const scoreByHeat = (heat: ReportHeat): number =>
|
||||
heat === '高' ? 0.95 : heat === '中' ? 0.75 : 0.55
|
||||
|
||||
const scoreByCount = (count: number, max = 5): number => Math.min(1, Math.max(0.3, count / max))
|
||||
|
||||
@@ -483,24 +503,60 @@ const clampTopics = (topics: ReportTopic[], config: ReportModeConfig): ReportTop
|
||||
keywords: topic.keywords.slice(0, 3)
|
||||
}))
|
||||
|
||||
const topicLimitForMessageVolume = (
|
||||
config: ReportModeConfig,
|
||||
messageCount: number
|
||||
): ReportModeConfig => {
|
||||
if (messageCount >= 1000) return { ...config, maxTopics: Math.max(config.maxTopics, 8) }
|
||||
if (messageCount >= 500) return { ...config, maxTopics: Math.max(config.maxTopics, 6) }
|
||||
if (messageCount >= 200) return { ...config, maxTopics: Math.max(config.maxTopics, 5) }
|
||||
if (messageCount >= 80) return { ...config, maxTopics: Math.max(config.maxTopics, 4) }
|
||||
return config
|
||||
}
|
||||
|
||||
const attachHighImpactImage = (
|
||||
topics: ReportTopic[],
|
||||
gallery: GroupDailyReport['media']['gallery']
|
||||
gallery: GroupDailyReport['media']['gallery'],
|
||||
visionGallery?: GroupDailyReport['media']['visionGallery']
|
||||
): ReportTopic[] => {
|
||||
if (!gallery.length) return topics
|
||||
if (!gallery.length && !visionGallery?.length) return topics
|
||||
// 优先用 visionGallery(AI 真实识别的 description)
|
||||
const firstVision = visionGallery?.find((it) => it.importance !== 'low')
|
||||
const [firstImage, ...rest] = gallery
|
||||
const nextTopics = topics.map((topic, index) =>
|
||||
index === 0 && firstImage.replyCount && firstImage.replyCount >= 3
|
||||
? {
|
||||
...topic,
|
||||
image: {
|
||||
imageUrl: firstImage.imageUrl,
|
||||
note: `该图片引发 ${firstImage.replyCount} 条回复。${firstImage.note.startsWith('根据') ? firstImage.note : `根据图片前后对话推断,${firstImage.note}`}`,
|
||||
sourceMessageIds: firstImage.sourceMessageIds
|
||||
}
|
||||
const nextTopics = topics.map((topic, index) => {
|
||||
if (index !== 0) return topic
|
||||
if (firstVision) {
|
||||
// AI 真实识别路径:note 直接用 description,不带"根据推断"前缀
|
||||
const noteParts = [firstVision.description]
|
||||
if (firstVision.ocrText) noteParts.push(`文字:${firstVision.ocrText}`)
|
||||
if (firstVision.tags.length) noteParts.push(`标签:${firstVision.tags.join('/')}`)
|
||||
return {
|
||||
...topic,
|
||||
image: {
|
||||
note: noteParts.join(' · '),
|
||||
sourceMessageIds: firstVision.sourceMessageIds
|
||||
// 注意:不填 imageUrl,因为 unknown imageHash 等问题可能导致 main 取不到原图,
|
||||
// 让 renderer 在 buildGroupReportFacts 阶段就把 dataUrl 预先加载好塞到 gallery 里,
|
||||
// 这里走 gallery 路径自然带 imageUrl
|
||||
}
|
||||
: topic
|
||||
)
|
||||
}
|
||||
}
|
||||
if (firstImage?.replyCount && firstImage.replyCount >= 3) {
|
||||
return {
|
||||
...topic,
|
||||
image: {
|
||||
imageUrl: firstImage.imageUrl,
|
||||
note: `该图片引发 ${firstImage.replyCount} 条回复。${firstImage.note.startsWith('根据') ? firstImage.note : `根据图片前后对话推断,${firstImage.note}`}`,
|
||||
sourceMessageIds: firstImage.sourceMessageIds
|
||||
}
|
||||
}
|
||||
}
|
||||
return topic
|
||||
})
|
||||
if (firstVision) {
|
||||
// visionGallery 用过的不再展示
|
||||
return nextTopics
|
||||
}
|
||||
gallery.splice(0, rest.length >= 0 ? 1 : 0)
|
||||
return nextTopics
|
||||
}
|
||||
@@ -510,7 +566,7 @@ const postProcessReport = (
|
||||
mode: ReportMode,
|
||||
metadata: GroupReportMetadata
|
||||
): GroupDailyReport => {
|
||||
const config = REPORT_MODE_CONFIG[mode]
|
||||
const config = topicLimitForMessageVolume(REPORT_MODE_CONFIG[mode], metadata.messageCount)
|
||||
|
||||
const topicsScored = sortByScore(report.topics, (item) => scoreByHeat(item.heat))
|
||||
const topicsDeduped = dedupeItems(
|
||||
@@ -518,8 +574,12 @@ const postProcessReport = (
|
||||
(item) => item.sourceMessageIds || [],
|
||||
(item) => createSignature(item.title, item.summary)
|
||||
)
|
||||
let gallery = [...report.media.gallery]
|
||||
const topics = attachHighImpactImage(clampTopics(topicsDeduped, config), gallery)
|
||||
const gallery = [...report.media.gallery]
|
||||
const topics = attachHighImpactImage(
|
||||
clampTopics(topicsDeduped, config),
|
||||
gallery,
|
||||
report.media.visionGallery
|
||||
)
|
||||
|
||||
const importantMessagesRaw = sortByScore(report.importantMessages, (item) =>
|
||||
Math.max(item.importance || 0, item.confidence || 0.6)
|
||||
@@ -567,11 +627,16 @@ const postProcessReport = (
|
||||
}))
|
||||
|
||||
const quotesRaw = sortByScore(report.quotes, (item) =>
|
||||
Math.max(item.importance || 0.55, item.confidence || 0.55, scoreByCount(item.messages.length, 4))
|
||||
Math.max(
|
||||
item.importance || 0.55,
|
||||
item.confidence || 0.55,
|
||||
scoreByCount(item.messages.length, 4)
|
||||
)
|
||||
)
|
||||
const quotes = dedupeItems(
|
||||
quotesRaw,
|
||||
(item) => item.sourceMessageIds || item.messages.map((message) => message.sourceMessageId || ''),
|
||||
(item) =>
|
||||
item.sourceMessageIds || item.messages.map((message) => message.sourceMessageId || ''),
|
||||
(item) => createSignature(item.note, item.messages.map((message) => message.content).join('|'))
|
||||
)
|
||||
.slice(0, config.maxQuotes)
|
||||
@@ -611,13 +676,19 @@ const postProcessReport = (
|
||||
|
||||
const hero = {
|
||||
headline:
|
||||
report.hero?.headline || topics[0]?.title || `${metadata.groupName}${mode === 'compact' ? '速览' : '日报'}`,
|
||||
report.hero?.headline ||
|
||||
topics[0]?.title ||
|
||||
`${metadata.groupName}${mode === 'compact' ? '速览' : '日报'}`,
|
||||
summary: truncate(
|
||||
report.hero?.summary || report.overview || '今天群里有新的讨论进展。',
|
||||
mode === 'compact' ? 84 : 120
|
||||
),
|
||||
keyTakeaway: report.hero?.keyTakeaway ? truncate(report.hero.keyTakeaway, config.noteLength) : undefined,
|
||||
pendingNote: report.hero?.pendingNote ? truncate(report.hero.pendingNote, config.noteLength) : undefined,
|
||||
keyTakeaway: report.hero?.keyTakeaway
|
||||
? truncate(report.hero.keyTakeaway, config.noteLength)
|
||||
: undefined,
|
||||
pendingNote: report.hero?.pendingNote
|
||||
? truncate(report.hero.pendingNote, config.noteLength)
|
||||
: undefined,
|
||||
statusLine:
|
||||
report.hero?.statusLine ||
|
||||
`今日形成 ${summaryStats.conclusionCount} 个结论 · ${summaryStats.todoCount} 个待办 · ${summaryStats.unresolvedCount} 个问题尚未解决`
|
||||
@@ -625,7 +696,13 @@ const postProcessReport = (
|
||||
|
||||
const sectionMeta: Partial<Record<ReportSectionKey, ReportSectionMeta>> = {
|
||||
hero: buildSectionMeta(true, 1, 1, 1, 0.95),
|
||||
topics: buildSectionMeta(config.enabledSections.includes('topics'), topics.length, report.topics.length, 0.98, 0.85),
|
||||
topics: buildSectionMeta(
|
||||
config.enabledSections.includes('topics'),
|
||||
topics.length,
|
||||
report.topics.length,
|
||||
0.98,
|
||||
0.85
|
||||
),
|
||||
importantMessages: buildSectionMeta(
|
||||
config.enabledSections.includes('importantMessages'),
|
||||
importantMessages.length,
|
||||
@@ -640,17 +717,84 @@ const postProcessReport = (
|
||||
0.97,
|
||||
0.8
|
||||
),
|
||||
moments: buildSectionMeta(config.enabledSections.includes('moments'), quotes.length, report.quotes.length, 0.75, 0.72),
|
||||
moments: buildSectionMeta(
|
||||
config.enabledSections.includes('moments'),
|
||||
quotes.length,
|
||||
report.quotes.length,
|
||||
0.75,
|
||||
0.72
|
||||
),
|
||||
analytics: buildSectionMeta(config.enabledSections.includes('analytics'), 1, 1, 0.8, 0.95),
|
||||
keywords: buildSectionMeta(config.enabledSections.includes('keywords'), keywords.length, report.keywords.length, 0.68, 0.9),
|
||||
resources: buildSectionMeta(config.enabledSections.includes('resources'), resources.length, report.resources.length, 0.55, 0.75),
|
||||
qa: buildSectionMeta(config.enabledSections.includes('qa'), qa.length, report.qa.length, 0.62, 0.78),
|
||||
storylines: buildSectionMeta(config.enabledSections.includes('storylines'), storylines.length, report.storylines.length, 0.63, 0.74),
|
||||
reversals: buildSectionMeta(config.enabledSections.includes('reversals'), reversals.length, report.reversals.length, 0.54, 0.72),
|
||||
gallery: buildSectionMeta(config.enabledSections.includes('gallery'), gallery.length, report.media.gallery.length, 0.6, 0.8),
|
||||
voices: buildSectionMeta(config.enabledSections.includes('voices'), voiceHighlights.length, report.media.voiceHighlights.length, 0.56, 0.84),
|
||||
badges: buildSectionMeta(config.enabledSections.includes('badges'), funBadges.length, report.media.funBadges.length, 0.45, 0.65),
|
||||
chains: buildSectionMeta(config.enabledSections.includes('chains'), participantChains.length, report.participantChains.length, 0.58, 0.71)
|
||||
keywords: buildSectionMeta(
|
||||
config.enabledSections.includes('keywords'),
|
||||
keywords.length,
|
||||
report.keywords.length,
|
||||
0.68,
|
||||
0.9
|
||||
),
|
||||
resources: buildSectionMeta(
|
||||
config.enabledSections.includes('resources'),
|
||||
resources.length,
|
||||
report.resources.length,
|
||||
0.55,
|
||||
0.75
|
||||
),
|
||||
qa: buildSectionMeta(
|
||||
config.enabledSections.includes('qa'),
|
||||
qa.length,
|
||||
report.qa.length,
|
||||
0.62,
|
||||
0.78
|
||||
),
|
||||
storylines: buildSectionMeta(
|
||||
config.enabledSections.includes('storylines'),
|
||||
storylines.length,
|
||||
report.storylines.length,
|
||||
0.63,
|
||||
0.74
|
||||
),
|
||||
reversals: buildSectionMeta(
|
||||
config.enabledSections.includes('reversals'),
|
||||
reversals.length,
|
||||
report.reversals.length,
|
||||
0.54,
|
||||
0.72
|
||||
),
|
||||
vision: buildSectionMeta(
|
||||
config.enabledSections.includes('vision'),
|
||||
report.media.visionGallery?.length || 0,
|
||||
report.media.visionGallery?.length || 0,
|
||||
0.72,
|
||||
0.9
|
||||
),
|
||||
gallery: buildSectionMeta(
|
||||
config.enabledSections.includes('gallery'),
|
||||
gallery.length,
|
||||
report.media.gallery.length,
|
||||
0.6,
|
||||
0.8
|
||||
),
|
||||
voices: buildSectionMeta(
|
||||
config.enabledSections.includes('voices'),
|
||||
voiceHighlights.length,
|
||||
report.media.voiceHighlights.length,
|
||||
0.56,
|
||||
0.84
|
||||
),
|
||||
badges: buildSectionMeta(
|
||||
config.enabledSections.includes('badges'),
|
||||
funBadges.length,
|
||||
report.media.funBadges.length,
|
||||
0.45,
|
||||
0.65
|
||||
),
|
||||
chains: buildSectionMeta(
|
||||
config.enabledSections.includes('chains'),
|
||||
participantChains.length,
|
||||
report.participantChains.length,
|
||||
0.58,
|
||||
0.71
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -670,6 +814,7 @@ const postProcessReport = (
|
||||
keywords,
|
||||
media: {
|
||||
gallery,
|
||||
visionGallery: report.media.visionGallery,
|
||||
voiceHighlights,
|
||||
funBadges
|
||||
},
|
||||
@@ -698,7 +843,8 @@ export const parseGroupDailyReport = (
|
||||
heroRoot && Object.keys(heroRoot).length
|
||||
? {
|
||||
headline: asString(heroRoot.headline) || topics[0]?.title || '今日群聊速览',
|
||||
summary: asString(heroRoot.summary) || asString(root.overview) || '今天群里有新的讨论进展。',
|
||||
summary:
|
||||
asString(heroRoot.summary) || asString(root.overview) || '今天群里有新的讨论进展。',
|
||||
keyTakeaway: asString(heroRoot.keyTakeaway),
|
||||
pendingNote: asString(heroRoot.pendingNote),
|
||||
statusLine: asString(heroRoot.statusLine)
|
||||
@@ -732,3 +878,95 @@ export const parseGroupDailyReport = (
|
||||
|
||||
return postProcessReport(report, metadata.reportMode || 'compact', metadata)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// main 分支兼容导出(SummaryDateRange / getSummaryDateRange 等)
|
||||
// 用于让 App.tsx / useGroupReportGeneration.ts 等 main 分支文件能继续编译
|
||||
// ============================================================
|
||||
export type SummaryDateRange = 'today' | 'yesterday' | '7days'
|
||||
export type SummaryMessageType =
|
||||
| 'text'
|
||||
| 'image'
|
||||
| 'sticker'
|
||||
| 'video'
|
||||
| 'voice'
|
||||
| 'share'
|
||||
| 'system'
|
||||
|
||||
export const SUMMARY_DATE_OPTIONS: { value: SummaryDateRange; label: string }[] = [
|
||||
{ value: 'today', label: '今天' },
|
||||
{ value: 'yesterday', label: '昨日' },
|
||||
{ value: '7days', label: '近 7 天' }
|
||||
]
|
||||
|
||||
export const SUMMARY_TYPE_OPTIONS: {
|
||||
value: SummaryMessageType
|
||||
label: string
|
||||
messageTypes: string[]
|
||||
description: string
|
||||
}[] = [
|
||||
{
|
||||
value: 'text',
|
||||
label: '文本',
|
||||
messageTypes: ['普通文本'],
|
||||
description: '使用文本内容、发送者和时间。'
|
||||
},
|
||||
{
|
||||
value: 'image',
|
||||
label: '图片',
|
||||
messageTypes: ['图片'],
|
||||
description: '图片 AI 识别结果将作为 ImageInsight 注入日报(由 ImageInsightService 提供)。'
|
||||
},
|
||||
{
|
||||
value: 'sticker',
|
||||
label: '表情包',
|
||||
messageTypes: ['表情包'],
|
||||
description: '不理解表情内容,仅按类型参与统计。'
|
||||
},
|
||||
{
|
||||
value: 'video',
|
||||
label: '视频',
|
||||
messageTypes: ['视频'],
|
||||
description: '不理解视频画面,仅按类型参与统计。'
|
||||
},
|
||||
{
|
||||
value: 'voice',
|
||||
label: '语音',
|
||||
messageTypes: ['语音'],
|
||||
description: '当前不转写语音,仅参与数量和活跃度统计。'
|
||||
},
|
||||
{
|
||||
value: 'share',
|
||||
label: '分享/引用',
|
||||
messageTypes: ['分享消息', '名片', '位置', '通话'],
|
||||
description: '使用解析到的标题、引用文本或类型信息。'
|
||||
},
|
||||
{
|
||||
value: 'system',
|
||||
label: '系统消息',
|
||||
messageTypes: ['系统消息'],
|
||||
description: '使用系统消息文本或类型信息。'
|
||||
}
|
||||
]
|
||||
|
||||
export const getSummaryDateRange = (
|
||||
range: SummaryDateRange
|
||||
): { startTime: number; endTime: number } => {
|
||||
const now = new Date()
|
||||
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime() / 1000
|
||||
const endTime = Math.floor(Date.now() / 1000)
|
||||
if (range === 'yesterday') {
|
||||
return { startTime: startOfToday - 86400, endTime: startOfToday - 1 }
|
||||
}
|
||||
if (range === '7days') {
|
||||
return { startTime: startOfToday - 6 * 86400, endTime }
|
||||
}
|
||||
return { startTime: startOfToday, endTime }
|
||||
}
|
||||
|
||||
export const isInternalName = (value?: string): boolean => {
|
||||
const text = String(value || '').trim()
|
||||
return (
|
||||
!text || /^wxid_/i.test(text) || /@chatroom$/i.test(text) || /^[a-z0-9_-]{18,}$/i.test(text)
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user