mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-05 04:20:34 +08:00
fix: 增加thinking disabled
修改退群监控模板 定时日报保存日报历史
This commit is contained in:
@@ -174,7 +174,7 @@ export async function saveGeneratedReport(
|
|||||||
dateRange: request.dateRange,
|
dateRange: request.dateRange,
|
||||||
messageCount: request.messageCount,
|
messageCount: request.messageCount,
|
||||||
generatedAt: timestamp.toISOString(),
|
generatedAt: timestamp.toISOString(),
|
||||||
reportDate: `${year}-${month}-${pad2(timestamp.getDate())}`,
|
reportDate: request.reportDate?.trim() || `${year}-${month}-${pad2(timestamp.getDate())}`,
|
||||||
htmlPath: savedHtmlPath,
|
htmlPath: savedHtmlPath,
|
||||||
pngPath: savedPngPath,
|
pngPath: savedPngPath,
|
||||||
jsonPath,
|
jsonPath,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Contact, Message } from '../../shared/types'
|
import type { Contact, Message } from '../../shared/types'
|
||||||
|
import type { GroupDailyReport, GroupReportMetadata } from '../../shared/group-report'
|
||||||
import { exportGroupReport } from '../group-report-service'
|
import { exportGroupReport } from '../group-report-service'
|
||||||
import { getGroupSnapshot, listMessages, resolveMd5 } from './chat-service'
|
import { getGroupSnapshot, listMessages, resolveMd5 } from './chat-service'
|
||||||
import { AIProviderService } from './ai-provider-service'
|
import { AIProviderService } from './ai-provider-service'
|
||||||
@@ -32,14 +33,26 @@ export interface AgentGroupReportRequest {
|
|||||||
export interface AgentGroupReportResult {
|
export interface AgentGroupReportResult {
|
||||||
success: boolean
|
success: boolean
|
||||||
groupName?: string
|
groupName?: string
|
||||||
|
htmlPath?: string
|
||||||
pngPath?: string
|
pngPath?: string
|
||||||
messageCount?: number
|
messageCount?: number
|
||||||
|
reportSnapshot?: GroupDailyReport
|
||||||
|
reportMetadata?: GroupReportMetadata
|
||||||
|
modelName?: string
|
||||||
|
tokenUsage?: {
|
||||||
|
input?: number
|
||||||
|
output?: number
|
||||||
|
total?: number
|
||||||
|
estimated?: boolean
|
||||||
|
}
|
||||||
|
duration?: number
|
||||||
error?: string
|
error?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateAgentGroupReport(
|
export async function generateAgentGroupReport(
|
||||||
request: AgentGroupReportRequest
|
request: AgentGroupReportRequest
|
||||||
): Promise<AgentGroupReportResult> {
|
): Promise<AgentGroupReportResult> {
|
||||||
|
const startedAt = Date.now()
|
||||||
const query = String(request.group || '')
|
const query = String(request.group || '')
|
||||||
.trim()
|
.trim()
|
||||||
.replace(/群聊?$/, '')
|
.replace(/群聊?$/, '')
|
||||||
@@ -54,7 +67,10 @@ export async function generateAgentGroupReport(
|
|||||||
const range = request.range === 'yesterday' || request.range === '7days' ? request.range : 'today'
|
const range = request.range === 'yesterday' || request.range === '7days' ? request.range : 'today'
|
||||||
const { startTime, endTime } =
|
const { startTime, endTime } =
|
||||||
request.range === 'recent24h'
|
request.range === 'recent24h'
|
||||||
? { startTime: Math.floor(Date.now() / 1000) - 86400, endTime: Math.floor(Date.now() / 1000) }
|
? (() => {
|
||||||
|
const now = Math.floor(Date.now() / 1000)
|
||||||
|
return { startTime: now - 86400, endTime: now }
|
||||||
|
})()
|
||||||
: getSummaryDateRange(range)
|
: getSummaryDateRange(range)
|
||||||
let messages = listMessages(contact.md5, startTime, endTime) as Message[]
|
let messages = listMessages(contact.md5, startTime, endTime) as Message[]
|
||||||
if (!messages.length) return { success: false, error: '所选时间范围没有可总结的消息' }
|
if (!messages.length) return { success: false, error: '所选时间范围没有可总结的消息' }
|
||||||
@@ -98,6 +114,7 @@ export async function generateAgentGroupReport(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const input = await buildGroupReportInput(messages, contact as Contact, true, 'full')
|
const input = await buildGroupReportInput(messages, contact as Contact, true, 'full')
|
||||||
|
const runtime = aiProvider.getRuntimeConfig()
|
||||||
const ai = await aiProvider.chat(
|
const ai = await aiProvider.chat(
|
||||||
[
|
[
|
||||||
{ role: 'system', content: GROUP_REPORT_SYSTEM_PROMPT },
|
{ role: 'system', content: GROUP_REPORT_SYSTEM_PROMPT },
|
||||||
@@ -106,6 +123,7 @@ export async function generateAgentGroupReport(
|
|||||||
{ timeoutMs: Math.max(30, Math.min(1800, request.timeoutSeconds || 300)) * 1000 }
|
{ timeoutMs: Math.max(30, Math.min(1800, request.timeoutSeconds || 300)) * 1000 }
|
||||||
)
|
)
|
||||||
if (!ai.success || !ai.data) return { success: false, error: ai.error || 'AI 总结失败' }
|
if (!ai.success || !ai.data) return { success: false, error: ai.error || 'AI 总结失败' }
|
||||||
|
let tokenUsage = ai.usage
|
||||||
const parseReport = (raw: string): ReturnType<typeof parseGroupDailyReport> =>
|
const parseReport = (raw: string): ReturnType<typeof parseGroupDailyReport> =>
|
||||||
parseGroupDailyReport(
|
parseGroupDailyReport(
|
||||||
raw,
|
raw,
|
||||||
@@ -133,6 +151,7 @@ export async function generateAgentGroupReport(
|
|||||||
error: `${repaired.error || 'AI 修复日报 JSON 失败'}(原始错误:${cause})`
|
error: `${repaired.error || 'AI 修复日报 JSON 失败'}(原始错误:${cause})`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
tokenUsage = mergeTokenUsage(tokenUsage, repaired.usage)
|
||||||
try {
|
try {
|
||||||
report = parseReport(repaired.data)
|
report = parseReport(repaired.data)
|
||||||
} catch (repairError) {
|
} catch (repairError) {
|
||||||
@@ -153,7 +172,27 @@ export async function generateAgentGroupReport(
|
|||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
groupName: input.metadata.groupName,
|
groupName: input.metadata.groupName,
|
||||||
|
htmlPath: exported.htmlPath,
|
||||||
pngPath: exported.pngPath,
|
pngPath: exported.pngPath,
|
||||||
messageCount: messages.length
|
messageCount: messages.length,
|
||||||
|
reportSnapshot: report,
|
||||||
|
reportMetadata: input.metadata,
|
||||||
|
modelName: runtime.modelName || runtime.model,
|
||||||
|
tokenUsage,
|
||||||
|
duration: Date.now() - startedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeTokenUsage(
|
||||||
|
first: AgentGroupReportResult['tokenUsage'],
|
||||||
|
second: AgentGroupReportResult['tokenUsage']
|
||||||
|
): AgentGroupReportResult['tokenUsage'] {
|
||||||
|
if (!first) return second
|
||||||
|
if (!second) return first
|
||||||
|
return {
|
||||||
|
input: (first.input || 0) + (second.input || 0),
|
||||||
|
output: (first.output || 0) + (second.output || 0),
|
||||||
|
total: (first.total || 0) + (second.total || 0),
|
||||||
|
estimated: Boolean(first.estimated || second.estimated)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,13 @@ type AIRequestResult = {
|
|||||||
}
|
}
|
||||||
interface OpenAIResponsePayload {
|
interface OpenAIResponsePayload {
|
||||||
error?: { message?: string }
|
error?: { message?: string }
|
||||||
choices?: Array<{ message?: { content?: string }; finish_reason?: string }>
|
choices?: Array<{
|
||||||
|
message?: {
|
||||||
|
content?: string | Array<{ type?: string; text?: string }> | null
|
||||||
|
reasoning_content?: string
|
||||||
|
}
|
||||||
|
finish_reason?: string
|
||||||
|
}>
|
||||||
usage?: { prompt_tokens?: number; completion_tokens?: number; total_tokens?: number }
|
usage?: { prompt_tokens?: number; completion_tokens?: number; total_tokens?: number }
|
||||||
}
|
}
|
||||||
interface OpenAIStreamPayload {
|
interface OpenAIStreamPayload {
|
||||||
@@ -263,6 +269,7 @@ export class AIProviderService {
|
|||||||
): Promise<{
|
): Promise<{
|
||||||
success: boolean
|
success: boolean
|
||||||
data?: string
|
data?: string
|
||||||
|
finishReason?: string
|
||||||
usage?: { input?: number; output?: number; total?: number; estimated?: boolean }
|
usage?: { input?: number; output?: number; total?: number; estimated?: boolean }
|
||||||
error?: string
|
error?: string
|
||||||
}> {
|
}> {
|
||||||
@@ -352,6 +359,7 @@ export class AIProviderService {
|
|||||||
onDelta?: AIChatDeltaHandler
|
onDelta?: AIChatDeltaHandler
|
||||||
): Promise<{
|
): Promise<{
|
||||||
data: string
|
data: string
|
||||||
|
finishReason?: string
|
||||||
usage?: { input?: number; output?: number; total?: number; estimated?: boolean }
|
usage?: { input?: number; output?: number; total?: number; estimated?: boolean }
|
||||||
}> {
|
}> {
|
||||||
if (options?.apiKey) return this.requestLegacy(messages, options, signal, onDelta)
|
if (options?.apiKey) return this.requestLegacy(messages, options, signal, onDelta)
|
||||||
@@ -634,6 +642,18 @@ function toOpenAIMessages(messages: AIMessage[]): Array<{ role: string; content:
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openAIMessageText(content: unknown): string {
|
||||||
|
if (typeof content === 'string') return content
|
||||||
|
if (!Array.isArray(content)) return ''
|
||||||
|
return content
|
||||||
|
.map((part) => {
|
||||||
|
if (!part || typeof part !== 'object') return ''
|
||||||
|
const text = (part as { text?: unknown }).text
|
||||||
|
return typeof text === 'string' ? text : ''
|
||||||
|
})
|
||||||
|
.join('')
|
||||||
|
}
|
||||||
|
|
||||||
function toOpenAIResponsesRequest(messages: AIMessage[]): {
|
function toOpenAIResponsesRequest(messages: AIMessage[]): {
|
||||||
instructions?: string
|
instructions?: string
|
||||||
input: Array<{ role: string; content: unknown[] }>
|
input: Array<{ role: string; content: unknown[] }>
|
||||||
@@ -723,6 +743,7 @@ async function requestOpenAICompatible(
|
|||||||
messages: toOpenAIMessages(messages),
|
messages: toOpenAIMessages(messages),
|
||||||
temperature: provider.advanced.temperature,
|
temperature: provider.advanced.temperature,
|
||||||
max_tokens: testing ? 8 : modelMaxTokens(provider, model),
|
max_tokens: testing ? 8 : modelMaxTokens(provider, model),
|
||||||
|
...(provider.advanced.thinking === 'disabled' ? { thinking: { type: 'disabled' } } : {}),
|
||||||
...(provider.advanced.stream ? { stream: true } : {})
|
...(provider.advanced.stream ? { stream: true } : {})
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@@ -741,7 +762,7 @@ async function requestOpenAICompatible(
|
|||||||
}
|
}
|
||||||
const payload = await parseJsonResponse<OpenAIResponsePayload>(response)
|
const payload = await parseJsonResponse<OpenAIResponsePayload>(response)
|
||||||
return {
|
return {
|
||||||
data: String(payload.choices?.[0]?.message?.content || ''),
|
data: openAIMessageText(payload.choices?.[0]?.message?.content),
|
||||||
finishReason: String(payload.choices?.[0]?.finish_reason || 'unknown'),
|
finishReason: String(payload.choices?.[0]?.finish_reason || 'unknown'),
|
||||||
usage: toOpenAIUsage(payload.usage)
|
usage: toOpenAIUsage(payload.usage)
|
||||||
}
|
}
|
||||||
@@ -935,7 +956,7 @@ async function parseOpenAIStream(
|
|||||||
throw new Error('模型服务返回了无法解析的流式数据')
|
throw new Error('模型服务返回了无法解析的流式数据')
|
||||||
}
|
}
|
||||||
if (payload.error?.message) throw new Error(payload.error.message)
|
if (payload.error?.message) throw new Error(payload.error.message)
|
||||||
const content = String(payload.choices?.[0]?.message?.content || '')
|
const content = openAIMessageText(payload.choices?.[0]?.message?.content)
|
||||||
if (content) onDelta?.(content)
|
if (content) onDelta?.(content)
|
||||||
return {
|
return {
|
||||||
data: content,
|
data: content,
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import type {
|
|||||||
ScheduledReportTask,
|
ScheduledReportTask,
|
||||||
ScheduledReportUpdateInput
|
ScheduledReportUpdateInput
|
||||||
} from '../../shared/scheduled-report'
|
} from '../../shared/scheduled-report'
|
||||||
|
import type { SaveGeneratedReportRequest } from '../../shared/report-history'
|
||||||
|
import { saveGeneratedReport } from '../report-history-service'
|
||||||
import { generateAgentGroupReport } from './agent-group-report-service'
|
import { generateAgentGroupReport } from './agent-group-report-service'
|
||||||
import { personalWechatSendService } from './personal-wechat-send-service'
|
import { personalWechatSendService } from './personal-wechat-send-service'
|
||||||
import { personalWechatCapabilityService } from './personal-wechat-capability-service'
|
import { personalWechatCapabilityService } from './personal-wechat-capability-service'
|
||||||
@@ -29,6 +31,9 @@ const TICK_MS = 15_000
|
|||||||
export interface ScheduledReportDependencies {
|
export interface ScheduledReportDependencies {
|
||||||
getCapability: () => Promise<PersonalWechatSendCapability>
|
getCapability: () => Promise<PersonalWechatSendCapability>
|
||||||
generateReport: typeof generateAgentGroupReport
|
generateReport: typeof generateAgentGroupReport
|
||||||
|
saveGeneratedReport: (
|
||||||
|
request: SaveGeneratedReportRequest
|
||||||
|
) => ReturnType<typeof saveGeneratedReport>
|
||||||
send: (request: PersonalWechatSendRequest) => ReturnType<typeof personalWechatSendService.send>
|
send: (request: PersonalWechatSendRequest) => ReturnType<typeof personalWechatSendService.send>
|
||||||
storageDir: string
|
storageDir: string
|
||||||
isDatabaseReady: () => boolean
|
isDatabaseReady: () => boolean
|
||||||
@@ -38,6 +43,7 @@ export interface ScheduledReportDependencies {
|
|||||||
const defaultDependencies = (): ScheduledReportDependencies => ({
|
const defaultDependencies = (): ScheduledReportDependencies => ({
|
||||||
getCapability: () => personalWechatCapabilityService.getPersonalWechatSendCapability(),
|
getCapability: () => personalWechatCapabilityService.getPersonalWechatSendCapability(),
|
||||||
generateReport: generateAgentGroupReport,
|
generateReport: generateAgentGroupReport,
|
||||||
|
saveGeneratedReport,
|
||||||
send: (request) => personalWechatSendService.send(request),
|
send: (request) => personalWechatSendService.send(request),
|
||||||
storageDir: path.join(app.getPath('userData'), STORAGE_DIR),
|
storageDir: path.join(app.getPath('userData'), STORAGE_DIR),
|
||||||
isDatabaseReady: () => isChatReady()
|
isDatabaseReady: () => isChatReady()
|
||||||
@@ -45,6 +51,15 @@ const defaultDependencies = (): ScheduledReportDependencies => ({
|
|||||||
|
|
||||||
const rangeValues = new Set<ScheduledReportRange>(['today', 'yesterday', '7days', 'recent24h'])
|
const rangeValues = new Set<ScheduledReportRange>(['today', 'yesterday', '7days', 'recent24h'])
|
||||||
|
|
||||||
|
const reportRangeLabel = (range: ScheduledReportRange): string =>
|
||||||
|
range === 'recent24h'
|
||||||
|
? '最近 24 小时'
|
||||||
|
: range === '7days'
|
||||||
|
? '近 7 天'
|
||||||
|
: range === 'today'
|
||||||
|
? '今天'
|
||||||
|
: '昨日'
|
||||||
|
|
||||||
export function validateScheduleTime(value: string): boolean {
|
export function validateScheduleTime(value: string): boolean {
|
||||||
return /^(?:[01]\d|2[0-3]):[0-5]\d$/.test(String(value || '').trim())
|
return /^(?:[01]\d|2[0-3]):[0-5]\d$/.test(String(value || '').trim())
|
||||||
}
|
}
|
||||||
@@ -309,6 +324,38 @@ export class ScheduledReportService {
|
|||||||
if (!generated.success || !generated.pngPath) {
|
if (!generated.success || !generated.pngPath) {
|
||||||
return finish('failed', `report_generation_failed:${generated.error || '日报生成失败'}`)
|
return finish('failed', `report_generation_failed:${generated.error || '日报生成失败'}`)
|
||||||
}
|
}
|
||||||
|
const reportContact = resolveMd5(task.group)
|
||||||
|
let savedHistory: Awaited<ReturnType<typeof this.deps.saveGeneratedReport>>
|
||||||
|
try {
|
||||||
|
savedHistory = await this.deps.saveGeneratedReport({
|
||||||
|
contactId: reportContact?.md5 || task.group,
|
||||||
|
contactName: generated.groupName || reportContact?.m_nsNickName || task.group,
|
||||||
|
contactAvatar: reportContact?.avatar,
|
||||||
|
dateRange: generated.reportMetadata?.dateRange || reportRangeLabel(task.reportRange),
|
||||||
|
reportDate: generated.reportMetadata?.reportDate,
|
||||||
|
messageCount: generated.messageCount || generated.reportMetadata?.messageCount || 0,
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
htmlPath: generated.htmlPath,
|
||||||
|
pngPath: generated.pngPath,
|
||||||
|
duration: generated.duration,
|
||||||
|
modelName: generated.modelName,
|
||||||
|
tokenUsage: generated.tokenUsage,
|
||||||
|
reportSnapshot: generated.reportSnapshot,
|
||||||
|
reportMetadata: generated.reportMetadata,
|
||||||
|
templateId: task.templateId
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
return finish(
|
||||||
|
'failed',
|
||||||
|
`report_history_save_failed:${error instanceof Error ? error.message : String(error)}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!savedHistory.success) {
|
||||||
|
return finish(
|
||||||
|
'failed',
|
||||||
|
`report_history_save_failed:${savedHistory.error || '日报历史保存失败'}`
|
||||||
|
)
|
||||||
|
}
|
||||||
const target = this.resolveTarget(task)
|
const target = this.resolveTarget(task)
|
||||||
if (!target) return finish('failed', 'wechat_send_failed:未找到指定微信群')
|
if (!target) return finish('failed', 'wechat_send_failed:未找到指定微信群')
|
||||||
let sent: Awaited<ReturnType<typeof this.deps.send>>
|
let sent: Awaited<ReturnType<typeof this.deps.send>>
|
||||||
|
|||||||
@@ -437,6 +437,7 @@ function App(): React.ReactElement {
|
|||||||
modelConfig: reportTextModelConfig,
|
modelConfig: reportTextModelConfig,
|
||||||
visionModelConfig: aiVisionModelConfig
|
visionModelConfig: aiVisionModelConfig
|
||||||
})
|
})
|
||||||
|
const reportHistoryRequestRef = React.useRef(0)
|
||||||
const lastCapturedReportKeyRef = React.useRef('')
|
const lastCapturedReportKeyRef = React.useRef('')
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -531,8 +532,10 @@ function App(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const loadGeneratedReports = React.useCallback(async (): Promise<void> => {
|
const loadGeneratedReports = React.useCallback(async (): Promise<void> => {
|
||||||
|
const requestId = ++reportHistoryRequestRef.current
|
||||||
try {
|
try {
|
||||||
const result = await window.api.listGeneratedReports()
|
const result = await window.api.listGeneratedReports()
|
||||||
|
if (requestId !== reportHistoryRequestRef.current) return
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
setReportNotice(result.error || '日报历史加载失败')
|
setReportNotice(result.error || '日报历史加载失败')
|
||||||
return
|
return
|
||||||
@@ -543,6 +546,7 @@ function App(): React.ReactElement {
|
|||||||
current && reports.some((report) => report.id === current) ? current : null
|
current && reports.some((report) => report.id === current) ? current : null
|
||||||
)
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (requestId !== reportHistoryRequestRef.current) return
|
||||||
setReportNotice(error instanceof Error ? error.message : String(error))
|
setReportNotice(error instanceof Error ? error.message : String(error))
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
@@ -817,6 +821,15 @@ function App(): React.ReactElement {
|
|||||||
void loadGeneratedReports()
|
void loadGeneratedReports()
|
||||||
}, [isAuthenticated, loadGeneratedReports])
|
}, [isAuthenticated, loadGeneratedReports])
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!isAuthenticated || activePage !== 'report') return
|
||||||
|
void loadGeneratedReports()
|
||||||
|
const refreshTimer = window.setInterval(() => {
|
||||||
|
void loadGeneratedReports()
|
||||||
|
}, 15_000)
|
||||||
|
return () => window.clearInterval(refreshTimer)
|
||||||
|
}, [activePage, isAuthenticated, loadGeneratedReports, reportSection, reportWorkspaceView])
|
||||||
|
|
||||||
const handleLogin = async (keyInput?: string, accountRootInput?: string): Promise<void> => {
|
const handleLogin = async (keyInput?: string, accountRootInput?: string): Promise<void> => {
|
||||||
const keyToUse = keyInput || dbKey
|
const keyToUse = keyInput || dbKey
|
||||||
let accountRoot = accountRootInput || selectedAccount?.accountRoot
|
let accountRoot = accountRootInput || selectedAccount?.accountRoot
|
||||||
@@ -1597,6 +1610,7 @@ function App(): React.ReactElement {
|
|||||||
: summaryDateRange === '7days'
|
: summaryDateRange === '7days'
|
||||||
? '近 7 天'
|
? '近 7 天'
|
||||||
: '今天',
|
: '今天',
|
||||||
|
reportDate: reportMetadata.reportDate,
|
||||||
messageCount: reportGeneration.reportMessages.length,
|
messageCount: reportGeneration.reportMessages.length,
|
||||||
generatedAt: new Date().toISOString(),
|
generatedAt: new Date().toISOString(),
|
||||||
generatedImage: reportGeneration.generatedImage || undefined,
|
generatedImage: reportGeneration.generatedImage || undefined,
|
||||||
|
|||||||
@@ -37,8 +37,6 @@ interface ReportGroup {
|
|||||||
reports: GeneratedReportRecord[]
|
reports: GeneratedReportRecord[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const DAY_MS = 86400000
|
|
||||||
|
|
||||||
const dateKey = (value: string): number => {
|
const dateKey = (value: string): number => {
|
||||||
const parsed = new Date(value).getTime()
|
const parsed = new Date(value).getTime()
|
||||||
return Number.isFinite(parsed) ? parsed : 0
|
return Number.isFinite(parsed) ? parsed : 0
|
||||||
@@ -62,12 +60,15 @@ const groupLabelFor = (value: string): string => {
|
|||||||
if (!Number.isFinite(date.getTime())) return '更早'
|
if (!Number.isFinite(date.getTime())) return '更早'
|
||||||
|
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime()
|
const startOfToday = new Date(now)
|
||||||
const startOfYesterday = startOfToday - DAY_MS
|
startOfToday.setHours(0, 0, 0, 0)
|
||||||
const time = new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime()
|
const startOfYesterday = new Date(startOfToday)
|
||||||
|
startOfYesterday.setDate(startOfYesterday.getDate() - 1)
|
||||||
|
const time = new Date(date)
|
||||||
|
time.setHours(0, 0, 0, 0)
|
||||||
|
|
||||||
if (time >= startOfToday) return '今天'
|
if (time.getTime() >= startOfToday.getTime()) return '今天'
|
||||||
if (time >= startOfYesterday) return '昨天'
|
if (time.getTime() >= startOfYesterday.getTime()) return '昨天'
|
||||||
if (date.getFullYear() === now.getFullYear()) return `${date.getMonth() + 1}月${date.getDate()}日`
|
if (date.getFullYear() === now.getFullYear()) return `${date.getMonth() + 1}月${date.getDate()}日`
|
||||||
return `${date.getFullYear()}年${date.getMonth() + 1}月${date.getDate()}日`
|
return `${date.getFullYear()}年${date.getMonth() + 1}月${date.getDate()}日`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -167,7 +167,8 @@ function GroupExitNotificationTemplate({
|
|||||||
/>
|
/>
|
||||||
<div className="exit-monitor-template-meta">
|
<div className="exit-monitor-template-meta">
|
||||||
<span>
|
<span>
|
||||||
支持 {`{user}`}、{`{groupRemark}`}、{`{wxid}`}、{`{time}`}
|
支持 {`{user}`}、{`{groupRemark}`}、{`{wxid}`}、{`{previousCount}`}、{`{currentCount}`}
|
||||||
|
、{`{time}`}
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
{draft.length}/{GROUP_EXIT_NOTIFICATION_TEMPLATE_MAX_LENGTH}
|
{draft.length}/{GROUP_EXIT_NOTIFICATION_TEMPLATE_MAX_LENGTH}
|
||||||
|
|||||||
@@ -357,6 +357,29 @@ export function AIProviderEditor({
|
|||||||
</Select>
|
</Select>
|
||||||
<small>Codex 或仅支持 /responses 的中转服务请选择 Responses API。</small>
|
<small>Codex 或仅支持 /responses 的中转服务请选择 Responses API。</small>
|
||||||
</label>
|
</label>
|
||||||
|
<label className="wide">
|
||||||
|
思考模式
|
||||||
|
<Select
|
||||||
|
value={provider.advanced.thinking || 'default'}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
patch({
|
||||||
|
advanced: {
|
||||||
|
...provider.advanced,
|
||||||
|
thinking: value as AIProviderConfig['advanced']['thinking']
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger aria-label="思考模式">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="default">跟随模型默认</SelectItem>
|
||||||
|
<SelectItem value="disabled">禁用思考(thinking: disabled)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<small>仅 Chat Completions 兼容接口会发送禁用参数。</small>
|
||||||
|
</label>
|
||||||
<label className="wide ai-provider-stream-toggle" htmlFor="ai-provider-stream">
|
<label className="wide ai-provider-stream-toggle" htmlFor="ai-provider-stream">
|
||||||
<span className="ai-provider-stream-toggle-control">
|
<span className="ai-provider-stream-toggle-control">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ export function createProviderFromPreset(presetId = 'deepseek'): AIProviderConfi
|
|||||||
maxTokens: 4096,
|
maxTokens: 4096,
|
||||||
stream: false,
|
stream: false,
|
||||||
apiProtocol: 'chat-completions',
|
apiProtocol: 'chat-completions',
|
||||||
|
thinking: 'default',
|
||||||
extraHeaders: {}
|
extraHeaders: {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -919,21 +919,34 @@ export const SUMMARY_TYPE_OPTIONS: {
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
export const getSummaryDateRange = (
|
export const getSummaryDateRangeAt = (
|
||||||
range: SummaryDateRange
|
range: SummaryDateRange,
|
||||||
|
now = new Date()
|
||||||
): { startTime: number; endTime: number } => {
|
): { startTime: number; endTime: number } => {
|
||||||
const now = new Date()
|
const startOfToday = new Date(now)
|
||||||
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime() / 1000
|
startOfToday.setHours(0, 0, 0, 0)
|
||||||
const endTime = Math.floor(Date.now() / 1000)
|
const startOfYesterday = new Date(startOfToday)
|
||||||
|
startOfYesterday.setDate(startOfYesterday.getDate() - 1)
|
||||||
|
const startOfTodaySeconds = Math.floor(startOfToday.getTime() / 1000)
|
||||||
|
const endTime = Math.floor(now.getTime() / 1000)
|
||||||
if (range === 'yesterday') {
|
if (range === 'yesterday') {
|
||||||
return { startTime: startOfToday - 86400, endTime: startOfToday - 1 }
|
return {
|
||||||
|
startTime: Math.floor(startOfYesterday.getTime() / 1000),
|
||||||
|
endTime: startOfTodaySeconds - 1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (range === '7days') {
|
if (range === '7days') {
|
||||||
return { startTime: startOfToday - 6 * 86400, endTime }
|
const startOfSevenDayWindow = new Date(startOfToday)
|
||||||
|
startOfSevenDayWindow.setDate(startOfSevenDayWindow.getDate() - 6)
|
||||||
|
return { startTime: Math.floor(startOfSevenDayWindow.getTime() / 1000), endTime }
|
||||||
}
|
}
|
||||||
return { startTime: startOfToday, endTime }
|
return { startTime: startOfTodaySeconds, endTime }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const getSummaryDateRange = (
|
||||||
|
range: SummaryDateRange
|
||||||
|
): { startTime: number; endTime: number } => getSummaryDateRangeAt(range)
|
||||||
|
|
||||||
export const isInternalName = (value?: string): boolean => {
|
export const isInternalName = (value?: string): boolean => {
|
||||||
const text = String(value || '').trim()
|
const text = String(value || '').trim()
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export type AIProviderType =
|
|||||||
export type AIAuthType = 'bearer' | 'x-api-key' | 'custom-header' | 'none'
|
export type AIAuthType = 'bearer' | 'x-api-key' | 'custom-header' | 'none'
|
||||||
|
|
||||||
export type AIOpenAIProtocol = 'chat-completions' | 'responses'
|
export type AIOpenAIProtocol = 'chat-completions' | 'responses'
|
||||||
|
export type AIThinkingMode = 'default' | 'disabled'
|
||||||
|
|
||||||
export interface AIProviderAuth {
|
export interface AIProviderAuth {
|
||||||
type: AIAuthType
|
type: AIAuthType
|
||||||
@@ -38,6 +39,7 @@ export interface AIProviderAdvancedSettings {
|
|||||||
maxTokens?: number
|
maxTokens?: number
|
||||||
stream?: boolean
|
stream?: boolean
|
||||||
apiProtocol?: AIOpenAIProtocol
|
apiProtocol?: AIOpenAIProtocol
|
||||||
|
thinking?: AIThinkingMode
|
||||||
extraHeaders: Record<string, string>
|
extraHeaders: Record<string, string>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,8 +27,7 @@ export interface GroupExitMonitorEvent {
|
|||||||
detectedAt: number
|
detectedAt: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 管理页预览和自动通知共用的模板。 */
|
const LEGACY_GROUP_EXIT_NOTIFICATION_TEMPLATE = [
|
||||||
export const GROUP_EXIT_NOTIFICATION_TEMPLATE = [
|
|
||||||
'[退群监测]',
|
'[退群监测]',
|
||||||
'',
|
'',
|
||||||
'用户: {user}',
|
'用户: {user}',
|
||||||
@@ -40,11 +39,28 @@ export const GROUP_EXIT_NOTIFICATION_TEMPLATE = [
|
|||||||
'退群时间: {time}'
|
'退群时间: {time}'
|
||||||
].join('\n')
|
].join('\n')
|
||||||
|
|
||||||
|
/** 管理页预览和自动通知共用的模板。 */
|
||||||
|
export const GROUP_EXIT_NOTIFICATION_TEMPLATE = [
|
||||||
|
'[退群监测]',
|
||||||
|
'',
|
||||||
|
'用户: {user}',
|
||||||
|
'',
|
||||||
|
'群备注: {groupRemark}',
|
||||||
|
'',
|
||||||
|
'微信号: {wxid}',
|
||||||
|
'',
|
||||||
|
'人数: {previousCount} -> {currentCount}',
|
||||||
|
'',
|
||||||
|
'退群时间: {time}'
|
||||||
|
].join('\n')
|
||||||
|
|
||||||
export const GROUP_EXIT_NOTIFICATION_TEMPLATE_MAX_LENGTH = 2_000
|
export const GROUP_EXIT_NOTIFICATION_TEMPLATE_MAX_LENGTH = 2_000
|
||||||
export const GROUP_EXIT_NOTIFICATION_TEMPLATE_PLACEHOLDERS = [
|
export const GROUP_EXIT_NOTIFICATION_TEMPLATE_PLACEHOLDERS = [
|
||||||
'user',
|
'user',
|
||||||
'groupRemark',
|
'groupRemark',
|
||||||
'wxid',
|
'wxid',
|
||||||
|
'previousCount',
|
||||||
|
'currentCount',
|
||||||
'time'
|
'time'
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
@@ -79,6 +95,10 @@ export function validateGroupExitNotificationTemplate(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeGroupExitNotificationTemplate(value: unknown): string {
|
export function normalizeGroupExitNotificationTemplate(value: unknown): string {
|
||||||
|
const template = typeof value === 'string' ? value.replace(/\r\n?/g, '\n').trim() : ''
|
||||||
|
if (template === LEGACY_GROUP_EXIT_NOTIFICATION_TEMPLATE) {
|
||||||
|
return GROUP_EXIT_NOTIFICATION_TEMPLATE
|
||||||
|
}
|
||||||
const result = validateGroupExitNotificationTemplate(value)
|
const result = validateGroupExitNotificationTemplate(value)
|
||||||
return result.valid && result.template ? result.template : GROUP_EXIT_NOTIFICATION_TEMPLATE
|
return result.valid && result.template ? result.template : GROUP_EXIT_NOTIFICATION_TEMPLATE
|
||||||
}
|
}
|
||||||
@@ -105,14 +125,18 @@ export function renderGroupExitMonitorNotification(
|
|||||||
user,
|
user,
|
||||||
groupRemark,
|
groupRemark,
|
||||||
wxid,
|
wxid,
|
||||||
|
previousCount: String(event.previousCount),
|
||||||
|
currentCount: String(event.currentCount),
|
||||||
time: formatGroupExitMonitorTime(event.detectedAt)
|
time: formatGroupExitMonitorTime(event.detectedAt)
|
||||||
}
|
}
|
||||||
return normalizeGroupExitNotificationTemplate(template).replace(
|
return normalizeGroupExitNotificationTemplate(template).replace(
|
||||||
/\{(user|groupRemark|wxid|time)\}/g,
|
/\{(user|groupRemark|wxid|previousCount|currentCount|time)\}/g,
|
||||||
(placeholder) => {
|
(placeholder) => {
|
||||||
if (placeholder === '{user}') return values.user
|
if (placeholder === '{user}') return values.user
|
||||||
if (placeholder === '{groupRemark}') return values.groupRemark
|
if (placeholder === '{groupRemark}') return values.groupRemark
|
||||||
if (placeholder === '{wxid}') return values.wxid
|
if (placeholder === '{wxid}') return values.wxid
|
||||||
|
if (placeholder === '{previousCount}') return values.previousCount
|
||||||
|
if (placeholder === '{currentCount}') return values.currentCount
|
||||||
return values.time
|
return values.time
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -62,6 +62,8 @@ export interface SaveGeneratedReportRequest {
|
|||||||
dateRange: string
|
dateRange: string
|
||||||
messageCount: number
|
messageCount: number
|
||||||
generatedAt: string
|
generatedAt: string
|
||||||
|
/** 实际覆盖的本地日期;缺省时由 generatedAt 推导。 */
|
||||||
|
reportDate?: string
|
||||||
generatedImage?: string
|
generatedImage?: string
|
||||||
htmlPath?: string
|
htmlPath?: string
|
||||||
pngPath?: string
|
pngPath?: string
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ describe('AI model settings', () => {
|
|||||||
expect(screen.getByLabelText('供应商名称')).toHaveFocus()
|
expect(screen.getByLabelText('供应商名称')).toHaveFocus()
|
||||||
expect(screen.getByLabelText('模型名称')).toHaveAttribute('placeholder', '例如:DeepSeek Chat')
|
expect(screen.getByLabelText('模型名称')).toHaveAttribute('placeholder', '例如:DeepSeek Chat')
|
||||||
expect(screen.getByLabelText('模型 ID')).toHaveAttribute('placeholder', '例如:deepseek-chat')
|
expect(screen.getByLabelText('模型 ID')).toHaveAttribute('placeholder', '例如:deepseek-chat')
|
||||||
|
expect(screen.getByRole('combobox', { name: '思考模式' })).toHaveTextContent('跟随模型默认')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('reveals and focuses the provider editor when editing an existing provider', async () => {
|
it('reveals and focuses the provider editor when editing an existing provider', async () => {
|
||||||
|
|||||||
@@ -260,7 +260,7 @@ let groupExitMonitorState = {
|
|||||||
monitoredRoomIds: ['group_regular@chatroom', 'group_folded@chatroom'],
|
monitoredRoomIds: ['group_regular@chatroom', 'group_folded@chatroom'],
|
||||||
notificationRoomIds: [],
|
notificationRoomIds: [],
|
||||||
notificationTemplate:
|
notificationTemplate:
|
||||||
'[退群监测]\n\n用户: {user}\n\n群备注: {groupRemark}\n\n微信号: {wxid}\n\n退群时间: {time}',
|
'[退群监测]\n\n用户: {user}\n\n群备注: {groupRemark}\n\n微信号: {wxid}\n\n人数: {previousCount} -> {currentCount}\n\n退群时间: {time}',
|
||||||
lastCheckedAt: fixtureNowMs - 30 * 1000,
|
lastCheckedAt: fixtureNowMs - 30 * 1000,
|
||||||
lastReadAt: 0,
|
lastReadAt: 0,
|
||||||
unreadCount: 1
|
unreadCount: 1
|
||||||
@@ -329,6 +329,7 @@ handle('group-exit-monitor:markRead', (readAt) => {
|
|||||||
|
|
||||||
const scheduledReportTasks = []
|
const scheduledReportTasks = []
|
||||||
const scheduledReportExecutions = []
|
const scheduledReportExecutions = []
|
||||||
|
const generatedReports = []
|
||||||
const scheduledReportNextRun = (scheduleTime) => {
|
const scheduledReportNextRun = (scheduleTime) => {
|
||||||
const [hours, minutes] = String(scheduleTime || '09:00')
|
const [hours, minutes] = String(scheduleTime || '09:00')
|
||||||
.split(':')
|
.split(':')
|
||||||
@@ -839,11 +840,19 @@ handle('report:prepareTemplateSwitch', () => ({
|
|||||||
values: { REPORT_TITLE: '固定脱敏群日报' }
|
values: { REPORT_TITLE: '固定脱敏群日报' }
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
handle('report:listGenerated', () => ({ success: true, reports: [] }))
|
handle('report:listGenerated', () => ({ success: true, reports: [...generatedReports] }))
|
||||||
handle('report:saveGenerated', (request) => ({
|
handle('report:saveGenerated', (request) => {
|
||||||
success: true,
|
const record = {
|
||||||
record: { id: 'fixture-report-record', ...request }
|
id: 'fixture-report-record',
|
||||||
}))
|
...request,
|
||||||
|
htmlStatus: request.htmlPath ? 'ready' : 'missing',
|
||||||
|
pngStatus: request.pngPath ? 'ready' : 'missing'
|
||||||
|
}
|
||||||
|
const index = generatedReports.findIndex((report) => report.id === record.id)
|
||||||
|
if (index >= 0) generatedReports[index] = record
|
||||||
|
else generatedReports.unshift(record)
|
||||||
|
return { success: true, record }
|
||||||
|
})
|
||||||
handle('report:updateGeneratedTemplate', (request) => ({
|
handle('report:updateGeneratedTemplate', (request) => ({
|
||||||
success: true,
|
success: true,
|
||||||
record: { id: request.reportId, templateId: request.templateId }
|
record: { id: request.reportId, templateId: request.templateId }
|
||||||
|
|||||||
@@ -37,6 +37,44 @@ const provider = (id: string, modelId: string, vision: boolean): AIProviderConfi
|
|||||||
describe('AI provider vision routing', () => {
|
describe('AI provider vision routing', () => {
|
||||||
afterAll(() => rmSync(root, { recursive: true, force: true }))
|
afterAll(() => rmSync(root, { recursive: true, force: true }))
|
||||||
|
|
||||||
|
it('sends the disabled thinking control only when it is selected', async () => {
|
||||||
|
const service = new AIProviderService()
|
||||||
|
const configured = provider('thinking-provider', 'deepseek-v4-flash-vision-exp', false)
|
||||||
|
configured.advanced.thinking = 'disabled'
|
||||||
|
expect(service.save(configured).success).toBe(true)
|
||||||
|
expect(service.setDefault(configured.id).success).toBe(true)
|
||||||
|
|
||||||
|
const fetchMock = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({ choices: [{ message: { content: 'ok' }, finish_reason: 'stop' }] }),
|
||||||
|
{ headers: { 'content-type': 'application/json' } }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
vi.stubGlobal('fetch', fetchMock)
|
||||||
|
try {
|
||||||
|
const result = await service.chat([{ role: 'user', content: 'hello' }])
|
||||||
|
expect(result).toMatchObject({ success: true, data: 'ok', finishReason: 'stop' })
|
||||||
|
const body = JSON.parse(fetchMock.mock.calls[0][1].body as string) as Record<string, unknown>
|
||||||
|
expect(body).toMatchObject({
|
||||||
|
model: 'deepseek-v4-flash-vision-exp',
|
||||||
|
thinking: { type: 'disabled' }
|
||||||
|
})
|
||||||
|
|
||||||
|
configured.advanced.thinking = 'default'
|
||||||
|
expect(service.save(configured).success).toBe(true)
|
||||||
|
await service.chat([{ role: 'user', content: 'hello' }])
|
||||||
|
const defaultBody = JSON.parse(fetchMock.mock.calls[1][1].body as string) as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>
|
||||||
|
expect(defaultBody).not.toHaveProperty('thinking')
|
||||||
|
} finally {
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
it('keeps DeepSeek as the text model while routing images to a verified vision model', () => {
|
it('keeps DeepSeek as the text model while routing images to a verified vision model', () => {
|
||||||
const service = new AIProviderService()
|
const service = new AIProviderService()
|
||||||
expect(service.save(provider('deepseek', 'deepseek-chat', false)).success).toBe(true)
|
expect(service.save(provider('deepseek', 'deepseek-chat', false)).success).toBe(true)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { mkdtempSync, readJsonSync, rmSync } from 'fs-extra'
|
import { mkdtempSync, readJsonSync, rmSync, writeJsonSync } from 'fs-extra'
|
||||||
import { tmpdir } from 'os'
|
import { tmpdir } from 'os'
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
@@ -32,6 +32,7 @@ vi.mock('../../src/main/services/personal-wechat-send-service', () => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
import { GroupExitMonitorService } from '../../src/main/services/group-exit-monitor-service'
|
import { GroupExitMonitorService } from '../../src/main/services/group-exit-monitor-service'
|
||||||
|
import { GROUP_EXIT_NOTIFICATION_TEMPLATE } from '../../src/shared/group-exit-monitor'
|
||||||
|
|
||||||
const member = { wxid: 'wxid_member', wechatNickname: '微信名', groupNickname: '群内名' }
|
const member = { wxid: 'wxid_member', wechatNickname: '微信名', groupNickname: '群内名' }
|
||||||
|
|
||||||
@@ -77,6 +78,17 @@ describe('GroupExitMonitorService', () => {
|
|||||||
expect(() => service.setNotificationTemplate('用户: {unknown}')).toThrow('不支持的占位符')
|
expect(() => service.setNotificationTemplate('用户: {unknown}')).toThrow('不支持的占位符')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('migrates the previous default notification template', () => {
|
||||||
|
writeJsonSync(join(mocks.userData, 'group-exit-monitor.json'), {
|
||||||
|
notificationTemplate:
|
||||||
|
'[退群监测]\n\n用户: {user}\n\n群备注: {groupRemark}\n\n微信号: {wxid}\n\n退群时间: {time}'
|
||||||
|
})
|
||||||
|
|
||||||
|
const service = new GroupExitMonitorService()
|
||||||
|
|
||||||
|
expect(service.getState().notificationTemplate).toBe(GROUP_EXIT_NOTIFICATION_TEMPLATE)
|
||||||
|
})
|
||||||
|
|
||||||
it('keeps the previous baseline when a native snapshot contains null members', async () => {
|
it('keeps the previous baseline when a native snapshot contains null members', async () => {
|
||||||
installGroupDb()
|
installGroupDb()
|
||||||
mocks.chat.getGroupSnapshotAsync.mockResolvedValueOnce({
|
mocks.chat.getGroupSnapshotAsync.mockResolvedValueOnce({
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ describe('group exit monitor member diff', () => {
|
|||||||
expect(message).toContain('用户: 微信名')
|
expect(message).toContain('用户: 微信名')
|
||||||
expect(message).toContain('群备注: 未设置')
|
expect(message).toContain('群备注: 未设置')
|
||||||
expect(message).toContain('微信号: wxid_fixture')
|
expect(message).toContain('微信号: wxid_fixture')
|
||||||
|
expect(message).toContain('人数: 240 -> 239')
|
||||||
expect(message).toContain('退群时间: 2026-08-31 00:19:07')
|
expect(message).toContain('退群时间: 2026-08-31 00:19:07')
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -97,8 +98,8 @@ describe('group exit monitor member diff', () => {
|
|||||||
message: '群昵称退出了测试群',
|
message: '群昵称退出了测试群',
|
||||||
detectedAt: new Date(2026, 7, 31, 0, 19, 7).getTime()
|
detectedAt: new Date(2026, 7, 31, 0, 19, 7).getTime()
|
||||||
},
|
},
|
||||||
'{user}|{groupRemark}|{wxid}|{time}'
|
'{user}|{groupRemark}|{wxid}|{previousCount}|{currentCount}|{time}'
|
||||||
)
|
)
|
||||||
expect(message).toBe('微信名|群内昵称|wxid_fixture|2026-08-31 00:19:07')
|
expect(message).toBe('微信名|群内昵称|wxid_fixture|2|1|2026-08-31 00:19:07')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
import {
|
import {
|
||||||
buildGroupReportInput,
|
buildGroupReportInput,
|
||||||
|
getSummaryDateRangeAt,
|
||||||
parseGroupDailyReport
|
parseGroupDailyReport
|
||||||
} from '../../src/renderer/src/utils/group-report'
|
} from '../../src/renderer/src/utils/group-report'
|
||||||
import { summaryContent } from '../../src/renderer/src/utils/group-report-facts'
|
import { summaryContent } from '../../src/renderer/src/utils/group-report-facts'
|
||||||
@@ -324,3 +325,24 @@ describe('group report parsing', () => {
|
|||||||
expect(input.media.gallery).toEqual([])
|
expect(input.media.gallery).toEqual([])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
describe('summary date ranges', () => {
|
||||||
|
it('uses local calendar days for today, yesterday, and seven-day ranges', () => {
|
||||||
|
const now = new Date(2026, 0, 1, 12, 34, 56)
|
||||||
|
const today = new Date(2026, 0, 1)
|
||||||
|
const yesterday = new Date(2025, 11, 31)
|
||||||
|
const sevenDaysAgo = new Date(2025, 11, 26)
|
||||||
|
|
||||||
|
expect(getSummaryDateRangeAt('today', now)).toEqual({
|
||||||
|
startTime: today.getTime() / 1000,
|
||||||
|
endTime: now.getTime() / 1000
|
||||||
|
})
|
||||||
|
expect(getSummaryDateRangeAt('yesterday', now)).toEqual({
|
||||||
|
startTime: yesterday.getTime() / 1000,
|
||||||
|
endTime: today.getTime() / 1000 - 1
|
||||||
|
})
|
||||||
|
expect(getSummaryDateRangeAt('7days', now)).toEqual({
|
||||||
|
startTime: sevenDaysAgo.getTime() / 1000,
|
||||||
|
endTime: now.getTime() / 1000
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ describe('generated report template history', () => {
|
|||||||
dateRange: '今天',
|
dateRange: '今天',
|
||||||
messageCount: 10,
|
messageCount: 10,
|
||||||
generatedAt: '2026-08-12T10:00:00.000Z',
|
generatedAt: '2026-08-12T10:00:00.000Z',
|
||||||
|
reportDate: '2026-08-11',
|
||||||
generatedImage: `data:image/png;base64,${Buffer.from('mobile-01').toString('base64')}`,
|
generatedImage: `data:image/png;base64,${Buffer.from('mobile-01').toString('base64')}`,
|
||||||
htmlPath: originalHtml,
|
htmlPath: originalHtml,
|
||||||
reportSnapshot,
|
reportSnapshot,
|
||||||
@@ -79,6 +80,7 @@ describe('generated report template history', () => {
|
|||||||
})
|
})
|
||||||
expect(saved.success).toBe(true)
|
expect(saved.success).toBe(true)
|
||||||
expect(saved.record).toBeDefined()
|
expect(saved.record).toBeDefined()
|
||||||
|
expect(saved.record?.reportDate).toBe('2026-08-11')
|
||||||
|
|
||||||
const updated = await updateGeneratedReportTemplate({
|
const updated = await updateGeneratedReportTemplate({
|
||||||
reportId: saved.record!.id,
|
reportId: saved.record!.id,
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ describe('scheduled report scheduling', () => {
|
|||||||
const generatedPath = join(storageDir, 'report.png')
|
const generatedPath = join(storageDir, 'report.png')
|
||||||
let generated = 0
|
let generated = 0
|
||||||
let sent = 0
|
let sent = 0
|
||||||
|
const saveHistory = vi.fn().mockResolvedValue({ success: true })
|
||||||
const service = new ScheduledReportService({
|
const service = new ScheduledReportService({
|
||||||
storageDir,
|
storageDir,
|
||||||
now: () => now,
|
now: () => now,
|
||||||
@@ -46,6 +47,7 @@ describe('scheduled report scheduling', () => {
|
|||||||
sent += 1
|
sent += 1
|
||||||
return { success: true, status: capability.senderStatus }
|
return { success: true, status: capability.senderStatus }
|
||||||
},
|
},
|
||||||
|
saveGeneratedReport: saveHistory,
|
||||||
isDatabaseReady: () => true
|
isDatabaseReady: () => true
|
||||||
})
|
})
|
||||||
const created = await service.createTask({
|
const created = await service.createTask({
|
||||||
@@ -61,6 +63,14 @@ describe('scheduled report scheduling', () => {
|
|||||||
expect(run.data?.status).toBe('success')
|
expect(run.data?.status).toBe('success')
|
||||||
expect(generated).toBe(1)
|
expect(generated).toBe(1)
|
||||||
expect(sent).toBe(1)
|
expect(sent).toBe(1)
|
||||||
|
expect(saveHistory).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
contactName: '研发群',
|
||||||
|
reportDate: undefined,
|
||||||
|
pngPath: generatedPath,
|
||||||
|
messageCount: 0
|
||||||
|
})
|
||||||
|
)
|
||||||
expect(await service.listExecutions(taskId)).toHaveLength(1)
|
expect(await service.listExecutions(taskId)).toHaveLength(1)
|
||||||
const restored = new ScheduledReportService({
|
const restored = new ScheduledReportService({
|
||||||
storageDir,
|
storageDir,
|
||||||
@@ -75,4 +85,30 @@ describe('scheduled report scheduling', () => {
|
|||||||
JSON.parse(await readFile(join(storageDir, 'executions.json'), 'utf8'))[0].message
|
JSON.parse(await readFile(join(storageDir, 'executions.json'), 'utf8'))[0].message
|
||||||
).toContain('微信发送成功')
|
).toContain('微信发送成功')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('does not send a generated report when history persistence fails', async () => {
|
||||||
|
const storageDir = await mkdtemp(join(tmpdir(), 'tracememo-scheduled-report-history-'))
|
||||||
|
const generatedPath = join(storageDir, 'report.png')
|
||||||
|
const send = vi.fn().mockResolvedValue({ success: true, status: capability.senderStatus })
|
||||||
|
const service = new ScheduledReportService({
|
||||||
|
storageDir,
|
||||||
|
getCapability: async () => capability,
|
||||||
|
generateReport: async () => ({ success: true, pngPath: generatedPath }),
|
||||||
|
saveGeneratedReport: vi.fn().mockResolvedValue({ success: false, error: '磁盘不可写' }),
|
||||||
|
send,
|
||||||
|
isDatabaseReady: () => true
|
||||||
|
})
|
||||||
|
const created = await service.createTask({
|
||||||
|
name: '历史失败日报',
|
||||||
|
group: '研发群',
|
||||||
|
target: '研发群@chatroom',
|
||||||
|
scheduleTime: '09:00'
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await service.runScheduledReportNow(created.data!.id)
|
||||||
|
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
expect(result.data?.error).toContain('report_history_save_failed:磁盘不可写')
|
||||||
|
expect(send).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user