feat: Add AI User Persona

This commit is contained in:
Jason
2026-05-24 23:31:41 +08:00
committed by GitHub
7 changed files with 1306 additions and 31 deletions

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'
@@ -1829,6 +1830,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: {

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: {

File diff suppressed because it is too large Load Diff

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字'

View File

@@ -3391,9 +3391,10 @@
&.insight-social-tab {
--insight-moments-column-width: 76px;
--insight-social-column-width: minmax(220px, 300px);
--insight-profile-column-width: 90px;
--insight-social-column-width: minmax(190px, 260px);
--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-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);
@@ -3405,6 +3406,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);
@@ -3435,6 +3442,63 @@
min-height: 30px;
}
.insight-profile-cell {
min-width: 0;
display: flex;
align-items: center;
justify-content: center;
min-height: 30px;
}
.insight-profile-status-btn {
width: 78px;
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;
}
.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;
@@ -3605,6 +3669,7 @@
grid-template-columns: minmax(0, 1fr) auto;
.insight-moments-column-title,
.insight-profile-column-title,
.insight-social-column-title {
display: none;
}
@@ -3617,12 +3682,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;
}

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,6 +4027,22 @@ 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}
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 ? (
<>

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: {