From 16a9aed6bb3d314fdfb0939db327d0b38a07c950 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=B5=E6=91=87=E5=B0=8F=E5=AD=90?= <969409112@qq.com> Date: Sun, 12 Jul 2026 03:09:28 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=9E=84=20UI-04=20AI=20=E7=BE=A4?= =?UTF-8?q?=E8=81=8A=E6=97=A5=E6=8A=A5=E5=B7=A5=E4=BD=9C=E5=8C=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/renderer/src/App.tsx | 203 +++++- src/renderer/src/assets/main.css | 662 ++++++++++++++++++ src/renderer/src/components/ChatWindow.tsx | 336 +-------- src/renderer/src/components/SettingsPanel.tsx | 71 ++ .../src/components/chat/ChatHeader.tsx | 2 +- .../src/components/layout/AppShell.tsx | 12 +- .../components/reports/AiReportWorkspace.tsx | 191 +++++ .../reports/MessageTypeSelector.tsx | 54 ++ .../src/components/reports/ModelSummary.tsx | 44 ++ .../reports/ReportDensitySelector.tsx | 23 + .../reports/ReportRangeSelector.tsx | 54 ++ .../reports/ReportSectionSelector.tsx | 34 + .../reports/ReportSourceSidebar.tsx | 103 +++ .../reports/ReportTaskStatusPanel.tsx | 79 +++ .../src/hooks/useGroupReportGeneration.ts | 338 +++++++++ src/renderer/src/utils/group-report.ts | 81 +++ 16 files changed, 1919 insertions(+), 368 deletions(-) create mode 100644 src/renderer/src/components/reports/AiReportWorkspace.tsx create mode 100644 src/renderer/src/components/reports/MessageTypeSelector.tsx create mode 100644 src/renderer/src/components/reports/ModelSummary.tsx create mode 100644 src/renderer/src/components/reports/ReportDensitySelector.tsx create mode 100644 src/renderer/src/components/reports/ReportRangeSelector.tsx create mode 100644 src/renderer/src/components/reports/ReportSectionSelector.tsx create mode 100644 src/renderer/src/components/reports/ReportSourceSidebar.tsx create mode 100644 src/renderer/src/components/reports/ReportTaskStatusPanel.tsx create mode 100644 src/renderer/src/hooks/useGroupReportGeneration.ts diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 1edfcd8..fc828ff 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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('archive') + const [reportSourceContact, setReportSourceContact] = useState(null) + const [reportNotice, setReportNotice] = useState('') + const [summaryDateRange, setSummaryDateRange] = useState('today') + const [summaryMessageTypes, setSummaryMessageTypes] = useState(['text']) + const [aiModelConfig, setAiModelConfig] = useState(() => ({ + 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(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('') const contactAvatarHydrationRunRef = React.useRef(0) + const reportGeneration = useGroupReportGeneration({ + sourceContact: reportSourceContact, + summaryDateRange, + summaryMessageTypes, + modelConfig: aiModelConfig + }) + const waitForPaint = (): Promise => 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): React.ReactElement => { + const labels: Record, string> = { + search: '检索', + export: '导出', + api: 'API', + settings: '设置' + } + return ( +
+
WechatExplorer
+

{labels[page]}

+

这个工作区会在后续 UI 重构阶段接入真实功能。

+
+ ) + } + + const renderArchiveWorkspace = (): React.ReactElement => ( +
+ setShowSettings(true)} + /> +
+ selectedContact && handleSelectContact(selectedContact)} + onRefreshData={loadContacts} + onCreateGroupReport={handleOpenReportWorkspace} + isAiLoading={reportGeneration.isGenerating} + /> +
+ ) + + const renderReportWorkspace = (): React.ReactElement => ( +
+ setShowSettings(true)} + /> + setShowSettings(true)} + onGenerate={() => void reportGeneration.generate()} + onCloseResult={reportGeneration.closeResult} + onCopyImage={reportGeneration.copyImage} + onRevealReport={reportGeneration.revealReport} + /> + void reportGeneration.retry()} + /> +
+ ) + + 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) }} > -
- setShowSettings(true)} - /> -
- selectedContact && handleSelectContact(selectedContact)} - onRefreshData={loadContacts} - /> -
+ {reportNotice &&
{reportNotice}
} + {renderCurrentWorkspace()} setShowSettings(false)} onDbKeyChange={setDbKey} + onAiModelConfigChange={setAiModelConfig} + onSaveAiModelConfig={handleSaveAiModelConfig} onDbRootChanged={() => { void refreshSelfInfo() void loadContacts() diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 66f5bf0..750ea18 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -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; +} diff --git a/src/renderer/src/components/ChatWindow.tsx b/src/renderer/src/components/ChatWindow.tsx index 4a2cf25..024728f 100644 --- a/src/renderer/src/components/ChatWindow.tsx +++ b/src/renderer/src/components/ChatWindow.tsx @@ -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 = { today: '今天', yesterday: '昨日', @@ -71,52 +59,6 @@ const getChatHeaderRangeLabel = (range: string): string => { return DATE_RANGE_LABELS[range] || '当前范围' } -const withTimeout = async (promise: Promise, label: string): Promise => { - let timer: number | undefined - const timeout = new Promise((_, 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 = ({ contact, messages, @@ -125,15 +67,15 @@ const ChatWindow: React.FC = ({ dateRange = 'today', onContentFilterChange, onRefresh, - onRefreshData + onRefreshData, + onCreateGroupReport, + isAiLoading = false }) => { const isGroupChat = Boolean( contact?.type === 'group' || contact?.m_nsUsrName?.endsWith('@chatroom') ) const messageListRef = useRef(null) const messagesEndRef = useRef(null) - const [generatedImage, setGeneratedImage] = useState(null) - const [reportPaths, setReportPaths] = useState<{ htmlPath: string; pngPath: string } | null>(null) const [previewImage, setPreviewImage] = useState(null) const [imageScale, setImageScale] = useState(0.75) const [imageRotation, setImageRotation] = useState(0) @@ -143,36 +85,8 @@ const ChatWindow: React.FC = ({ 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('today') - const [summaryMessageTypes, setSummaryMessageTypes] = useState(['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 = ({ }, []) 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 = ({ document.body.removeChild(link) } - const [isLoading, setIsLoading] = useState(false) - - const AIChat = async (): Promise => { - 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() - 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 => { - 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 = ({ 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)} /> = ({ onJumpToLatest={scrollToBottom} /> - {/* 加载模态框 */} - {isLoading && ( -
-
-
正在生成群聊日报...
-
- 正在分析记录、处理头像并生成 HTML 和长图 -
-
-
- )} - - {/* 图片预览模态框 */} - {generatedImage && ( -
setGeneratedImage(null)}> -
e.stopPropagation()}> -
-
- Generated Summary -
-
-
- - {reportPaths && ( - - )} - -
-
-
- )} - {previewImage && (
e.stopPropagation()}> @@ -563,100 +339,6 @@ const ChatWindow: React.FC = ({
)} - - {/* AI Settings Modal */} - {showSettingsModal && ( -
setShowSettingsModal(false)}> -
e.stopPropagation()}> -

AI 设置

-
-
时间范围
-
- {SUMMARY_DATE_OPTIONS.map((option) => ( - - ))} -
-
-
-
消息类型
-
- {SUMMARY_TYPE_OPTIONS.map((option) => ( - - ))} -
-
-
- - -
-
- - setBaseURL(e.target.value)} - placeholder="https://api.deepseek.com" - style={{ width: '95%', padding: '8px' }} - /> -
-
- - setApiKey(e.target.value)} - placeholder="Enter your API Key" - style={{ width: '95%', padding: '8px' }} - /> -
-
- - -
-
-
- )}
) } diff --git a/src/renderer/src/components/SettingsPanel.tsx b/src/renderer/src/components/SettingsPanel.tsx index 01d550a..06ebb6f 100644 --- a/src/renderer/src/components/SettingsPanel.tsx +++ b/src/renderer/src/components/SettingsPanel.tsx @@ -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 = ({ 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 = ({
+
+
AI 模型配置
+
+ + +
+
+ + onAiModelConfigChange({ ...aiModelConfig, baseURL: event.target.value }) + } + placeholder="https://api.deepseek.com" + spellCheck={false} + /> +
+
+ + onAiModelConfigChange({ ...aiModelConfig, apiKey: event.target.value }) + } + placeholder="API Key" + spellCheck={false} + /> +
+
+ 所选内容会发送至你配置的模型服务进行处理。配置沿用原有本地 + localStorage 保存方式。 +
+
+ {/* API 服务 */}
本地 HTTP API
diff --git a/src/renderer/src/components/chat/ChatHeader.tsx b/src/renderer/src/components/chat/ChatHeader.tsx index 5c09b30..da8a8a9 100644 --- a/src/renderer/src/components/chat/ChatHeader.tsx +++ b/src/renderer/src/components/chat/ChatHeader.tsx @@ -124,7 +124,7 @@ export function ChatHeader({ type="button" className="chat-ai-button" onClick={onOpenAiSettings} - disabled={!isGroupChat || isAiLoading} + disabled={isAiLoading} title={isGroupChat ? '生成 AI 日报' : 'AI 日报当前仅支持群聊'} > diff --git a/src/renderer/src/components/layout/AppShell.tsx b/src/renderer/src/components/layout/AppShell.tsx index 87db084..65b8499 100644 --- a/src/renderer/src/components/layout/AppShell.tsx +++ b/src/renderer/src/components/layout/AppShell.tsx @@ -57,16 +57,8 @@ export function AppShell({ -
- {activePage === 'archive' ? ( - children - ) : ( -
-
WechatExplorer
-

{activeItem?.label || '工作区'}

-

这个工作区会在后续 UI 重构阶段接入真实功能。

-
- )} +
+ {children}
) diff --git a/src/renderer/src/components/reports/AiReportWorkspace.tsx b/src/renderer/src/components/reports/AiReportWorkspace.tsx new file mode 100644 index 0000000..3b55b88 --- /dev/null +++ b/src/renderer/src/components/reports/AiReportWorkspace.tsx @@ -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 + 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 => { + const result = await onCopyImage() + setActionStatus(result.success ? '图片已复制' : result.error || '复制失败') + } + + const handleReveal = async (): Promise => { + const result = await onRevealReport() + setActionStatus(result.success ? '已在文件夹中显示' : result.error || '打开文件夹失败') + } + + return ( +
+
+
+

生成群聊日报

+

+ {groupName} · {rangeLabel(summaryDateRange)} +

+
+ {phase === 'error' && error &&
{error}
} +
+ +
+ {!sourceContact && ( +
+

未选择群聊

+

从左侧选择一个群聊后,可以配置范围并生成 AI 群聊日报。

+
+ )} + + + + + + +
+

隐私说明

+

微信数据库和聊天记录默认从本机读取。

+

所选内容将发送至你配置的模型服务进行处理。

+

WechatExplorer 本身不额外保存或转发内容。

+
+ + {generatedImage && ( +
+
+

生成成功

+ {reportPaths ? 'HTML 与 PNG 已导出' : '结果已生成'} +
+
+ 生成的群聊日报 +
+
+ + + +
+ {actionStatus &&

{actionStatus}

} +
+ )} +
+ +
+ + {modelLabel(modelConfig.model)} · {modelStatus} · 所选内容会发送至你配置的模型服务 + +
+ {disabledReason && !isGenerating && {disabledReason}} + +
+
+
+ ) +} diff --git a/src/renderer/src/components/reports/MessageTypeSelector.tsx b/src/renderer/src/components/reports/MessageTypeSelector.tsx new file mode 100644 index 0000000..59faf9c --- /dev/null +++ b/src/renderer/src/components/reports/MessageTypeSelector.tsx @@ -0,0 +1,54 @@ +import React from 'react' +import { + SUMMARY_TYPE_OPTIONS, + SummaryMessageType +} from '../../utils/group-report' + +interface MessageTypeSelectorProps { + value: SummaryMessageType[] + counts: Record + 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 ( +
+
+

纳入的消息类型

+ 至少选择一种 +
+
+ {SUMMARY_TYPE_OPTIONS.map((option) => ( + + ))} +
+
+ ) +} diff --git a/src/renderer/src/components/reports/ModelSummary.tsx b/src/renderer/src/components/reports/ModelSummary.tsx new file mode 100644 index 0000000..d0d44de --- /dev/null +++ b/src/renderer/src/components/reports/ModelSummary.tsx @@ -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 ( +
+
+
+

模型配置

+

+ {modelLabel(config.model)} · {statusText} +

+
+ +
+
+ ) +} diff --git a/src/renderer/src/components/reports/ReportDensitySelector.tsx b/src/renderer/src/components/reports/ReportDensitySelector.tsx new file mode 100644 index 0000000..2242d19 --- /dev/null +++ b/src/renderer/src/components/reports/ReportDensitySelector.tsx @@ -0,0 +1,23 @@ +import React from 'react' + +export function ReportDensitySelector(): React.ReactElement { + return ( +
+
+

内容密度

+ 当前提示词使用标准密度 +
+
+ + + +
+
+ ) +} diff --git a/src/renderer/src/components/reports/ReportRangeSelector.tsx b/src/renderer/src/components/reports/ReportRangeSelector.tsx new file mode 100644 index 0000000..f40a290 --- /dev/null +++ b/src/renderer/src/components/reports/ReportRangeSelector.tsx @@ -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 ( +
+
+

总结范围

+ {countText} +
+
+ {SUMMARY_DATE_OPTIONS.map((option) => ( + + ))} + +
+
+ ) +} diff --git a/src/renderer/src/components/reports/ReportSectionSelector.tsx b/src/renderer/src/components/reports/ReportSectionSelector.tsx new file mode 100644 index 0000000..ae08a54 --- /dev/null +++ b/src/renderer/src/components/reports/ReportSectionSelector.tsx @@ -0,0 +1,34 @@ +import React from 'react' + +const SECTIONS = [ + '今日话题', + '重要消息', + '问题与解答', + '实用资源', + '精彩对话', + '活跃成员', + '活跃时间分布', + '关键词' +] + +export function ReportSectionSelector(): React.ReactElement { + return ( +
+
+

日报内容

+ 当前模板固定生成以下内容 +
+
+ {SECTIONS.map((label) => ( + + + {label} + + ))} +
+
+ ) +} diff --git a/src/renderer/src/components/reports/ReportSourceSidebar.tsx b/src/renderer/src/components/reports/ReportSourceSidebar.tsx new file mode 100644 index 0000000..1a47b32 --- /dev/null +++ b/src/renderer/src/components/reports/ReportSourceSidebar.tsx @@ -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 ( + + ) +} diff --git a/src/renderer/src/components/reports/ReportTaskStatusPanel.tsx b/src/renderer/src/components/reports/ReportTaskStatusPanel.tsx new file mode 100644 index 0000000..6cd4581 --- /dev/null +++ b/src/renderer/src/components/reports/ReportTaskStatusPanel.tsx @@ -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 ( + + ) +} diff --git a/src/renderer/src/hooks/useGroupReportGeneration.ts b/src/renderer/src/hooks/useGroupReportGeneration.ts new file mode 100644 index 0000000..2cc56e4 --- /dev/null +++ b/src/renderer/src/hooks/useGroupReportGeneration.ts @@ -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 + 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 (promise: Promise, label: string): Promise => { + let timer: number | undefined + const timeout = new Promise((_, 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 => + new Set( + SUMMARY_TYPE_OPTIONS.filter((option) => types.includes(option.value)).flatMap( + (option) => option.messageTypes + ) + ) + +const applyGroupMemberNames = async ( + contact: Contact, + messages: Message[] +): Promise => { + let memberMap = new Map() + 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 + rangeState: RangeMessageState + generatedImage: string | null + reportPaths: ReportPaths | null + isGenerating: boolean + generate: () => Promise + retry: () => Promise + clearError: () => void + closeResult: () => void + copyImage: () => Promise<{ success: boolean; error?: string }> + revealReport: () => Promise<{ success: boolean; error?: string }> +} { + const [phase, setPhase] = useState('idle') + const [error, setError] = useState('') + const [rangeMessages, setRangeMessages] = useState([]) + const [rangeState, setRangeState] = useState({ status: 'idle', error: '' }) + const [generatedImage, setGeneratedImage] = useState(null) + const [reportPaths, setReportPaths] = useState(null) + const rangeRequestIdRef = useRef(0) + + const isGenerating = + phase === 'loadingMessages' || + phase === 'preparingInput' || + phase === 'requestingModel' || + phase === 'exportingReport' + + const loadRangeMessages = useCallback( + async (markAsTaskPhase: boolean): Promise => { + 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 + 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 => { + 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 + } +} diff --git a/src/renderer/src/utils/group-report.ts b/src/renderer/src/utils/group-report.ts index 4cabec7..74389f4 100644 --- a/src/renderer/src/utils/group-report.ts +++ b/src/renderer/src/utils/group-report.ts @@ -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()