mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-17 03:27:00 +08:00
重构 UI-04 AI 群聊日报工作区
This commit is contained in:
+173
-30
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user