重构 UI-04 AI 群聊日报工作区

This commit is contained in:
电摇小子
2026-07-14 11:07:35 +08:00
committed by 电摇小子
parent 7880e874b2
commit 16a9aed6bb
16 changed files with 1919 additions and 368 deletions
+173 -30
View File
@@ -4,6 +4,14 @@ import ChatWindow from './components/ChatWindow'
import { SettingsPanel } from './components/SettingsPanel'
import { AppShell } from './components/layout/AppShell'
import { AppPage } from './components/layout/navigation'
import { AiReportWorkspace } from './components/reports/AiReportWorkspace'
import { ReportSourceSidebar } from './components/reports/ReportSourceSidebar'
import { ReportTaskStatusPanel } from './components/reports/ReportTaskStatusPanel'
import {
AiModelConfig,
useGroupReportGeneration
} from './hooks/useGroupReportGeneration'
import { SummaryDateRange, SummaryMessageType } from './utils/group-report'
import { Contact, Message } from '../../shared/types'
const SIDEBAR_MIN_WIDTH = 260
@@ -158,6 +166,15 @@ function App(): React.ReactElement {
const [showMacKeyFaq, setShowMacKeyFaq] = useState(false)
const [showSettings, setShowSettings] = useState(false)
const [activePage, setActivePage] = useState<AppPage>('archive')
const [reportSourceContact, setReportSourceContact] = useState<Contact | null>(null)
const [reportNotice, setReportNotice] = useState('')
const [summaryDateRange, setSummaryDateRange] = useState<SummaryDateRange>('today')
const [summaryMessageTypes, setSummaryMessageTypes] = useState<SummaryMessageType[]>(['text'])
const [aiModelConfig, setAiModelConfig] = useState<AiModelConfig>(() => ({
apiKey: localStorage.getItem('ai_api_key') || '',
baseURL: localStorage.getItem('ai_base_url') || 'https://api.deepseek.com',
model: localStorage.getItem('ai_model') || 'deepseek-chat'
}))
const [selfInfo, setSelfInfo] = useState<SelfInfo | null>(null)
const [isNativeMonitorActive, setIsNativeMonitorActive] = useState(false)
const [bootState, setBootState] = useState<'loading' | 'connecting' | 'login'>('loading')
@@ -169,6 +186,13 @@ function App(): React.ReactElement {
const selectedContactMd5Ref = React.useRef<string>('')
const contactAvatarHydrationRunRef = React.useRef(0)
const reportGeneration = useGroupReportGeneration({
sourceContact: reportSourceContact,
summaryDateRange,
summaryMessageTypes,
modelConfig: aiModelConfig
})
const waitForPaint = (): Promise<void> =>
new Promise((resolve) => window.setTimeout(resolve, 80))
@@ -307,7 +331,7 @@ function App(): React.ReactElement {
}
if (!AUTO_LOGIN_ENABLED) return
try {
const result: any = await window.api.initDb(key)
const result = await window.api.initDb(key)
if (!active) return
const success = typeof result === 'boolean' ? result : result.success
if (success) {
@@ -325,7 +349,7 @@ function App(): React.ReactElement {
setDbKeyStatusKind('error')
setBootState('login')
}
} catch (error: any) {
} catch (error: unknown) {
if (!active) return
const message = error instanceof Error ? error.message : String(error)
setDbKeyStatus(`自动连接失败: ${message}`)
@@ -714,6 +738,147 @@ function App(): React.ReactElement {
}
}
const isGroupContact = (contact: Contact | null): boolean =>
Boolean(contact?.type === 'group' || contact?.m_nsUsrName?.endsWith('@chatroom'))
const handlePageChange = (page: AppPage): void => {
setActivePage(page)
if (page === 'report' && isGroupContact(selectedContact) && !reportSourceContact) {
setReportSourceContact(selectedContact)
}
}
const handleOpenReportWorkspace = (): void => {
if (!selectedContact) {
setReportNotice('请先选择一个群聊')
window.setTimeout(() => setReportNotice(''), 3200)
return
}
if (!isGroupContact(selectedContact)) {
setReportNotice('AI 群聊日报仅支持群聊')
window.setTimeout(() => setReportNotice(''), 3200)
return
}
setReportNotice('')
setReportSourceContact(selectedContact)
setActivePage('report')
}
const handleSelectReportSource = (contact: Contact): void => {
setReportSourceContact(contact)
if (selectedContact?.md5 !== contact.md5) {
void handleSelectContact(contact)
}
}
const handleSaveAiModelConfig = (): void => {
localStorage.setItem('ai_api_key', aiModelConfig.apiKey)
localStorage.setItem('ai_base_url', aiModelConfig.baseURL)
localStorage.setItem('ai_model', aiModelConfig.model)
}
const renderPlaceholderPage = (page: Exclude<AppPage, 'archive' | 'report'>): React.ReactElement => {
const labels: Record<Exclude<AppPage, 'archive' | 'report'>, string> = {
search: '检索',
export: '导出',
api: 'API',
settings: '设置'
}
return (
<div className="app-page-placeholder">
<div className="app-page-placeholder-eyebrow">WechatExplorer</div>
<h2>{labels[page]}</h2>
<p> UI </p>
</div>
)
}
const renderArchiveWorkspace = (): React.ReactElement => (
<div className="app-container">
<Sidebar
contacts={filteredContacts}
selectedContact={selectedContact}
onSelectContact={handleSelectContact}
onSearch={handleSearchContacts}
onContentFilter={setContentFilter}
width={sidebarWidth}
dateRange={dateRange}
onDateRangeChange={handleDateRangeChange}
selfInfo={selfInfo}
dbReady={isAuthenticated}
onOpenSettings={() => setShowSettings(true)}
/>
<div className="resizer" onMouseDown={startResizing} />
<ChatWindow
key={selectedContact?.md5}
contact={selectedContact}
messages={messages}
isLoadingMessages={isMessagesLoading}
contentFilter={contentFilter}
dateRange={dateRange}
onContentFilterChange={setContentFilter}
onRefresh={() => selectedContact && handleSelectContact(selectedContact)}
onRefreshData={loadContacts}
onCreateGroupReport={handleOpenReportWorkspace}
isAiLoading={reportGeneration.isGenerating}
/>
</div>
)
const renderReportWorkspace = (): React.ReactElement => (
<div className="report-page">
<ReportSourceSidebar
contacts={contacts}
selectedContact={reportSourceContact}
selfInfo={selfInfo}
dbReady={isAuthenticated}
onSelectContact={handleSelectReportSource}
onOpenSettings={() => setShowSettings(true)}
/>
<AiReportWorkspace
sourceContact={reportSourceContact}
summaryDateRange={summaryDateRange}
summaryMessageTypes={summaryMessageTypes}
modelConfig={aiModelConfig}
rangeMessageCount={reportGeneration.rangeMessages.length}
reportMessageCount={reportGeneration.reportMessages.length}
messageTypeCounts={reportGeneration.messageTypeCounts}
rangeState={reportGeneration.rangeState}
phase={reportGeneration.phase}
error={reportGeneration.error}
generatedImage={reportGeneration.generatedImage}
reportPaths={reportGeneration.reportPaths}
isGenerating={reportGeneration.isGenerating}
onSummaryDateRangeChange={setSummaryDateRange}
onSummaryMessageTypesChange={setSummaryMessageTypes}
onOpenModelSettings={() => setShowSettings(true)}
onGenerate={() => void reportGeneration.generate()}
onCloseResult={reportGeneration.closeResult}
onCopyImage={reportGeneration.copyImage}
onRevealReport={reportGeneration.revealReport}
/>
<ReportTaskStatusPanel
phase={reportGeneration.phase}
error={reportGeneration.error}
onRetry={() => void reportGeneration.retry()}
/>
</div>
)
const renderCurrentWorkspace = (): React.ReactElement => {
switch (activePage) {
case 'archive':
return renderArchiveWorkspace()
case 'report':
return renderReportWorkspace()
case 'search':
case 'export':
case 'api':
case 'settings':
return renderPlaceholderPage(activePage)
}
}
const [sidebarWidth, setSidebarWidth] = useState(300)
const [isResizing, setIsResizing] = useState(false)
const sidebarResizeStartRef = React.useRef({ x: 0, width: 300 })
@@ -844,46 +1009,24 @@ function App(): React.ReactElement {
activePage={activePage}
selfInfo={selfInfo}
dbReady={isAuthenticated}
onPageChange={setActivePage}
onPageChange={handlePageChange}
onOpenSettings={() => {
setActivePage('settings')
setShowSettings(true)
}}
>
<div className="app-container">
<Sidebar
contacts={filteredContacts}
selectedContact={selectedContact}
onSelectContact={handleSelectContact}
onSearch={handleSearchContacts}
onContentFilter={setContentFilter}
width={sidebarWidth}
dateRange={dateRange}
onDateRangeChange={handleDateRangeChange}
selfInfo={selfInfo}
dbReady={isAuthenticated}
onOpenSettings={() => setShowSettings(true)}
/>
<div className="resizer" onMouseDown={startResizing} />
<ChatWindow
key={selectedContact?.md5}
contact={selectedContact}
messages={messages}
isLoadingMessages={isMessagesLoading}
contentFilter={contentFilter}
dateRange={dateRange}
onContentFilterChange={setContentFilter}
onRefresh={() => selectedContact && handleSelectContact(selectedContact)}
onRefreshData={loadContacts}
/>
</div>
{reportNotice && <div className="app-toast">{reportNotice}</div>}
{renderCurrentWorkspace()}
<SettingsPanel
open={showSettings}
selfInfo={selfInfo}
dbReady={isAuthenticated}
dbKey={dbKey}
aiModelConfig={aiModelConfig}
onClose={() => setShowSettings(false)}
onDbKeyChange={setDbKey}
onAiModelConfigChange={setAiModelConfig}
onSaveAiModelConfig={handleSaveAiModelConfig}
onDbRootChanged={() => {
void refreshSelfInfo()
void loadContacts()
+662
View File
@@ -2761,3 +2761,665 @@ body {
word-break: break-all;
max-width: 100%;
}
.app-toast {
position: fixed;
top: 18px;
left: calc(var(--wxex-nav-width) + 50%);
z-index: 40;
transform: translateX(-50%);
padding: 9px 14px;
border: 1px solid rgba(198, 134, 53, 0.35);
border-radius: var(--wxex-radius-md);
background: #fff9ef;
color: #7b4b14;
font: 13px/18px var(--wxex-font);
box-shadow: var(--wxex-shadow-popover);
}
.report-page {
display: grid;
grid-template-columns: 292px minmax(420px, 1fr) 304px;
width: 100%;
height: 100%;
min-height: 0;
min-width: 0;
overflow: hidden;
background: var(--wxex-bg-main);
}
.report-page > * {
min-height: 0;
min-width: 0;
}
.report-source-sidebar {
display: flex;
min-width: 0;
min-height: 0;
height: 100%;
flex-direction: column;
overflow: hidden;
border-right: 1px solid var(--wxex-border);
background: var(--wxex-bg-sidebar);
}
.report-source-header {
display: flex;
flex: 0 0 auto;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 18px 16px 12px;
border-bottom: 1px solid var(--wxex-border);
}
.report-source-header h2,
.ai-report-header h1,
.report-task-header h2 {
margin: 0;
color: var(--wxex-text-primary);
}
.report-source-header h2 {
font: 700 18px/24px var(--wxex-font);
}
.report-source-header p,
.ai-report-header p,
.report-task-header p {
margin: 4px 0 0;
color: var(--wxex-text-secondary);
font: 12px/18px var(--wxex-font);
}
.report-source-header > span {
flex: 0 0 auto;
margin-top: 3px;
color: var(--wxex-text-muted);
font: 12px/18px var(--wxex-font);
}
.report-source-search {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: 8px;
margin: 12px 14px;
padding: 0 10px;
height: 36px;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-md);
background: var(--wxex-bg-elevated);
color: var(--wxex-text-muted);
}
.report-source-search svg {
width: 16px;
height: 16px;
}
.report-source-search path,
.report-source-search circle {
fill: none;
stroke: currentColor;
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
}
.report-source-search input {
min-width: 0;
flex: 1;
border: 0;
outline: 0;
background: transparent;
color: var(--wxex-text-primary);
font: 13px/18px var(--wxex-font);
}
.report-source-list {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
padding: 0 10px 12px;
}
.report-source-item {
position: relative;
display: flex;
width: 100%;
min-width: 0;
align-items: center;
gap: 10px;
padding: 10px 10px 10px 12px;
border: 0;
border-radius: var(--wxex-radius-md);
background: transparent;
color: var(--wxex-text-primary);
text-align: left;
cursor: pointer;
font-family: var(--wxex-font);
}
.report-source-item:hover {
background: rgba(255, 255, 255, 0.62);
}
.report-source-item.active {
background: var(--wxex-brand-soft);
color: var(--wxex-brand);
}
.report-source-item.active::before {
position: absolute;
left: 0;
top: 8px;
bottom: 8px;
width: 3px;
border-radius: 4px;
background: var(--wxex-brand);
content: '';
}
.report-source-avatar {
display: grid;
width: 36px;
height: 36px;
flex: 0 0 auto;
place-items: center;
overflow: hidden;
border-radius: var(--wxex-radius-md);
background: var(--wxex-bg-elevated);
color: var(--wxex-brand);
font: 700 14px/1 var(--wxex-font);
}
.report-source-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.report-source-text {
display: grid;
min-width: 0;
}
.report-source-text span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.report-source-text span {
color: inherit;
font: 600 13px/18px var(--wxex-font);
}
.report-source-empty {
padding: 18px 8px;
color: var(--wxex-text-muted);
font: 13px/18px var(--wxex-font);
}
.report-source-account {
flex: 0 0 auto;
padding: 12px;
border-top: 1px solid var(--wxex-border);
}
.ai-report-workspace {
display: flex;
min-width: 0;
min-height: 0;
height: 100%;
flex-direction: column;
overflow: hidden;
background: var(--wxex-bg-main);
}
.ai-report-header {
display: flex;
flex: 0 0 auto;
align-items: flex-start;
justify-content: space-between;
gap: 18px;
padding: 22px 28px 16px;
border-bottom: 1px solid var(--wxex-border);
}
.ai-report-header h1 {
font: 700 21px/28px var(--wxex-font);
}
.ai-report-header p {
font-size: 13px;
}
.report-inline-error {
max-width: 320px;
padding: 8px 10px;
border: 1px solid rgba(200, 90, 90, 0.35);
border-radius: var(--wxex-radius-md);
background: #fff4f4;
color: var(--wxex-danger);
font: 12px/17px var(--wxex-font);
}
.ai-report-body {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
padding: 18px 28px 20px;
}
.ai-report-body > * {
max-width: 720px;
}
.report-empty-state,
.report-config-section,
.report-privacy-note,
.report-result-panel {
margin-bottom: 16px;
padding: 16px;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-lg);
background: var(--wxex-bg-elevated);
}
.report-empty-state h2,
.report-privacy-note h3,
.report-config-section h3,
.report-result-panel h3 {
margin: 0;
color: var(--wxex-text-primary);
font: 700 15px/20px var(--wxex-font);
}
.report-empty-state p,
.report-privacy-note p {
margin: 6px 0 0;
color: var(--wxex-text-secondary);
font: 13px/19px var(--wxex-font);
}
.report-section-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.report-section-heading span {
color: var(--wxex-text-muted);
font: 12px/18px var(--wxex-font);
}
.report-section-heading span.danger {
color: var(--wxex-danger);
}
.report-range-options,
.report-density-options {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
}
.report-density-options {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.report-range-options button,
.report-density-options button,
.report-model-summary button,
.report-result-actions button,
.report-task-error button,
.ai-report-footer button {
min-height: 34px;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-md);
background: var(--wxex-bg-elevated);
color: var(--wxex-text-primary);
cursor: pointer;
font: 600 13px/18px var(--wxex-font);
}
.report-range-options button.active,
.report-density-options button.active {
border-color: var(--wxex-brand);
background: var(--wxex-brand-soft);
color: var(--wxex-brand);
}
.report-range-options button:disabled,
.report-density-options button:disabled {
color: var(--wxex-text-muted);
cursor: not-allowed;
opacity: 0.75;
}
.report-range-options button span,
.report-density-options button span {
display: block;
margin-top: 2px;
font: 11px/14px var(--wxex-font);
}
.report-type-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.report-check-row {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: start;
gap: 8px;
padding: 10px;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-md);
background: #fbfcfb;
color: var(--wxex-text-primary);
}
.report-check-row input {
margin-top: 2px;
accent-color: var(--wxex-brand);
}
.report-check-row span {
display: grid;
min-width: 0;
gap: 2px;
}
.report-check-row b {
font: 600 13px/18px var(--wxex-font);
}
.report-check-row small {
color: var(--wxex-text-muted);
font: 12px/16px var(--wxex-font);
}
.report-check-row em {
color: var(--wxex-text-secondary);
font: normal 12px/18px var(--wxex-font);
}
.report-readonly-modules {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
cursor: default;
}
.report-readonly-modules span {
display: flex;
min-width: 0;
align-items: center;
gap: 8px;
padding: 8px 10px;
border: 1px solid #e9eeeb;
border-radius: var(--wxex-radius-md);
background: #f7f9f8;
color: var(--wxex-text-secondary);
font: 12px/17px var(--wxex-font);
cursor: default;
}
.report-readonly-modules svg {
width: 14px;
height: 14px;
flex: 0 0 auto;
color: var(--wxex-text-muted);
}
.report-readonly-modules rect,
.report-readonly-modules path {
fill: none;
stroke: currentColor;
stroke-width: 1.7;
stroke-linecap: round;
stroke-linejoin: round;
}
.report-model-summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.report-model-summary p {
margin: 4px 0 0;
color: var(--wxex-text-secondary);
font: 13px/18px var(--wxex-font);
}
.report-model-summary button,
.report-result-actions button,
.report-task-error button {
padding: 0 12px;
}
.report-result-preview {
max-height: 460px;
overflow: auto;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-md);
background: #f6f4f0;
text-align: center;
}
.report-result-preview img {
display: inline-block;
width: min(100%, 430px);
height: auto;
}
.report-result-actions {
display: flex;
gap: 8px;
margin-top: 10px;
}
.report-action-status {
margin: 8px 0 0;
color: var(--wxex-text-secondary);
font: 12px/16px var(--wxex-font);
}
.ai-report-footer {
display: flex;
flex: 0 0 auto;
min-height: 66px;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 12px 28px;
border-top: 1px solid var(--wxex-border);
background: var(--wxex-bg-main);
}
.report-footer-note,
.report-footer-actions span {
min-width: 0;
color: var(--wxex-text-secondary);
font: 12px/18px var(--wxex-font);
}
.report-footer-note {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.report-footer-actions {
display: flex;
min-width: 0;
flex: 0 0 auto;
align-items: center;
gap: 12px;
}
.report-footer-actions span {
max-width: 280px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ai-report-footer button {
min-width: 132px;
padding: 0 16px;
border-color: var(--wxex-ai);
background: var(--wxex-ai);
color: #fff;
}
.ai-report-footer button:disabled {
border-color: var(--wxex-border);
background: #e8ebe9;
color: var(--wxex-text-muted);
cursor: not-allowed;
}
.report-task-panel {
display: flex;
min-width: 0;
min-height: 0;
height: 100%;
flex-direction: column;
overflow: hidden;
border-left: 1px solid var(--wxex-border);
background: #f7f9f8;
}
.report-task-header {
flex: 0 0 auto;
padding: 20px 18px 14px;
border-bottom: 1px solid var(--wxex-border);
}
.report-task-header h2 {
font: 700 16px/22px var(--wxex-font);
}
.report-task-steps {
flex: 0 0 auto;
padding: 16px 18px;
}
.report-task-step {
display: grid;
grid-template-columns: 18px minmax(0, 1fr);
gap: 10px;
padding: 0 0 18px;
color: var(--wxex-text-muted);
}
.report-task-step b {
display: block;
color: inherit;
font: 600 13px/18px var(--wxex-font);
}
.report-task-step small {
color: var(--wxex-text-muted);
font: 12px/16px var(--wxex-font);
}
.report-task-dot {
width: 12px;
height: 12px;
margin-top: 3px;
border: 2px solid var(--wxex-border);
border-radius: 50%;
background: transparent;
}
.report-task-step.done {
color: var(--wxex-success);
}
.report-task-step.done .report-task-dot {
border-color: var(--wxex-success);
background: var(--wxex-success);
}
.report-task-step.active {
color: var(--wxex-ai);
}
.report-task-step.active .report-task-dot {
border-color: var(--wxex-ai);
background: var(--wxex-ai-soft);
}
.report-task-error,
.report-task-success,
.report-task-note {
margin: 0 18px 14px;
padding: 12px;
border-radius: var(--wxex-radius-md);
font: 12px/18px var(--wxex-font);
}
.report-task-error {
border: 1px solid rgba(200, 90, 90, 0.35);
background: #fff4f4;
color: var(--wxex-danger);
}
.report-task-success {
border: 1px solid rgba(46, 139, 104, 0.28);
background: var(--wxex-brand-soft);
color: var(--wxex-success);
}
.report-task-note {
margin-top: auto;
flex: 0 0 auto;
color: var(--wxex-text-secondary);
background: var(--wxex-ai-soft);
}
.report-task-error p,
.report-task-success p {
margin: 4px 0 10px;
}
@media (max-width: 1120px) {
.report-page {
grid-template-columns: 268px minmax(360px, 1fr) 280px;
}
.report-type-grid {
grid-template-columns: 1fr;
}
.report-readonly-modules {
grid-template-columns: 1fr;
}
}
.report-density-options button:disabled:hover {
border-color: var(--wxex-border);
background: var(--wxex-bg-elevated);
color: var(--wxex-text-muted);
}
.report-density-options button.active:disabled {
border-color: var(--wxex-brand);
background: var(--wxex-brand-soft);
color: var(--wxex-brand);
opacity: 1;
cursor: default;
}
+9 -327
View File
@@ -1,10 +1,5 @@
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { Message, Contact } from '../../../shared/types'
import {
buildGroupReportInput,
GROUP_REPORT_SYSTEM_PROMPT,
parseGroupDailyReport
} from '../utils/group-report'
import { ChatHeader } from './chat/ChatHeader'
import { ChatStatusBar } from './chat/ChatStatusBar'
import { DataTrustBar } from './chat/DataTrustBar'
@@ -21,18 +16,11 @@ interface ChatWindowProps {
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 SUMMARY_DATE_OPTIONS: { value: SummaryDateRange; label: string }[] = [
{ value: 'today', label: '今天' },
{ value: 'yesterday', label: '昨日' },
{ value: '7days', label: '最近 7 天' }
]
const MAX_RENDERED_MESSAGES = 600
const REPORT_STEP_TIMEOUT_MS = 90_000
const DATE_RANGE_LABELS: Record<string, string> = {
today: '今天',
yesterday: '昨日',
@@ -71,52 +59,6 @@ const getChatHeaderRangeLabel = (range: string): string => {
return DATE_RANGE_LABELS[range] || '当前范围'
}
const withTimeout = async <T,>(promise: Promise<T>, label: string): Promise<T> => {
let timer: number | undefined
const timeout = new Promise<never>((_, reject) => {
timer = window.setTimeout(() => reject(new Error(`${label} 超时`)), REPORT_STEP_TIMEOUT_MS)
})
try {
return await Promise.race([promise, timeout])
} finally {
if (timer) window.clearTimeout(timer)
}
}
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)
)
}
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 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 }
}
const ChatWindow: React.FC<ChatWindowProps> = ({
contact,
messages,
@@ -125,15 +67,15 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
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)
@@ -143,36 +85,8 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
null
)
const [showAvatar, setShowAvatar] = 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-chat')
const [summaryDateRange, setSummaryDateRange] = useState<SummaryDateRange>('today')
const [summaryMessageTypes, setSummaryMessageTypes] = useState<SummaryMessageType[]>(['text'])
const [isAtLatest, setIsAtLatest] = useState(true)
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)
setShowSettingsModal(false)
AIChat()
}
const toggleSummaryMessageType = (type: SummaryMessageType): void => {
setSummaryMessageTypes((current) =>
current.includes(type) ? current.filter((item) => item !== type) : [...current, type]
)
}
const scrollToBottom = useCallback((): void => {
messagesEndRef.current?.scrollIntoView({ behavior: 'auto' })
setIsAtLatest(true)
@@ -185,7 +99,8 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
}, [])
useEffect(() => {
scrollToBottom()
const frame = window.requestAnimationFrame(() => scrollToBottom())
return () => window.cancelAnimationFrame(frame)
}, [messages, scrollToBottom])
const openImagePreview = (imageUrl: string): void => {
@@ -316,87 +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('当前条件下没有可总结的消息')
let memberMap = new Map<string, { nickname: string; avatar: string }>()
if (isGroupChat) {
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 || '' }
])
)
} catch (error) {
console.warn('[GroupReport] member snapshot failed:', error)
}
}
const namedReportMessages = reportMessages.map((message) => {
if (!isGroupChat || !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 }
})
const input = buildGroupReportInput(namedReportMessages, contact, isGroupChat)
console.log('🚀 ~ AIChat ~ input:', input)
console.log('🚀 ~ AIChat ~ input.prompt:', input.prompt)
const result = await withTimeout(
window.api.aiChat(
[
{ role: 'system', content: GROUP_REPORT_SYSTEM_PROMPT },
{ role: 'user', content: input.prompt }
],
{ apiKey, model, baseURL }
),
'AI 生成日报'
)
if (!result.success || !result.data) throw new Error(result.error || 'AI 请求失败')
const report = parseGroupDailyReport(result.data, input.topSpeakers, input.activeTimeline)
const exported = await withTimeout(
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 || '')
@@ -427,13 +261,13 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
loadedCount={messages.length}
filteredCount={filteredMessages.length}
contentFilter={contentFilter || ''}
isAiLoading={isLoading}
isAiLoading={isAiLoading}
canExport={messages.length > 0}
onContentFilterChange={onContentFilterChange || (() => undefined)}
onRefresh={onRefresh}
onRefreshData={onRefreshData}
onExport={handleExport}
onOpenAiSettings={() => setShowSettingsModal(true)}
onOpenAiSettings={onCreateGroupReport || (() => undefined)}
/>
<DataTrustBar messageCount={messages.length} />
<MessageList
@@ -456,64 +290,6 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
onJumpToLatest={scrollToBottom}
/>
{/* 加载模态框 */}
{isLoading && (
<div className="modal-overlay">
<div className="modal-content" style={{ textAlign: 'center', minWidth: '200px' }}>
<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>
)}
{previewImage && (
<div className="image-viewer-overlay" onClick={closeImagePreview}>
<div className="image-viewer-window" onClick={(e) => e.stopPropagation()}>
@@ -563,100 +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-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-chat">DeepSeek Chat</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>
)
}
@@ -24,23 +24,44 @@ interface ApiState {
error?: string
}
interface AiModelConfig {
apiKey: string
baseURL: string
model: string
}
interface SettingsPanelProps {
open: boolean
selfInfo: SelfInfo | null
dbReady: boolean
dbKey: string
aiModelConfig: AiModelConfig
onClose: () => void
onDbKeyChange: (key: string) => void
onAiModelConfigChange: (config: AiModelConfig) => void
onSaveAiModelConfig: () => void
onDbRootChanged: () => void
}
const AI_MODEL_OPTIONS = [
{ value: 'deepseek-chat', label: 'DeepSeek Chat' },
{ value: 'gpt-4o', label: 'GPT-4o' },
{ value: 'gpt-4o-mini', label: 'GPT-4o Mini' },
{ value: 'gpt-4-turbo', label: 'GPT-4 Turbo' },
{ value: 'claude-3-5-sonnet-20240620', label: 'Claude 3.5 Sonnet' },
{ value: 'moonshot-v1-8k', label: 'Moonshot V1' }
]
export const SettingsPanel: React.FC<SettingsPanelProps> = ({
open,
selfInfo,
dbReady,
dbKey,
aiModelConfig,
onClose,
onDbKeyChange,
onAiModelConfigChange,
onSaveAiModelConfig,
onDbRootChanged
}) => {
const isWindows = window.electron.process.platform === 'win32'
@@ -344,6 +365,56 @@ export const SettingsPanel: React.FC<SettingsPanelProps> = ({
</div>
</section>
<section className="settings-section">
<div className="settings-section-title">AI </div>
<div className="settings-row">
<select
className="settings-input settings-input-half"
value={aiModelConfig.model}
onChange={(event) =>
onAiModelConfigChange({ ...aiModelConfig, model: event.target.value })
}
>
{AI_MODEL_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<button className="settings-btn" onClick={onSaveAiModelConfig}>
AI
</button>
</div>
<div className="settings-row">
<input
type="text"
className="settings-input"
value={aiModelConfig.baseURL}
onChange={(event) =>
onAiModelConfigChange({ ...aiModelConfig, baseURL: event.target.value })
}
placeholder="https://api.deepseek.com"
spellCheck={false}
/>
</div>
<div className="settings-row">
<input
type="password"
className="settings-input"
value={aiModelConfig.apiKey}
onChange={(event) =>
onAiModelConfigChange({ ...aiModelConfig, apiKey: event.target.value })
}
placeholder="API Key"
spellCheck={false}
/>
</div>
<div className="settings-hint">
沿
localStorage
</div>
</section>
{/* API 服务 */}
<section className="settings-section">
<div className="settings-section-title"> HTTP API</div>
@@ -124,7 +124,7 @@ export function ChatHeader({
type="button"
className="chat-ai-button"
onClick={onOpenAiSettings}
disabled={!isGroupChat || isAiLoading}
disabled={isAiLoading}
title={isGroupChat ? '生成 AI 日报' : 'AI 日报当前仅支持群聊'}
>
<AiIcon />
@@ -57,16 +57,8 @@ export function AppShell({
<AccountSummary selfInfo={selfInfo} dbReady={dbReady} compact onClick={onOpenSettings} />
</div>
</aside>
<main className="app-shell-main">
{activePage === 'archive' ? (
children
) : (
<div className="app-page-placeholder">
<div className="app-page-placeholder-eyebrow">WechatExplorer</div>
<h2>{activeItem?.label || '工作区'}</h2>
<p> UI </p>
</div>
)}
<main className="app-shell-main" aria-label={activeItem?.label || '工作区'}>
{children}
</main>
</div>
)
@@ -0,0 +1,191 @@
import React, { useMemo, useState } from 'react'
import { Contact } from '../../../../shared/types'
import {
AiModelConfig,
RangeMessageState,
ReportGenerationPhase,
ReportPaths
} from '../../hooks/useGroupReportGeneration'
import { SummaryDateRange, SummaryMessageType } from '../../utils/group-report'
import { MessageTypeSelector } from './MessageTypeSelector'
import { ModelSummary } from './ModelSummary'
import { ReportDensitySelector } from './ReportDensitySelector'
import { ReportRangeSelector } from './ReportRangeSelector'
import { ReportSectionSelector } from './ReportSectionSelector'
interface AiReportWorkspaceProps {
sourceContact: Contact | null
summaryDateRange: SummaryDateRange
summaryMessageTypes: SummaryMessageType[]
modelConfig: AiModelConfig
rangeMessageCount: number
reportMessageCount: number
messageTypeCounts: Record<SummaryMessageType, number>
rangeState: RangeMessageState
phase: ReportGenerationPhase
error: string
generatedImage: string | null
reportPaths: ReportPaths | null
isGenerating: boolean
onSummaryDateRangeChange: (value: SummaryDateRange) => void
onSummaryMessageTypesChange: (value: SummaryMessageType[]) => void
onOpenModelSettings: () => void
onGenerate: () => void
onCloseResult: () => void
onCopyImage: () => Promise<{ success: boolean; error?: string }>
onRevealReport: () => Promise<{ success: boolean; error?: string }>
}
const rangeLabel = (range: SummaryDateRange): string => {
if (range === 'yesterday') return '昨日'
if (range === '7days') return '近 7 天'
return '今天'
}
const modelLabel = (model: string): string => {
if (model === 'deepseek-chat') return 'DeepSeek Chat'
if (model === 'gpt-4o-mini') return 'GPT-4o Mini'
if (model === 'gpt-4o') return 'GPT-4o'
if (model === 'gpt-4-turbo') return 'GPT-4 Turbo'
if (model === 'claude-3-5-sonnet-20240620') return 'Claude 3.5 Sonnet'
if (model === 'moonshot-v1-8k') return 'Moonshot V1'
return model || '未选择模型'
}
export function AiReportWorkspace({
sourceContact,
summaryDateRange,
summaryMessageTypes,
modelConfig,
rangeMessageCount,
reportMessageCount,
messageTypeCounts,
rangeState,
phase,
error,
generatedImage,
reportPaths,
isGenerating,
onSummaryDateRangeChange,
onSummaryMessageTypesChange,
onOpenModelSettings,
onGenerate,
onCloseResult,
onCopyImage,
onRevealReport
}: AiReportWorkspaceProps): React.ReactElement {
const [actionStatus, setActionStatus] = useState('')
const groupName = sourceContact?.m_nsNickName || sourceContact?.m_nsUsrName || '未选择群聊'
const configDisabled = isGenerating
const disabledReason = useMemo(() => {
if (!sourceContact) return '请先选择群聊'
if (!modelConfig.apiKey.trim()) return '请先配置 API Key'
if (rangeState.status === 'loading') return '正在计算消息数量'
if (rangeState.status === 'error') return rangeState.error
if (!reportMessageCount) return '当前范围没有可总结消息'
if (!summaryMessageTypes.length) return '请至少选择一种消息类型'
return ''
}, [modelConfig.apiKey, rangeState, reportMessageCount, sourceContact, summaryMessageTypes.length])
const canGenerate = !isGenerating && !disabledReason
const modelStatus =
modelConfig.apiKey.trim() && modelConfig.baseURL.trim()
? '配置正常'
: modelConfig.apiKey.trim() || modelConfig.baseURL.trim()
? '配置不完整'
: '尚未配置'
const handleCopy = async (): Promise<void> => {
const result = await onCopyImage()
setActionStatus(result.success ? '图片已复制' : result.error || '复制失败')
}
const handleReveal = async (): Promise<void> => {
const result = await onRevealReport()
setActionStatus(result.success ? '已在文件夹中显示' : result.error || '打开文件夹失败')
}
return (
<main className="ai-report-workspace">
<header className="ai-report-header">
<div>
<h1></h1>
<p>
{groupName} · {rangeLabel(summaryDateRange)}
</p>
</div>
{phase === 'error' && error && <div className="report-inline-error">{error}</div>}
</header>
<div className="ai-report-body">
{!sourceContact && (
<div className="report-empty-state">
<h2></h2>
<p> AI </p>
</div>
)}
<ReportRangeSelector
value={summaryDateRange}
messageCount={rangeMessageCount}
rangeState={rangeState}
disabled={configDisabled}
onChange={onSummaryDateRangeChange}
/>
<MessageTypeSelector
value={summaryMessageTypes}
counts={messageTypeCounts}
disabled={configDisabled}
onChange={onSummaryMessageTypesChange}
/>
<ReportSectionSelector />
<ReportDensitySelector />
<ModelSummary
config={modelConfig}
onOpenSettings={onOpenModelSettings}
/>
<section className="report-privacy-note">
<h3></h3>
<p></p>
<p></p>
<p>WechatExplorer </p>
</section>
{generatedImage && (
<section className="report-result-panel">
<div className="report-section-heading">
<h3></h3>
<span>{reportPaths ? 'HTML 与 PNG 已导出' : '结果已生成'}</span>
</div>
<div className="report-result-preview">
<img src={generatedImage} alt="生成的群聊日报" />
</div>
<div className="report-result-actions">
<button type="button" onClick={handleCopy}>
</button>
<button type="button" onClick={handleReveal} disabled={!reportPaths}>
</button>
<button type="button" onClick={onCloseResult}>
</button>
</div>
{actionStatus && <p className="report-action-status">{actionStatus}</p>}
</section>
)}
</div>
<footer className="ai-report-footer">
<span className="report-footer-note">
{modelLabel(modelConfig.model)} · {modelStatus} ·
</span>
<div className="report-footer-actions">
{disabledReason && !isGenerating && <span>{disabledReason}</span>}
<button type="button" disabled={!canGenerate} onClick={onGenerate}>
{isGenerating ? '正在生成日报' : '开始生成日报'}
</button>
</div>
</footer>
</main>
)
}
@@ -0,0 +1,54 @@
import React from 'react'
import {
SUMMARY_TYPE_OPTIONS,
SummaryMessageType
} from '../../utils/group-report'
interface MessageTypeSelectorProps {
value: SummaryMessageType[]
counts: Record<SummaryMessageType, number>
disabled: boolean
onChange: (value: SummaryMessageType[]) => void
}
export function MessageTypeSelector({
value,
counts,
disabled,
onChange
}: MessageTypeSelectorProps): React.ReactElement {
const toggle = (type: SummaryMessageType): void => {
if (value.includes(type)) {
if (value.length === 1) return
onChange(value.filter((item) => item !== type))
return
}
onChange([...value, type])
}
return (
<section className="report-config-section">
<div className="report-section-heading">
<h3></h3>
<span></span>
</div>
<div className="report-type-grid">
{SUMMARY_TYPE_OPTIONS.map((option) => (
<label key={option.value} className="report-check-row">
<input
type="checkbox"
checked={value.includes(option.value)}
disabled={disabled || (value.length === 1 && value.includes(option.value))}
onChange={() => toggle(option.value)}
/>
<span>
<b>{option.label}</b>
<small>{option.description}</small>
</span>
<em>{counts[option.value]}</em>
</label>
))}
</div>
</section>
)
}
@@ -0,0 +1,44 @@
import React from 'react'
import { AiModelConfig } from '../../hooks/useGroupReportGeneration'
interface ModelSummaryProps {
config: AiModelConfig
onOpenSettings: () => void
}
const MODEL_OPTIONS = [
{ value: 'deepseek-chat', label: 'DeepSeek Chat' },
{ value: 'gpt-4o', label: 'GPT-4o' },
{ value: 'gpt-4o-mini', label: 'GPT-4o Mini' },
{ value: 'gpt-4-turbo', label: 'GPT-4 Turbo' },
{ value: 'claude-3-5-sonnet-20240620', label: 'Claude 3.5 Sonnet' },
{ value: 'moonshot-v1-8k', label: 'Moonshot V1' }
]
const modelLabel = (model: string): string =>
MODEL_OPTIONS.find((option) => option.value === model)?.label || model || '未选择模型'
export function ModelSummary({
config,
onOpenSettings
}: ModelSummaryProps): React.ReactElement {
const hasApiKey = Boolean(config.apiKey.trim())
const hasBaseUrl = Boolean(config.baseURL.trim())
const statusText = hasApiKey && hasBaseUrl ? '配置正常' : hasApiKey || hasBaseUrl ? '配置不完整' : '尚未配置'
return (
<section className="report-config-section">
<div className="report-model-summary">
<div>
<h3></h3>
<p>
{modelLabel(config.model)} · {statusText}
</p>
</div>
<button type="button" onClick={onOpenSettings}>
</button>
</div>
</section>
)
}
@@ -0,0 +1,23 @@
import React from 'react'
export function ReportDensitySelector(): React.ReactElement {
return (
<section className="report-config-section">
<div className="report-section-heading">
<h3></h3>
<span>使</span>
</div>
<div className="report-density-options">
<button type="button" disabled>
<span></span>
</button>
<button type="button" className="active" disabled>
<span></span>
</button>
<button type="button" disabled>
<span></span>
</button>
</div>
</section>
)
}
@@ -0,0 +1,54 @@
import React from 'react'
import {
SUMMARY_DATE_OPTIONS,
SummaryDateRange
} from '../../utils/group-report'
import { RangeMessageState } from '../../hooks/useGroupReportGeneration'
interface ReportRangeSelectorProps {
value: SummaryDateRange
messageCount: number
rangeState: RangeMessageState
disabled: boolean
onChange: (value: SummaryDateRange) => void
}
export function ReportRangeSelector({
value,
messageCount,
rangeState,
disabled,
onChange
}: ReportRangeSelectorProps): React.ReactElement {
const countText =
rangeState.status === 'loading'
? '正在计算'
: rangeState.status === 'error'
? rangeState.error
: `${messageCount} 条消息`
return (
<section className="report-config-section">
<div className="report-section-heading">
<h3></h3>
<span className={rangeState.status === 'error' ? 'danger' : ''}>{countText}</span>
</div>
<div className="report-range-options">
{SUMMARY_DATE_OPTIONS.map((option) => (
<button
key={option.value}
type="button"
className={value === option.value ? 'active' : ''}
disabled={disabled}
onClick={() => onChange(option.value)}
>
{option.label}
</button>
))}
<button type="button" disabled title="当前业务尚未支持自定义开始和结束时间">
<span></span>
</button>
</div>
</section>
)
}
@@ -0,0 +1,34 @@
import React from 'react'
const SECTIONS = [
'今日话题',
'重要消息',
'问题与解答',
'实用资源',
'精彩对话',
'活跃成员',
'活跃时间分布',
'关键词'
]
export function ReportSectionSelector(): React.ReactElement {
return (
<section className="report-config-section">
<div className="report-section-heading">
<h3></h3>
<span></span>
</div>
<div className="report-readonly-modules" aria-label="当前模板固定生成以下内容">
{SECTIONS.map((label) => (
<span key={label}>
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<rect x="7" y="10" width="10" height="8" rx="1.5" />
<path d="M9 10V7.5a3 3 0 0 1 6 0V10" />
</svg>
{label}
</span>
))}
</div>
</section>
)
}
@@ -0,0 +1,103 @@
import React, { useMemo, useState } from 'react'
import { Contact } from '../../../../shared/types'
import { AccountSummary } from '../account/AccountSummary'
interface SelfInfo {
wxid: string
nickname: string
avatar?: string
accountRoot: string
}
interface ReportSourceSidebarProps {
contacts: Contact[]
selectedContact: Contact | null
selfInfo: SelfInfo | null
dbReady: boolean
onSelectContact: (contact: Contact) => void
onOpenSettings: () => void
}
export function ReportSourceSidebar({
contacts,
selectedContact,
selfInfo,
dbReady,
onSelectContact,
onOpenSettings
}: ReportSourceSidebarProps): React.ReactElement {
const [keyword, setKeyword] = useState('')
const groups = useMemo(() => {
const lower = keyword.trim().toLowerCase()
return contacts
.filter((contact) => contact.type === 'group')
.filter((contact) => {
if (!lower) return true
return (
contact.m_nsNickName.toLowerCase().includes(lower) ||
contact.m_nsUsrName.toLowerCase().includes(lower)
)
})
}, [contacts, keyword])
return (
<aside className="report-source-sidebar">
<div className="report-source-header">
<div>
<h2></h2>
<p></p>
</div>
<span>{groups.length} </span>
</div>
<label className="report-source-search">
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<circle cx="10.5" cy="10.5" r="5.5" />
<path d="m15 15 4 4" />
</svg>
<input
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
placeholder="搜索群聊"
/>
</label>
<div className="report-source-list">
{groups.length ? (
groups.map((contact) => {
const selected = selectedContact?.md5 === contact.md5
const nickname = contact.m_nsNickName.trim()
const displayName = nickname || contact.m_nsUsrName || '未命名群聊'
return (
<button
key={contact.md5}
type="button"
className={`report-source-item ${selected ? 'active' : ''}`}
onClick={() => onSelectContact(contact)}
title={contact.m_nsUsrName}
>
<span className="report-source-avatar">
{contact.avatar ? (
<img src={contact.avatar} alt={displayName} referrerPolicy="no-referrer" />
) : (
displayName.charAt(0)
)}
</span>
<span className="report-source-text">
<span>{displayName}</span>
</span>
</button>
)
})
) : (
<div className="report-source-empty"></div>
)}
</div>
<div className="report-source-account">
<AccountSummary
selfInfo={selfInfo}
dbReady={dbReady}
onClick={onOpenSettings}
/>
</div>
</aside>
)
}
@@ -0,0 +1,79 @@
import React from 'react'
import {
REPORT_TASK_STEPS,
ReportGenerationPhase
} from '../../hooks/useGroupReportGeneration'
interface ReportTaskStatusPanelProps {
phase: ReportGenerationPhase
error: string
onRetry: () => void
}
const phaseIndex = (phase: ReportGenerationPhase): number =>
REPORT_TASK_STEPS.findIndex((step) => step.id === phase)
export function ReportTaskStatusPanel({
phase,
error,
onRetry
}: ReportTaskStatusPanelProps): React.ReactElement {
const activeIndex = phaseIndex(phase)
const completedAll = phase === 'success'
return (
<aside className="report-task-panel">
<div className="report-task-header">
<h2></h2>
<p>
{completedAll
? '生成完成'
: phase === 'error'
? '生成失败'
: activeIndex >= 0
? `${activeIndex + 1}/${REPORT_TASK_STEPS.length}`
: '等待开始'}
</p>
</div>
<div className="report-task-steps">
{REPORT_TASK_STEPS.map((step, index) => {
const state =
completedAll || (activeIndex >= 0 && index < activeIndex)
? 'done'
: activeIndex === index
? 'active'
: 'waiting'
return (
<div key={step.id} className={`report-task-step ${state}`}>
<span className="report-task-dot" aria-hidden />
<div>
<b>{step.label}</b>
<small>
{state === 'done' ? '已完成' : state === 'active' ? '进行中' : '等待中'}
</small>
</div>
</div>
)
})}
</div>
{phase === 'error' && (
<div className="report-task-error">
<b></b>
<p>{error}</p>
<button type="button" onClick={onRetry}>
</button>
</div>
)}
{phase === 'success' && (
<div className="report-task-success">
<b></b>
<p>HTML PNG </p>
</div>
)}
<div className="report-task-note">
</div>
</aside>
)
}
@@ -0,0 +1,338 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Contact, Message } from '../../../shared/types'
import {
buildGroupReportInput,
getSummaryDateRange,
GROUP_REPORT_SYSTEM_PROMPT,
isInternalName,
parseGroupDailyReport,
SUMMARY_TYPE_OPTIONS,
SummaryDateRange,
SummaryMessageType
} from '../utils/group-report'
const REPORT_STEP_TIMEOUT_MS = 90_000
export type ReportGenerationPhase =
| 'idle'
| 'loadingMessages'
| 'preparingInput'
| 'requestingModel'
| 'exportingReport'
| 'success'
| 'error'
export interface AiModelConfig {
apiKey: string
baseURL: string
model: string
}
export interface ReportPaths {
htmlPath: string
pngPath: string
}
export interface ReportGenerationResult {
imageDataUrl: string
paths: ReportPaths
}
interface UseGroupReportGenerationArgs {
sourceContact: Contact | null
summaryDateRange: SummaryDateRange
summaryMessageTypes: SummaryMessageType[]
modelConfig: AiModelConfig
}
export interface ReportTaskStep {
id: Exclude<ReportGenerationPhase, 'idle' | 'success' | 'error'>
label: string
}
export const REPORT_TASK_STEPS: ReportTaskStep[] = [
{ id: 'loadingMessages', label: '读取并筛选聊天记录' },
{ id: 'preparingInput', label: '整理日报输入' },
{ id: 'requestingModel', label: '调用模型生成内容' },
{ id: 'exportingReport', label: '导出 HTML 与 PNG' }
]
export interface RangeMessageState {
status: 'idle' | 'loading' | 'success' | 'error'
error: string
}
const withTimeout = async <T,>(promise: Promise<T>, label: string): Promise<T> => {
let timer: number | undefined
const timeout = new Promise<never>((_, reject) => {
timer = window.setTimeout(() => reject(new Error(`${label} 超时`)), REPORT_STEP_TIMEOUT_MS)
})
try {
return await Promise.race([promise, timeout])
} finally {
if (timer) window.clearTimeout(timer)
}
}
const errorMessage = (error: unknown): string =>
error instanceof Error ? error.message : String(error)
const isGroupContact = (contact: Contact | null): boolean =>
Boolean(contact?.type === 'group' || contact?.m_nsUsrName?.endsWith('@chatroom'))
const selectedMessageTypeSet = (types: SummaryMessageType[]): Set<string> =>
new Set(
SUMMARY_TYPE_OPTIONS.filter((option) => types.includes(option.value)).flatMap(
(option) => option.messageTypes
)
)
const applyGroupMemberNames = async (
contact: Contact,
messages: Message[]
): Promise<Message[]> => {
let memberMap = new Map<string, { nickname: 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 || '' }
])
)
} catch (error) {
console.warn('[GroupReport] member snapshot failed:', error)
}
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 }
})
}
export function useGroupReportGeneration({
sourceContact,
summaryDateRange,
summaryMessageTypes,
modelConfig
}: UseGroupReportGenerationArgs): {
phase: ReportGenerationPhase
error: string
rangeMessages: Message[]
reportMessages: Message[]
messageTypeCounts: Record<SummaryMessageType, number>
rangeState: RangeMessageState
generatedImage: string | null
reportPaths: ReportPaths | null
isGenerating: boolean
generate: () => Promise<void>
retry: () => Promise<void>
clearError: () => void
closeResult: () => void
copyImage: () => Promise<{ success: boolean; error?: string }>
revealReport: () => Promise<{ success: boolean; error?: string }>
} {
const [phase, setPhase] = useState<ReportGenerationPhase>('idle')
const [error, setError] = useState('')
const [rangeMessages, setRangeMessages] = useState<Message[]>([])
const [rangeState, setRangeState] = useState<RangeMessageState>({ status: 'idle', error: '' })
const [generatedImage, setGeneratedImage] = useState<string | null>(null)
const [reportPaths, setReportPaths] = useState<ReportPaths | null>(null)
const rangeRequestIdRef = useRef(0)
const isGenerating =
phase === 'loadingMessages' ||
phase === 'preparingInput' ||
phase === 'requestingModel' ||
phase === 'exportingReport'
const loadRangeMessages = useCallback(
async (markAsTaskPhase: boolean): Promise<Message[]> => {
if (!sourceContact) return []
const requestId = ++rangeRequestIdRef.current
const { startTime, endTime } = getSummaryDateRange(summaryDateRange)
setRangeState({ status: 'loading', error: '' })
if (markAsTaskPhase) setPhase('loadingMessages')
try {
const messages = await withTimeout(
window.api.getMessages(sourceContact.md5, startTime, endTime),
'读取聊天记录'
)
if (requestId === rangeRequestIdRef.current) {
setRangeMessages(messages)
setRangeState({ status: 'success', error: '' })
}
return messages
} catch (loadError) {
const message = errorMessage(loadError)
if (requestId === rangeRequestIdRef.current) {
setRangeMessages([])
setRangeState({ status: 'error', error: message })
}
throw loadError
}
},
[sourceContact, summaryDateRange]
)
useEffect(() => {
if (!sourceContact || !isGroupContact(sourceContact)) {
rangeRequestIdRef.current += 1
setRangeMessages([])
setRangeState({ status: 'idle', error: '' })
return
}
let active = true
void loadRangeMessages(false).catch((loadError) => {
if (!active) return
console.warn('[GroupReport] range messages load failed:', loadError)
})
return () => {
active = false
}
}, [sourceContact, summaryDateRange, loadRangeMessages])
const allowedTypes = useMemo(
() => selectedMessageTypeSet(summaryMessageTypes),
[summaryMessageTypes]
)
const messageTypeCounts = useMemo(() => {
const counts = Object.fromEntries(
SUMMARY_TYPE_OPTIONS.map((option) => [option.value, 0])
) as Record<SummaryMessageType, number>
for (const message of rangeMessages) {
const option = SUMMARY_TYPE_OPTIONS.find((item) => item.messageTypes.includes(message.type))
if (option) counts[option.value] += 1
}
return counts
}, [rangeMessages])
const reportMessages = useMemo(
() => rangeMessages.filter((message) => allowedTypes.has(message.type)),
[allowedTypes, rangeMessages]
)
const generate = useCallback(async (): Promise<void> => {
if (isGenerating) return
if (!sourceContact) {
setPhase('error')
setError('请先选择一个群聊')
return
}
if (!isGroupContact(sourceContact)) {
setPhase('error')
setError('AI 群聊日报仅支持群聊')
return
}
if (!modelConfig.apiKey.trim()) {
setPhase('error')
setError('尚未配置 API Key')
return
}
if (!summaryMessageTypes.length) {
setPhase('error')
setError('请至少选择一种消息类型')
return
}
setError('')
setGeneratedImage(null)
setReportPaths(null)
try {
const sourceMessages =
rangeState.status === 'success' ? rangeMessages : await loadRangeMessages(true)
if (rangeState.status === 'success') setPhase('loadingMessages')
const selectedTypes = selectedMessageTypeSet(summaryMessageTypes)
const filteredMessages = sourceMessages.filter((message) => selectedTypes.has(message.type))
if (!filteredMessages.length) throw new Error('当前范围没有可总结消息')
setPhase('preparingInput')
const namedReportMessages = await applyGroupMemberNames(sourceContact, filteredMessages)
const input = buildGroupReportInput(namedReportMessages, sourceContact, true)
setPhase('requestingModel')
const result = await withTimeout(
window.api.aiChat(
[
{ role: 'system', content: GROUP_REPORT_SYSTEM_PROMPT },
{ role: 'user', content: input.prompt }
],
modelConfig
),
'AI 生成日报'
)
if (!result.success || !result.data) throw new Error(result.error || 'AI 请求失败')
const report = parseGroupDailyReport(result.data, input.topSpeakers, input.activeTimeline)
setPhase('exportingReport')
const exported = await withTimeout(
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 })
setPhase('success')
} catch (generateError) {
setError(errorMessage(generateError))
setPhase('error')
}
}, [
isGenerating,
loadRangeMessages,
modelConfig,
rangeMessages,
rangeState.status,
sourceContact,
summaryMessageTypes
])
const clearError = useCallback((): void => {
setError('')
setPhase('idle')
}, [])
const closeResult = useCallback((): void => {
setGeneratedImage(null)
}, [])
const copyImage = useCallback(async (): Promise<{ success: boolean; error?: string }> => {
if (!generatedImage) return { success: false, error: '没有可复制的日报图片' }
return window.api.copyImage(generatedImage)
}, [generatedImage])
const revealReport = useCallback(async (): Promise<{ success: boolean; error?: string }> => {
if (!reportPaths) return { success: false, error: '没有可显示的日报文件' }
return window.api.revealGroupReport(reportPaths.pngPath)
}, [reportPaths])
return {
phase,
error,
rangeMessages,
reportMessages,
messageTypeCounts,
rangeState,
generatedImage,
reportPaths,
isGenerating,
generate,
retry: generate,
clearError,
closeResult,
copyImage,
revealReport
}
}
+81
View File
@@ -11,6 +11,65 @@ import {
ReportTopic
} from '../../../shared/group-report'
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: '不做视觉识别,仅使用图片类型、发送者和时间。'
},
{
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 GROUP_REPORT_SYSTEM_PROMPT = `你是微信群聊日报编辑。请仅根据用户提供的聊天记录生成结构化中文日报。
原则:
@@ -46,6 +105,28 @@ topics 提取 3 至 7 个,参与者最多 5 人,quotes 最多 3 组,keywor
const isInternalIdentifier = (value: string): boolean =>
/@chatroom$/i.test(value) || /^wxid_/i.test(value) || /^[a-z0-9_-]{18,}$/i.test(value)
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)
)
}
const summarySender = (message: Message, contact: Contact | null, isGroup: boolean): string => {
if (message.from === 'assistant') {
const ownGroupNickname = message.name?.trim()