Merge pull request #1020 from Jasonzhu1207/main

feat: Add User Persona By AI For Insight
This commit is contained in:
cc authored and GitHub committed 2026-05-26 23:35:57 +08:00
commit cfed07a03b
7 files changed
+1401 -64

No files matched your search

+17
View File
@@ -32,6 +32,7 @@ import { httpService } from './services/httpService'
import { messagePushService } from './services/messagePushService'
import { insightService } from './services/insightService'
import { insightRecordService } from './services/insightRecordService'
import { insightProfileService } from './services/insightProfileService'
import { groupSummaryService } from './services/groupSummaryService'
import { normalizeWeiboCookieInput, weiboService } from './services/social/weiboService'
import { bizService } from './services/bizService'
@@ -1823,6 +1824,22 @@ function registerIpcHandlers() {
return insightService.triggerSessionInsight(payload)
})
ipcMain.handle('insight:listProfileStatuses', async (_, sessionIds: string[]) => {
return insightProfileService.listProfileStatuses(Array.isArray(sessionIds) ? sessionIds : [])
})
ipcMain.handle('insight:generateProfile', async (_, payload: {
sessionId: string
displayName?: string
avatarUrl?: string
}) => {
return insightProfileService.generateProfile(payload)
})
ipcMain.handle('insight:cancelProfile', async (_, sessionId?: string) => {
return insightProfileService.cancelProfile(sessionId)
})
ipcMain.handle('insight:generateFootprintInsight', async (_, payload: {
rangeLabel: string
summary: {
+7
View File
@@ -588,6 +588,13 @@ contextBridge.exposeInMainWorld('electronAPI', {
displayName?: string
avatarUrl?: string
}) => ipcRenderer.invoke('insight:triggerSessionInsight', payload),
listProfileStatuses: (sessionIds: string[]) => ipcRenderer.invoke('insight:listProfileStatuses', sessionIds),
generateProfile: (payload: {
sessionId: string
displayName?: string
avatarUrl?: string
}) => ipcRenderer.invoke('insight:generateProfile', payload),
cancelProfile: (sessionId?: string) => ipcRenderer.invoke('insight:cancelProfile', sessionId),
generateFootprintInsight: (payload: {
rangeLabel: string
summary: {
+1001
View File
@@ -0,0 +1,1001 @@
import fs from 'fs'
import path from 'path'
import https from 'https'
import http from 'http'
import { URL } from 'url'
import { app } from 'electron'
import { randomUUID, createHash } from 'crypto'
import { ConfigService } from './config'
import { chatService, type Message } from './chatService'
import { wcdbService } from './wcdbService'
const API_TIMEOUT_MS = 45_000
const API_TEMPERATURE = 0.7
const MONTH_MATERIAL_CHAR_LIMIT = 45_000
const DIRECT_MONTH_MESSAGE_LIMIT = 1000
const MONTH_CURSOR_BATCH_SIZE = 800
const MAX_RETRY_ATTEMPTS = 5
const MONTHLY_OUTPUT_MIN_TOKENS = 1600
const FINAL_OUTPUT_MIN_TOKENS = 2400
type ProfileStatusValue = 'none' | 'ready' | 'running' | 'failed'
interface SharedAiModelConfig {
apiBaseUrl: string
apiKey: string
model: string
maxTokens: number
}
interface ActiveProfileTask {
taskId: string
sessionId: string
displayName: string
controller: AbortController
phase: string
startedAt: number
cursor?: number
}
interface MonthWindow {
key: string
label: string
startSec: number
endSec: number
}
interface MonthStats {
total: number
mine: number
peer: number
activeDays: number
longestActiveDayStreak: number
longestSilenceDays: number
topHours: string[]
firstTime?: number
lastTime?: number
}
interface PreparedMonthMaterial {
text: string
compressed: boolean
stats: MonthStats
scannedMessages: number
sampledMessages: number
}
interface MonthSummary {
month: string
messageCount: number
compressed: boolean
sampledMessages: number
summary: string
}
export interface InsightProfileRecord {
id: string
accountScope: string
sessionId: string
displayName: string
avatarUrl?: string
createdAt: number
updatedAt: number
rangeStart: number
rangeEnd: number
months: string[]
emptyMonths: string[]
monthlySummaries: MonthSummary[]
finalProfile: string
stats: {
scannedMessages: number
summarizedMonths: number
emptyMonths: number
compressedMonths: number
}
model: string
}
export interface InsightProfileStatus {
sessionId: string
status: ProfileStatusValue
updatedAt?: number
error?: string
phase?: string
busy?: boolean
}
export interface InsightProfileStatusListResult {
success: boolean
statuses: Record<string, InsightProfileStatus>
activeTask?: {
sessionId: string
displayName: string
phase: string
startedAt: number
}
error?: string
}
export interface InsightProfileGenerateResult {
success: boolean
message: string
cancelled?: boolean
profile?: InsightProfileRecord
error?: string
}
class AbortRequestError extends Error {
constructor(message = '画像任务已取消') {
super(message)
this.name = 'AbortError'
}
}
class ApiRequestError extends Error {
statusCode?: number
responseBody?: string
constructor(message: string, statusCode?: number, responseBody?: string) {
super(message)
this.name = 'ApiRequestError'
this.statusCode = statusCode
this.responseBody = responseBody
}
}
function isAbortError(error: unknown): boolean {
return (error as Error)?.name === 'AbortError' || String((error as Error)?.message || '').includes('取消')
}
function abortIfNeeded(signal?: AbortSignal): void {
if (signal?.aborted) {
throw new AbortRequestError()
}
}
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
abortIfNeeded(signal)
let settled = false
const timer = setTimeout(() => {
if (settled) return
settled = true
cleanup()
resolve()
}, ms)
const onAbort = () => {
if (settled) return
settled = true
clearTimeout(timer)
cleanup()
reject(new AbortRequestError())
}
const cleanup = () => {
signal?.removeEventListener('abort', onAbort)
}
signal?.addEventListener('abort', onAbort, { once: true })
})
}
function normalizeApiMaxTokens(value: unknown): number {
const numeric = Number(value)
if (!Number.isFinite(numeric)) return 1024
return Math.min(2_000_000, Math.max(1, Math.floor(numeric)))
}
function buildApiUrl(baseUrl: string, apiPath: string): string {
const base = baseUrl.replace(/\/+$/, '')
const suffix = apiPath.startsWith('/') ? apiPath : `/${apiPath}`
return `${base}${suffix}`
}
function formatPromptCurrentTime(date: Date = new Date()): string {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
return `当前系统时间:${year}年${month}月${day}日 ${hours}:${minutes}`
}
function appendPromptCurrentTime(prompt: string): string {
const base = String(prompt || '').trimEnd()
return base ? `${base}\n\n${formatPromptCurrentTime()}` : formatPromptCurrentTime()
}
function clampText(value: unknown, maxLength: number): string {
const text = String(value || '').replace(/\s+/g, ' ').trim()
if (text.length <= maxLength) return text
return `${text.slice(0, Math.max(0, maxLength - 1))}…`
}
function truncateStructuredText(value: unknown, maxLength: number): string {
const text = String(value || '').replace(/\u0000/g, '').trim()
if (text.length <= maxLength) return text
return `${text.slice(0, Math.max(0, maxLength - 3))}...`
}
function formatDateTime(timestampSeconds: number): string {
if (!Number.isFinite(timestampSeconds) || timestampSeconds <= 0) return ''
const date = new Date(timestampSeconds * 1000)
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}`
}
function formatMonthKey(date: Date): string {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`
}
function getMonthStart(date: Date): Date {
return new Date(date.getFullYear(), date.getMonth(), 1, 0, 0, 0, 0)
}
function toSeconds(date: Date): number {
return Math.floor(date.getTime() / 1000)
}
function buildRecentTwelveMonthWindows(now: Date = new Date()): MonthWindow[] {
const currentMonthStart = getMonthStart(now)
const windows: MonthWindow[] = []
for (let index = 11; index >= 0; index -= 1) {
const start = new Date(currentMonthStart)
start.setMonth(currentMonthStart.getMonth() - index)
const next = new Date(start)
next.setMonth(start.getMonth() + 1)
const isCurrentMonth = index === 0
const end = isCurrentMonth ? now : new Date(next.getTime() - 1000)
const key = formatMonthKey(start)
windows.push({
key,
label: key,
startSec: toSeconds(start),
endSec: Math.max(toSeconds(start), toSeconds(end))
})
}
return windows
}
function callProfileApi(
config: SharedAiModelConfig,
messages: Array<{ role: string; content: string }>,
maxTokens: number,
signal?: AbortSignal
): Promise<string> {
return new Promise((resolve, reject) => {
try {
abortIfNeeded(signal)
const endpoint = buildApiUrl(config.apiBaseUrl, '/chat/completions')
const urlObj = new URL(endpoint)
const payload = JSON.stringify({
model: config.model,
messages,
max_tokens: normalizeApiMaxTokens(maxTokens),
temperature: API_TEMPERATURE,
stream: false
})
const requestOptions = {
hostname: urlObj.hostname,
port: urlObj.port || (urlObj.protocol === 'https:' ? 443 : 80),
path: urlObj.pathname + urlObj.search,
method: 'POST' as const,
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(payload).toString(),
Authorization: `Bearer ${config.apiKey}`
}
}
const requestFn = urlObj.protocol === 'https:' ? https.request : http.request
const req = requestFn(requestOptions, (res) => {
let data = ''
res.on('data', (chunk) => { data += chunk })
res.on('end', () => {
try {
if (res.statusCode && res.statusCode >= 400) {
reject(new ApiRequestError(`API 请求失败 (${res.statusCode}): ${data.slice(0, 200)}`, res.statusCode, data))
return
}
const parsed = JSON.parse(data)
const content = parsed?.choices?.[0]?.message?.content
if (typeof content === 'string' && content.trim()) {
resolve(content.trim())
} else {
reject(new Error(`API 返回格式异常: ${data.slice(0, 200)}`))
}
} catch {
reject(new Error(`JSON 解析失败: ${data.slice(0, 200)}`))
}
})
})
const onAbort = () => {
req.destroy(new AbortRequestError())
}
signal?.addEventListener('abort', onAbort, { once: true })
req.setTimeout(API_TIMEOUT_MS, () => {
req.destroy()
reject(new Error('API 请求超时'))
})
req.on('error', (error) => {
signal?.removeEventListener('abort', onAbort)
reject(isAbortError(error) || signal?.aborted ? new AbortRequestError() : error)
})
req.on('close', () => {
signal?.removeEventListener('abort', onAbort)
})
req.write(payload)
req.end()
} catch (error) {
reject(error)
}
})
}
async function callProfileApiWithRetry(
config: SharedAiModelConfig,
messages: Array<{ role: string; content: string }>,
maxTokens: number,
signal?: AbortSignal
): Promise<string> {
let lastError: unknown
for (let attempt = 1; attempt <= MAX_RETRY_ATTEMPTS; attempt += 1) {
abortIfNeeded(signal)
try {
return await callProfileApi(config, messages, maxTokens, signal)
} catch (error) {
if (isAbortError(error) || signal?.aborted) throw new AbortRequestError()
lastError = error
if (attempt >= MAX_RETRY_ATTEMPTS) break
await sleep(Math.min(10_000, 800 * Math.pow(2, attempt - 1)), signal)
}
}
throw lastError instanceof Error ? lastError : new Error(String(lastError || 'API 请求失败'))
}
class InsightProfileService {
private readonly config = ConfigService.getInstance()
private filePath: string | null = null
private loaded = false
private records: InsightProfileRecord[] = []
private activeTask: ActiveProfileTask | null = null
private failedStatus = new Map<string, { error: string; updatedAt: number }>()
private resolveFilePath(): string {
if (this.filePath) return this.filePath
const userDataPath = app?.getPath?.('userData') || process.cwd()
fs.mkdirSync(userDataPath, { recursive: true })
this.filePath = path.join(userDataPath, 'weflow-insight-profiles.json')
return this.filePath
}
private ensureLoaded(): void {
if (this.loaded) return
this.loaded = true
try {
const filePath = this.resolveFilePath()
if (!fs.existsSync(filePath)) return
const parsed = JSON.parse(fs.readFileSync(filePath, 'utf-8'))
const records = Array.isArray(parsed) ? parsed : parsed?.records
if (Array.isArray(records)) {
this.records = records.filter((item) => item && typeof item === 'object') as InsightProfileRecord[]
}
} catch {
this.records = []
}
}
private persist(): void {
try {
fs.writeFileSync(this.resolveFilePath(), JSON.stringify({ version: 1, records: this.records }, null, 2), 'utf-8')
} catch {
// Profile generation should not crash when local persistence fails.
}
}
private getCurrentAccountScope(): string {
const myWxid = String(this.config.getMyWxidCleaned() || '').trim()
if (myWxid) return `wxid:${myWxid}`
const dbPath = String(this.config.get('dbPath') || '').trim()
if (dbPath) {
const hash = createHash('sha1').update(dbPath).digest('hex').slice(0, 16)
return `db:${hash}`
}
return 'default'
}
private getSharedAiModelConfig(): SharedAiModelConfig {
const apiBaseUrl = String(
this.config.get('aiModelApiBaseUrl')
|| this.config.get('aiInsightApiBaseUrl')
|| ''
).trim().replace(/\/+$/, '')
const apiKey = String(
this.config.get('aiModelApiKey')
|| this.config.get('aiInsightApiKey')
|| ''
).trim()
const model = String(
this.config.get('aiModelApiModel')
|| this.config.get('aiInsightApiModel')
|| 'gpt-4o-mini'
).trim() || 'gpt-4o-mini'
const maxTokens = normalizeApiMaxTokens(this.config.get('aiModelApiMaxTokens'))
return { apiBaseUrl, apiKey, model, maxTokens }
}
private findLatestRecord(sessionId: string): InsightProfileRecord | null {
this.ensureLoaded()
const scope = this.getCurrentAccountScope()
const normalizedSessionId = String(sessionId || '').trim()
if (!normalizedSessionId) return null
const matches = this.records
.filter((record) => record.accountScope === scope && record.sessionId === normalizedSessionId)
.sort((a, b) => b.updatedAt - a.updatedAt)
return matches[0] || null
}
listProfileStatuses(sessionIds: string[]): InsightProfileStatusListResult {
this.ensureLoaded()
const scope = this.getCurrentAccountScope()
const normalizedIds = Array.from(new Set((sessionIds || []).map((id) => String(id || '').trim()).filter(Boolean)))
const latestBySession = new Map<string, InsightProfileRecord>()
for (const record of this.records.filter((item) => item.accountScope === scope)) {
const existing = latestBySession.get(record.sessionId)
if (!existing || record.updatedAt > existing.updatedAt) {
latestBySession.set(record.sessionId, record)
}
}
const statuses: Record<string, InsightProfileStatus> = {}
for (const sessionId of normalizedIds) {
const activeForSession = this.activeTask?.sessionId === sessionId
if (activeForSession && this.activeTask) {
statuses[sessionId] = {
sessionId,
status: 'running',
phase: this.activeTask.phase,
updatedAt: this.activeTask.startedAt,
busy: false
}
continue
}
const record = latestBySession.get(sessionId)
if (record) {
statuses[sessionId] = {
sessionId,
status: 'ready',
updatedAt: record.updatedAt,
busy: Boolean(this.activeTask)
}
continue
}
const failed = this.failedStatus.get(sessionId)
if (failed) {
statuses[sessionId] = {
sessionId,
status: 'failed',
updatedAt: failed.updatedAt,
error: failed.error,
busy: Boolean(this.activeTask)
}
continue
}
statuses[sessionId] = {
sessionId,
status: 'none',
busy: Boolean(this.activeTask)
}
}
return {
success: true,
statuses,
activeTask: this.activeTask
? {
sessionId: this.activeTask.sessionId,
displayName: this.activeTask.displayName,
phase: this.activeTask.phase,
startedAt: this.activeTask.startedAt
}
: undefined
}
}
getProfileContextSection(sessionId: string): string {
const record = this.findLatestRecord(sessionId)
if (!record?.finalProfile) return ''
const rangeStart = formatDateTime(record.rangeStart)
const rangeEnd = formatDateTime(record.rangeEnd)
return [
`联系人长期 AI 画像(覆盖 ${rangeStart} 至 ${rangeEnd},生成于 ${new Date(record.updatedAt).toLocaleString('zh-CN')}):`,
clampText(record.finalProfile, 3000)
].join('\n')
}
cancelProfile(sessionId?: string): { success: boolean; message: string } {
const normalizedSessionId = String(sessionId || '').trim()
if (!this.activeTask) return { success: true, message: '当前没有画像任务' }
if (normalizedSessionId && normalizedSessionId !== this.activeTask.sessionId) {
return { success: false, message: '当前运行中的画像任务不属于该联系人' }
}
this.activeTask.phase = '正在取消画像...'
this.activeTask.controller.abort()
return { success: true, message: '已请求取消画像任务' }
}
cancelActiveTask(reason = '画像任务已取消'): void {
if (!this.activeTask) return
this.activeTask.phase = reason
this.activeTask.controller.abort()
}
async generateProfile(params: {
sessionId: string
displayName?: string
avatarUrl?: string
}): Promise<InsightProfileGenerateResult> {
const sessionId = String(params?.sessionId || '').trim()
if (!sessionId || sessionId.endsWith('@chatroom')) {
return { success: false, message: 'AI 画像仅支持私聊联系人' }
}
if (this.activeTask) {
return {
success: false,
message: `「${this.activeTask.displayName}」的画像正在生成,请等待完成或取消后再试`
}
}
const aiConfig = this.getSharedAiModelConfig()
if (!aiConfig.apiBaseUrl || !aiConfig.apiKey) {
return { success: false, message: '请先填写通用 AI 模型配置(API 地址和 Key)' }
}
const existing = this.findLatestRecord(sessionId)
const displayName = clampText(params?.displayName || existing?.displayName || sessionId, 80) || sessionId
const controller = new AbortController()
const task: ActiveProfileTask = {
taskId: randomUUID(),
sessionId,
displayName,
controller,
phase: '正在初始化画像...',
startedAt: Date.now()
}
this.activeTask = task
try {
const connectResult = await chatService.connect()
abortIfNeeded(controller.signal)
if (!connectResult.success) {
throw new Error('数据库连接失败,请先在“数据库连接”页完成配置')
}
const windows = buildRecentTwelveMonthWindows()
const monthlySummaries: MonthSummary[] = []
const emptyMonths: string[] = []
let scannedMessages = 0
let compressedMonths = 0
for (let index = 0; index < windows.length; index += 1) {
abortIfNeeded(controller.signal)
const month = windows[index]
task.phase = `正在读取 ${month.label} 聊天记录 (${index + 1}/12)...`
const messages = await this.readMonthMessages(sessionId, month, task)
scannedMessages += messages.length
if (messages.length === 0) {
emptyMonths.push(month.label)
continue
}
const material = this.prepareMonthMaterial(messages, displayName)
if (material.compressed) compressedMonths += 1
task.phase = `正在生成 ${month.label} 月度画像 (${monthlySummaries.length + 1})...`
const summary = await this.generateMonthlySummary(aiConfig, displayName, month.label, material, controller.signal)
monthlySummaries.push({
month: month.label,
messageCount: material.scannedMessages,
compressed: material.compressed,
sampledMessages: material.sampledMessages,
summary
})
}
if (monthlySummaries.length === 0) {
throw new Error('最近 12 个自然月没有可用于画像的聊天记录')
}
task.phase = '正在汇总完整 AI 画像...'
const finalProfile = await this.generateFinalProfile(aiConfig, displayName, windows, emptyMonths, monthlySummaries, controller.signal)
abortIfNeeded(controller.signal)
const now = Date.now()
const record: InsightProfileRecord = {
id: randomUUID(),
accountScope: this.getCurrentAccountScope(),
sessionId,
displayName,
avatarUrl: String(params?.avatarUrl || existing?.avatarUrl || '').trim() || undefined,
createdAt: existing?.createdAt || now,
updatedAt: now,
rangeStart: windows[0].startSec,
rangeEnd: windows[windows.length - 1].endSec,
months: windows.map((month) => month.label),
emptyMonths,
monthlySummaries,
finalProfile,
stats: {
scannedMessages,
summarizedMonths: monthlySummaries.length,
emptyMonths: emptyMonths.length,
compressedMonths
},
model: aiConfig.model
}
this.upsertRecord(record)
this.failedStatus.delete(sessionId)
return {
success: true,
message: `已完成「${displayName}」的 AI 画像`,
profile: record
}
} catch (error) {
if (isAbortError(error) || controller.signal.aborted) {
return { success: false, cancelled: true, message: '画像已取消' }
}
const message = (error as Error).message || String(error)
if (!existing) {
this.failedStatus.set(sessionId, { error: message, updatedAt: Date.now() })
}
return { success: false, message: `画像失败:${message}`, error: message }
} finally {
if (task.cursor) {
await wcdbService.closeMessageCursor(task.cursor).catch(() => {})
}
if (this.activeTask?.taskId === task.taskId) {
this.activeTask = null
}
}
}
private upsertRecord(record: InsightProfileRecord): void {
this.ensureLoaded()
this.records = this.records.filter((item) => !(item.accountScope === record.accountScope && item.sessionId === record.sessionId))
this.records.push(record)
this.persist()
}
private async readMonthMessages(sessionId: string, month: MonthWindow, task: ActiveProfileTask): Promise<Message[]> {
const cursorResult = await wcdbService.openMessageCursorLite(
sessionId,
MONTH_CURSOR_BATCH_SIZE,
true,
month.startSec,
month.endSec
)
if (!cursorResult.success || !cursorResult.cursor) {
throw new Error(cursorResult.error || `读取 ${month.label} 聊天记录失败`)
}
task.cursor = cursorResult.cursor
const messages: Message[] = []
try {
while (true) {
abortIfNeeded(task.controller.signal)
const batch = await wcdbService.fetchMessageBatch(cursorResult.cursor)
if (!batch.success) {
throw new Error(batch.error || `读取 ${month.label} 聊天记录失败`)
}
const rows = Array.isArray(batch.rows) ? batch.rows as Record<string, any>[] : []
if (rows.length > 0) {
const mapped = chatService.mapRowsToMessagesLiteForApi(rows)
for (const message of mapped) {
const createTime = Number(message.createTime || 0)
if (createTime < month.startSec || createTime > month.endSec) continue
messages.push({
...message,
rawContent: clampText(message.rawContent || message.content || '', 1200),
content: undefined
})
}
}
if (!batch.hasMore) break
}
messages.sort((a, b) => (a.createTime - b.createTime) || (a.sortSeq - b.sortSeq) || (a.localId - b.localId))
return messages
} finally {
await wcdbService.closeMessageCursor(cursorResult.cursor).catch(() => {})
if (task.cursor === cursorResult.cursor) task.cursor = undefined
}
}
private prepareMonthMaterial(messages: Message[], peerDisplayName: string): PreparedMonthMaterial {
const stats = this.computeMonthStats(messages)
const lines = messages.map((message) => this.formatMessageLine(message, peerDisplayName))
const fullText = lines.join('\n')
if (messages.length <= DIRECT_MONTH_MESSAGE_LIMIT && fullText.length <= MONTH_MATERIAL_CHAR_LIMIT) {
return {
text: fullText,
compressed: false,
stats,
scannedMessages: messages.length,
sampledMessages: messages.length
}
}
const statsText = this.formatMonthStats(stats)
const selectedIndices = this.selectRepresentativeIndices(messages)
const sampledLines = Array.from(selectedIndices)
.sort((a, b) => a - b)
.map((index) => lines[index])
const sampledText = this.fitLinesToBudget(sampledLines, Math.max(10_000, MONTH_MATERIAL_CHAR_LIMIT - statsText.length - 800))
const text = [
'本月聊天记录已完整扫描。由于原文过长,以下为本地统计摘要、时间均匀抽样与高信息密度片段;请基于这些证据谨慎概括,不要把抽样片段视为全部事实。',
'',
statsText,
'',
'代表性聊天片段(按时间顺序):',
sampledText || '无可读文本片段'
].join('\n')
return {
text: truncateStructuredText(text, MONTH_MATERIAL_CHAR_LIMIT),
compressed: true,
stats,
scannedMessages: messages.length,
sampledMessages: sampledLines.length
}
}
private computeMonthStats(messages: Message[]): MonthStats {
const daySet = new Set<string>()
const hourCounts = new Map<number, number>()
let mine = 0
let peer = 0
let firstTime = 0
let lastTime = 0
for (const message of messages) {
const ts = Math.max(0, Math.floor(Number(message.createTime || 0)))
if (ts > 0) {
if (!firstTime || ts < firstTime) firstTime = ts
if (!lastTime || ts > lastTime) lastTime = ts
const date = new Date(ts * 1000)
const day = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
daySet.add(day)
const hour = date.getHours()
hourCounts.set(hour, (hourCounts.get(hour) || 0) + 1)
}
if (message.isSend === 1) mine += 1
else peer += 1
}
const sortedDays = Array.from(daySet).sort()
let longestActiveDayStreak = 0
let currentStreak = 0
let longestSilenceDays = 0
let prevDayTime = 0
for (const day of sortedDays) {
const dayTime = new Date(`${day}T00:00:00`).getTime()
if (!prevDayTime || dayTime - prevDayTime === 86_400_000) {
currentStreak += 1
} else {
currentStreak = 1
longestSilenceDays = Math.max(longestSilenceDays, Math.floor((dayTime - prevDayTime) / 86_400_000) - 1)
}
prevDayTime = dayTime
longestActiveDayStreak = Math.max(longestActiveDayStreak, currentStreak)
}
const topHours = Array.from(hourCounts.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 3)
.map(([hour, count]) => `${String(hour).padStart(2, '0')}:00(${count}条)`)
return {
total: messages.length,
mine,
peer,
activeDays: daySet.size,
longestActiveDayStreak,
longestSilenceDays,
topHours,
firstTime: firstTime || undefined,
lastTime: lastTime || undefined
}
}
private formatMonthStats(stats: MonthStats): string {
return [
'本月统计摘要:',
`消息总数:${stats.total}`,
`我发送:${stats.mine};对方发送:${stats.peer}`,
`活跃天数:${stats.activeDays}`,
`最长连续活跃:${stats.longestActiveDayStreak} 天`,
`最长无互动间隔:${stats.longestSilenceDays} 天`,
`主要互动时段:${stats.topHours.length > 0 ? stats.topHours.join('、') : '无'}`,
`首条消息时间:${stats.firstTime ? formatDateTime(stats.firstTime) : '无'}`,
`末条消息时间:${stats.lastTime ? formatDateTime(stats.lastTime) : '无'}`
].join('\n')
}
private selectRepresentativeIndices(messages: Message[]): Set<number> {
const selected = new Set<number>()
const addWindow = (center: number, radius = 2) => {
for (let index = Math.max(0, center - radius); index <= Math.min(messages.length - 1, center + radius); index += 1) {
selected.add(index)
}
}
if (messages.length === 0) return selected
const bucketCount = Math.min(24, Math.max(6, Math.ceil(messages.length / 250)))
for (let bucket = 0; bucket < bucketCount; bucket += 1) {
const start = Math.floor((messages.length * bucket) / bucketCount)
const end = Math.max(start, Math.floor((messages.length * (bucket + 1)) / bucketCount) - 1)
addWindow(start, 1)
addWindow(Math.floor((start + end) / 2), 1)
addWindow(end, 1)
}
const scored = messages.map((message, index) => ({
index,
score: this.scoreMessageForProfile(message)
}))
.filter((item) => item.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, 120)
for (const item of scored) {
addWindow(item.index, 2)
}
addWindow(0, 3)
addWindow(Math.floor(messages.length / 2), 3)
addWindow(messages.length - 1, 3)
return selected
}
private scoreMessageForProfile(message: Message): number {
const content = this.extractReadableContent(message)
if (!content || content.startsWith('[')) return 0
const emotionWords = [
'谢谢', '感谢', '抱歉', '对不起', '开心', '高兴', '难过', '委屈', '生气', '焦虑', '压力', '累',
'想你', '喜欢', '爱', '在乎', '担心', '害怕', '烦', '崩溃', '见面', '一起', '约', '陪', '帮'
]
let score = Math.min(80, content.length)
if (/[??]/.test(content)) score += 18
if (/[!!]{1,}/.test(content)) score += 8
for (const word of emotionWords) {
if (content.includes(word)) score += 24
}
if (message.quotedContent) score += 12
if (content.length >= 80) score += 16
return score
}
private fitLinesToBudget(lines: string[], budget: number): string {
const output: string[] = []
let used = 0
for (const line of lines) {
const normalized = clampText(line, 700)
const nextUsed = used + normalized.length + 1
if (nextUsed > budget) break
output.push(normalized)
used = nextUsed
}
return output.join('\n')
}
private extractReadableContent(message: Message): string {
const parsed = String(message.parsedContent || '').trim()
if (parsed) return clampText(parsed, 600)
const raw = String(message.rawContent || message.content || '').trim()
if (!raw) return '[其他消息]'
if (/^(<\?xml|<msg\b|<appmsg\b|<img\b|<emoji\b|<voip\b|<sysmsg\b|&lt;)/i.test(raw)) {
return '[其他消息]'
}
return clampText(raw, 600)
}
private formatMessageLine(message: Message, peerDisplayName: string): string {
const sender = message.isSend === 1 ? '我' : peerDisplayName
const content = this.extractReadableContent(message)
const quoted = message.quotedContent ? ` [引用:${clampText(message.quotedContent, 120)}]` : ''
return `${formatDateTime(message.createTime)} ${sender}:${content}${quoted}`
}
private async generateMonthlySummary(
config: SharedAiModelConfig,
displayName: string,
monthLabel: string,
material: PreparedMonthMaterial,
signal?: AbortSignal
): Promise<string> {
const systemPrompt = `你是一个克制、细致的长期关系画像分析助手。你只根据给定聊天材料分析,不做诊断,不给道德评判,不编造事实。你的目标是从一个自然月的聊天中提炼这个人的沟通风格、情绪模式、关系需求、关注主题、互动节奏,以及与“我”的关系变化线索。
要求:
1. 输出中文纯文本,不使用 Markdown。
2. 控制在 400-600 字。
3. 必须区分“有证据支持的观察”和“不确定但可留意的倾向”。
4. 不要逐条复述聊天记录,要提炼稳定模式和本月变化。
5. 对敏感内容使用概括,不输出隐私细节。
6. 如果材料经过压缩或抽样,明确保持谨慎,不把局部片段当成全部事实。`
const userPrompt = appendPromptCurrentTime(`对象:${displayName}
月份:${monthLabel}
材料状态:${material.compressed ? '本月原始记录过长,已完整扫描后进行本地结构化压缩与代表性抽样' : '本月记录未超过预算,按时间顺序提供'}
扫描消息数:${material.scannedMessages}
用于输入的代表消息数:${material.sampledMessages}
本月聊天材料:
${material.text}
请输出本月画像总结,覆盖:沟通风格、情绪与压力线索、关注主题、与我的互动模式、本月关系变化、后续相处建议。`)
return callProfileApiWithRetry(
config,
[
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt }
],
Math.max(config.maxTokens, MONTHLY_OUTPUT_MIN_TOKENS),
signal
)
}
private async generateFinalProfile(
config: SharedAiModelConfig,
displayName: string,
months: MonthWindow[],
emptyMonths: string[],
monthlySummaries: MonthSummary[],
signal?: AbortSignal
): Promise<string> {
const systemPrompt = `你是用户的私人关系画像整理助手。你需要把最近 12 个自然月的月度画像总结合成为一份长期 AI 画像。你只能基于月度总结和空月信息判断,不编造缺失月份内容。
要求:
1. 输出中文纯文本,不使用 Markdown。
2. 控制在 900-1400 字。
3. 画像要稳定、克制、可用于后续 AI 见解上下文。
4. 优先总结长期模式,其次指出近三个月变化。
5. 给出与这个人互动时最值得注意的 3-5 条原则。
6. 不做医学、法律、心理诊断;避免贴标签式结论。`
const summaryText = monthlySummaries
.map((item) => `【${item.month}】消息数:${item.messageCount};材料${item.compressed ? '已压缩抽样' : '未压缩'}\n${item.summary}`)
.join('\n\n')
const userPrompt = appendPromptCurrentTime(`对象:${displayName}
时间范围:${months[0].label} 至 ${months[months.length - 1].label}
空月:${emptyMonths.length > 0 ? emptyMonths.join('、') : '无'}
月度总结:
${summaryText}
请生成完整 AI 画像,结构包含:整体印象、沟通风格、情绪/压力模式、核心关注、关系互动模式、最近变化、相处建议、后续 AI 见解使用注意事项。`)
return callProfileApiWithRetry(
config,
[
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt }
],
Math.max(config.maxTokens, FINAL_OUTPUT_MIN_TOKENS),
signal
)
}
}
export const insightProfileService = new InsightProfileService()
+6
View File
@@ -21,6 +21,7 @@ import { chatService, ChatSession, Message } from './chatService'
import { snsService } from './snsService'
import { weiboService } from './social/weiboService'
import { showNotification } from '../windows/notificationWindow'
import { insightProfileService } from './insightProfileService'
import {
insightRecordService,
type InsightRecordLog,
@@ -385,6 +386,7 @@ class InsightService {
this.clearTimers()
this.clearRuntimeCache()
this.processing = false
insightProfileService.cancelActiveTask('AI 见解服务已停止,画像任务已取消')
if (hadActiveFlow) {
insightLog('INFO', '已停止')
}
@@ -400,6 +402,7 @@ class InsightService {
}
if (normalizedKey === 'dbPath' || normalizedKey === 'decryptKey' || normalizedKey === 'myWxid') {
insightProfileService.cancelActiveTask('数据库或账号配置已变化,画像任务已取消')
this.clearRuntimeCache()
}
@@ -409,6 +412,7 @@ class InsightService {
handleConfigCleared(): void {
this.clearTimers()
this.clearRuntimeCache()
insightProfileService.cancelActiveTask('配置已清除,画像任务已取消')
this.processing = false
}
@@ -1467,6 +1471,7 @@ ${afterText}
const momentsContextSection = await this.getMomentsContextSection(sessionId)
const socialContextSection = await this.getSocialContextSection(sessionId)
const profileContextSection = insightProfileService.getProfileContextSection(sessionId)
// ── 默认 system prompt(稳定内容,有利于 provider 端 prompt cache 命中)────
const DEFAULT_SYSTEM_PROMPT = `你是用户的私人关系观察助手,名叫"见解"。你的任务是主动提供有价值的观察和建议。
@@ -1486,6 +1491,7 @@ ${afterText}
? `已 ${silentDays} 天未联系「${resolvedDisplayName}」。`
: '',
contextSection,
profileContextSection,
momentsContextSection,
socialContextSection,
'请给出你的见解(≤80字):'
+139 -13
View File
@@ -3390,14 +3390,17 @@
}
&.insight-social-tab {
--insight-moments-column-width: 76px;
--insight-social-column-width: minmax(220px, 300px);
--insight-status-column-width: 82px;
--insight-social-list-grid: minmax(0, 1fr) var(--insight-moments-column-width) var(--insight-social-column-width) var(--insight-status-column-width);
--insight-moments-column-width: 44px;
--insight-profile-column-width: 78px;
--insight-social-column-width: minmax(184px, 230px);
--insight-status-column-width: 70px;
--insight-social-list-grid: minmax(0, 1fr) var(--insight-moments-column-width) var(--insight-profile-column-width) var(--insight-social-column-width) var(--insight-status-column-width);
.anti-revoke-list-header {
grid-template-columns: var(--insight-social-list-grid);
gap: 14px;
gap: 8px;
padding-left: 14px;
padding-right: 10px;
.insight-moments-column-title {
display: flex;
@@ -3405,6 +3408,12 @@
color: var(--text-tertiary);
}
.insight-profile-column-title {
display: flex;
justify-content: center;
color: var(--text-tertiary);
}
.insight-social-column-title {
min-width: 0;
color: var(--text-tertiary);
@@ -3420,11 +3429,19 @@
display: grid;
grid-template-columns: var(--insight-social-list-grid);
align-items: center;
gap: 14px;
gap: 8px;
padding-left: 14px;
padding-right: 10px;
}
.anti-revoke-row-main {
min-width: 0;
gap: 8px;
.anti-revoke-check {
width: 16px;
height: 16px;
}
}
.insight-moments-cell {
@@ -3435,6 +3452,92 @@
min-height: 30px;
}
.insight-profile-cell {
min-width: 0;
display: flex;
align-items: center;
justify-content: center;
min-height: 30px;
}
.insight-profile-status-btn {
position: relative;
width: 74px;
height: 28px;
border-radius: 999px;
border: 1px solid color-mix(in srgb, var(--border-color) 84%, transparent);
background: color-mix(in srgb, var(--bg-secondary) 90%, var(--bg-primary) 10%);
color: var(--text-secondary);
display: inline-flex;
align-items: center;
justify-content: center;
gap: 5px;
font-size: 12px;
line-height: 1;
white-space: nowrap;
cursor: pointer;
transition: border-color 0.16s ease, background 0.16s ease, color 0.16s ease, opacity 0.16s ease;
&:disabled {
cursor: not-allowed;
opacity: 0.48;
}
&:not(:disabled)::after {
content: attr(data-tooltip);
position: absolute;
left: 50%;
bottom: calc(100% + 8px);
transform: translateX(-50%) translateY(2px);
z-index: 8;
max-width: 180px;
width: max-content;
padding: 6px 8px;
border-radius: 6px;
background: color-mix(in srgb, var(--text-primary) 92%, #000 8%);
color: var(--bg-primary);
font-size: 12px;
line-height: 1.2;
pointer-events: none;
opacity: 0;
visibility: hidden;
transition: opacity 0.12s ease, transform 0.12s ease, visibility 0.12s ease;
}
&:not(:disabled):hover::after,
&:not(:disabled):focus-visible::after {
opacity: 1;
visibility: visible;
transform: translateX(-50%) translateY(0);
}
.profile-status-dot {
width: 6px;
height: 6px;
border-radius: 999px;
background: color-mix(in srgb, var(--text-tertiary) 86%, transparent);
flex-shrink: 0;
}
&.ready {
color: var(--primary);
border-color: color-mix(in srgb, var(--primary) 35%, var(--border-color));
background: color-mix(in srgb, var(--primary) 10%, var(--bg-secondary));
}
&.running {
color: color-mix(in srgb, var(--primary) 70%, var(--text-primary) 30%);
border-color: color-mix(in srgb, var(--primary) 30%, var(--border-color));
background: color-mix(in srgb, var(--primary) 9%, var(--bg-secondary));
}
&.failed {
color: color-mix(in srgb, var(--danger) 72%, var(--text-primary) 28%);
border-color: color-mix(in srgb, var(--danger) 24%, var(--border-color));
background: color-mix(in srgb, var(--danger) 8%, var(--bg-secondary));
}
}
.insight-moments-toggle {
position: relative;
width: 18px;
@@ -3489,24 +3592,33 @@
}
.insight-social-binding-cell {
min-width: 0;
display: flex;
align-items: flex-start;
gap: 6px;
flex-wrap: wrap;
}
.insight-social-binding-controls {
min-width: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px 10px;
grid-template-columns: minmax(92px, 1fr) auto;
gap: 6px;
align-items: center;
flex: 1 1 100%;
}
.insight-social-binding-input-wrap {
min-width: 0;
display: flex;
align-items: center;
gap: 8px;
gap: 5px;
}
.binding-platform-chip {
flex-shrink: 0;
border-radius: 999px;
padding: 2px 7px;
padding: 2px 6px;
font-size: 11px;
color: var(--text-secondary);
border: 1px solid color-mix(in srgb, var(--border-color) 84%, transparent);
@@ -3536,12 +3648,18 @@
display: inline-flex;
align-items: center;
justify-self: flex-end;
gap: 8px;
gap: 4px;
.btn {
min-width: 0;
padding-left: 8px;
padding-right: 8px;
}
}
.insight-social-binding-feedback {
grid-column: 1 / span 2;
min-height: 18px;
flex: 1 1 100%;
}
.binding-feedback {
@@ -3563,6 +3681,11 @@
align-items: flex-end;
max-width: none;
min-width: 0;
.status-badge {
padding-left: 8px;
padding-right: 8px;
}
}
}
@@ -3605,6 +3728,7 @@
grid-template-columns: minmax(0, 1fr) auto;
.insight-moments-column-title,
.insight-profile-column-title,
.insight-social-column-title {
display: none;
}
@@ -3617,12 +3741,14 @@
}
.insight-moments-cell,
.insight-profile-cell,
.insight-social-binding-cell,
.anti-revoke-row-status {
width: 100%;
}
.insight-moments-cell {
.insight-moments-cell,
.insight-profile-cell {
justify-content: flex-start;
}
+194 -51
View File
@@ -1,5 +1,6 @@
import { useState, useEffect, useRef } from 'react'
import { useLocation } from 'react-router-dom'
import { useMemo } from 'react'
import { useAppStore } from '../stores/appStore'
import { useChatStore } from '../stores/chatStore'
import { useThemeStore, themes } from '../stores/themeStore'
@@ -8,6 +9,7 @@ import { dialog } from '../services/ipc'
import * as configService from '../services/config'
import groupSummaryPrompt from '../../shared/groupSummaryPrompt.json'
import type { ChatSession, ContactInfo } from '../types/models'
import type { InsightProfileStatus } from '../types/electron'
import {
Eye, EyeOff, FolderSearch, FolderOpen, Search, Copy,
RotateCcw, Trash2, Plug, Check, Sun, Moon, Monitor,
@@ -331,6 +333,8 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
const [weiboBindingDrafts, setWeiboBindingDrafts] = useState<Record<string, string>>({})
const [weiboBindingErrors, setWeiboBindingErrors] = useState<Record<string, string>>({})
const [weiboBindingLoadingSessionId, setWeiboBindingLoadingSessionId] = useState<string | null>(null)
const [aiInsightProfileStatuses, setAiInsightProfileStatuses] = useState<Record<string, InsightProfileStatus>>({})
const [aiInsightProfileActiveSessionId, setAiInsightProfileActiveSessionId] = useState<string | null>(null)
const [aiFootprintEnabled, setAiFootprintEnabled] = useState(false)
const [aiFootprintSystemPrompt, setAiFootprintSystemPrompt] = useState('')
const [aiGroupSummaryEnabled, setAiGroupSummaryEnabled] = useState(false)
@@ -2861,37 +2865,41 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
showMessage('已清空主动推送过滤列表', true)
}
const sessionFilterOptionMap = new Map<string, SessionFilterOption>()
const { sessionFilterOptionMap, sessionFilterOptions } = useMemo(() => {
const optionMap = new Map<string, SessionFilterOption>()
for (const session of chatSessions) {
if (session.username.toLowerCase().includes('placeholder_foldgroup')) continue
sessionFilterOptionMap.set(session.username, {
username: session.username,
displayName: session.displayName || session.username,
avatarUrl: session.avatarUrl,
type: getSessionFilterType(session)
})
}
for (const session of chatSessions) {
if (session.username.toLowerCase().includes('placeholder_foldgroup')) continue
optionMap.set(session.username, {
username: session.username,
displayName: session.displayName || session.username,
avatarUrl: session.avatarUrl,
type: getSessionFilterType(session)
})
}
for (const contact of messagePushContactOptions) {
if (!contact.username) continue
if (contact.type !== 'friend' && contact.type !== 'group' && contact.type !== 'official' && contact.type !== 'former_friend') continue
const existing = sessionFilterOptionMap.get(contact.username)
sessionFilterOptionMap.set(contact.username, {
username: contact.username,
displayName: existing?.displayName || contact.displayName || contact.remark || contact.nickname || contact.username,
avatarUrl: existing?.avatarUrl || contact.avatarUrl,
type: getSessionFilterType(contact)
})
}
for (const contact of messagePushContactOptions) {
if (!contact.username) continue
if (contact.type !== 'friend' && contact.type !== 'group' && contact.type !== 'official' && contact.type !== 'former_friend') continue
const existing = optionMap.get(contact.username)
optionMap.set(contact.username, {
username: contact.username,
displayName: existing?.displayName || contact.displayName || contact.remark || contact.nickname || contact.username,
avatarUrl: existing?.avatarUrl || contact.avatarUrl,
type: getSessionFilterType(contact)
})
}
const sessionFilterOptions = Array.from(sessionFilterOptionMap.values())
.sort((a, b) => {
const aSession = chatSessions.find(session => session.username === a.username)
const bSession = chatSessions.find(session => session.username === b.username)
return Number(bSession?.sortTimestamp || bSession?.lastTimestamp || 0) -
Number(aSession?.sortTimestamp || aSession?.lastTimestamp || 0)
})
const options = Array.from(optionMap.values())
.sort((a, b) => {
const aSession = chatSessions.find(session => session.username === a.username)
const bSession = chatSessions.find(session => session.username === b.username)
return Number(bSession?.sortTimestamp || bSession?.lastTimestamp || 0) -
Number(aSession?.sortTimestamp || aSession?.lastTimestamp || 0)
})
return { sessionFilterOptionMap: optionMap, sessionFilterOptions: options }
}, [chatSessions, messagePushContactOptions])
const getSessionFilterOptionInfo = (username: string) => {
return sessionFilterOptionMap.get(username) || {
@@ -3268,6 +3276,120 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
await configService.setAiInsightWeiboBindings(nextBindings)
if (!silent) showMessage('已清除微博绑定', true)
}
const refreshAiInsightProfileStatuses = async (sessionIds?: string[]) => {
const ids = normalizeSessionIds(
sessionIds || sessionFilterOptions
.filter((session) => session.type === 'private')
.map((session) => session.username)
)
if (ids.length === 0) {
setAiInsightProfileStatuses({})
setAiInsightProfileActiveSessionId(null)
return
}
try {
const result = await window.electronAPI.insight.listProfileStatuses(ids)
if (!result.success) return
setAiInsightProfileStatuses(result.statuses || {})
setAiInsightProfileActiveSessionId(result.activeTask?.sessionId || null)
} catch (error) {
console.warn('刷新 AI 画像状态失败:', error)
}
}
const handleGenerateInsightProfile = async (session: SessionFilterOption) => {
const sessionId = session.username
const currentStatus = aiInsightProfileStatuses[sessionId]
if (currentStatus?.status === 'running') {
try {
const result = await window.electronAPI.insight.cancelProfile(sessionId)
showMessage(result.message || '已请求取消画像任务', result.success)
} catch (e: any) {
showMessage(`取消画像失败:${e?.message || String(e)}`, false)
} finally {
setTimeout(() => { void refreshAiInsightProfileStatuses() }, 500)
}
return
}
if (aiInsightProfileActiveSessionId && aiInsightProfileActiveSessionId !== sessionId) return
setAiInsightProfileStatuses((prev) => ({
...prev,
[sessionId]: {
sessionId,
status: 'running',
phase: '正在初始化画像...',
updatedAt: Date.now()
}
}))
setAiInsightProfileActiveSessionId(sessionId)
try {
const result = await window.electronAPI.insight.generateProfile({
sessionId,
displayName: session.displayName || session.username,
avatarUrl: session.avatarUrl
})
showMessage(result.message || (result.success ? '画像完成' : '画像失败'), result.success)
} catch (e: any) {
showMessage(`画像失败:${e?.message || String(e)}`, false)
} finally {
await refreshAiInsightProfileStatuses()
}
}
useEffect(() => {
if (activeTab !== 'insight') return
const ids = sessionFilterOptions
.filter((session) => session.type === 'private')
.map((session) => session.username)
if (ids.length === 0) return
void refreshAiInsightProfileStatuses(ids)
const timer = window.setInterval(() => {
void refreshAiInsightProfileStatuses(ids)
}, 2500)
return () => window.clearInterval(timer)
}, [activeTab, sessionFilterOptions])
const getInsightProfileButtonMeta = (sessionId: string) => {
const status = aiInsightProfileStatuses[sessionId]
const activeOther = Boolean(aiInsightProfileActiveSessionId && aiInsightProfileActiveSessionId !== sessionId)
if (status?.status === 'running') {
return {
className: 'running',
label: '取消',
title: status.phase || '画像生成中,点击取消',
disabled: false,
icon: <Loader2 size={13} style={{ animation: 'spin 1s linear infinite' }} />
}
}
if (status?.status === 'ready') {
return {
className: 'ready',
label: '已画像',
title: activeOther ? '其他联系人正在画像中' : '点击以重新画像',
disabled: activeOther,
icon: <Check size={13} />
}
}
if (status?.status === 'failed') {
return {
className: 'failed',
label: '失败',
title: activeOther ? '其他联系人正在画像中' : (status.error || '画像失败,点击重试'),
disabled: activeOther,
icon: <XCircle size={13} />
}
}
return {
className: 'none',
label: '未画像',
title: activeOther ? '其他联系人正在画像中' : '点击进行画像',
disabled: activeOther,
icon: <i className="profile-status-dot" aria-hidden="true" />
}
}
const renderInsightTab = () => (
<div className="tab-content">
{/* 总开关 */}
@@ -3842,6 +3964,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
<div className="anti-revoke-list-header">
<span>对话({filteredSessions.length})</span>
<span className="insight-moments-column-title">朋友圈</span>
<span className="insight-profile-column-title">画像</span>
<span className="insight-social-column-title">社交平台(微博)</span>
<span className="anti-revoke-status-column-title">状态</span>
</div>
@@ -3853,6 +3976,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
const weiboDraftValue = getWeiboBindingDraftValue(session.username)
const isBindingLoading = weiboBindingLoadingSessionId === session.username
const weiboBindingError = weiboBindingErrors[session.username]
const profileButtonMeta = getInsightProfileButtonMeta(session.username)
return (
<div
key={session.username}
@@ -3903,37 +4027,56 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
<span className="binding-feedback muted">-</span>
)}
</div>
<div className="insight-profile-cell">
{isPrivateSession ? (
<button
type="button"
className={`insight-profile-status-btn ${profileButtonMeta.className}`}
title={profileButtonMeta.title}
data-tooltip={profileButtonMeta.title}
disabled={profileButtonMeta.disabled}
onClick={() => void handleGenerateInsightProfile(session)}
>
{profileButtonMeta.icon}
<span>{profileButtonMeta.label}</span>
</button>
) : (
<span className="binding-feedback muted">-</span>
)}
</div>
<div className="insight-social-binding-cell">
{isPrivateSession ? (
<>
<div className="insight-social-binding-input-wrap">
<span className="binding-platform-chip">微博</span>
<input
type="text"
className="insight-social-binding-input"
value={weiboDraftValue}
placeholder="填写数字 UID"
onChange={(e) => updateWeiboBindingDraft(session.username, e.target.value)}
/>
</div>
<div className="insight-social-binding-actions">
<button
type="button"
className="btn btn-secondary btn-sm"
onClick={() => void handleSaveWeiboBinding(session.username, session.displayName || session.username)}
disabled={isBindingLoading || !weiboDraftValue.trim()}
>
{isBindingLoading ? '绑定中...' : (weiboBinding ? '更新' : '绑定')}
</button>
{weiboBinding && (
<div className="insight-social-binding-controls">
<div className="insight-social-binding-input-wrap">
<span className="binding-platform-chip">微博</span>
<input
type="text"
className="insight-social-binding-input"
value={weiboDraftValue}
placeholder="填写数字 UID"
onChange={(e) => updateWeiboBindingDraft(session.username, e.target.value)}
/>
</div>
<div className="insight-social-binding-actions">
<button
type="button"
className="btn btn-secondary btn-sm"
onClick={() => void handleClearWeiboBinding(session.username)}
onClick={() => void handleSaveWeiboBinding(session.username, session.displayName || session.username)}
disabled={isBindingLoading || !weiboDraftValue.trim()}
>
清除
{isBindingLoading ? '绑定中...' : (weiboBinding ? '更新' : '绑定')}
</button>
)}
{weiboBinding && (
<button
type="button"
className="btn btn-secondary btn-sm"
onClick={() => void handleClearWeiboBinding(session.username)}
>
清除
</button>
)}
</div>
</div>
<div className="insight-social-binding-feedback">
{weiboBindingError ? (
+37
View File
@@ -124,6 +124,36 @@ export interface InsightRecordResult {
error?: string
}
export type InsightProfileStatusValue = 'none' | 'ready' | 'running' | 'failed'
export interface InsightProfileStatus {
sessionId: string
status: InsightProfileStatusValue
updatedAt?: number
error?: string
phase?: string
busy?: boolean
}
export interface InsightProfileStatusListResult {
success: boolean
statuses: Record<string, InsightProfileStatus>
activeTask?: {
sessionId: string
displayName: string
phase: string
startedAt: number
}
error?: string
}
export interface InsightProfileGenerateResult {
success: boolean
message: string
cancelled?: boolean
error?: string
}
export type GroupSummaryTriggerType = 'auto' | 'manual'
export interface GroupSummaryTopic {
@@ -1421,6 +1451,13 @@ export interface ElectronAPI {
displayName?: string
avatarUrl?: string
}) => Promise<{ success: boolean; message: string; recordId?: string; insight?: string; skipped?: boolean; notificationEnabled?: boolean }>
listProfileStatuses: (sessionIds: string[]) => Promise<InsightProfileStatusListResult>
generateProfile: (payload: {
sessionId: string
displayName?: string
avatarUrl?: string
}) => Promise<InsightProfileGenerateResult>
cancelProfile: (sessionId?: string) => Promise<{ success: boolean; message: string }>
generateFootprintInsight: (payload: {
rangeLabel: string
summary: {