Merge pull request #1020 from Jasonzhu1207/main
feat: Add User Persona By AI For Insight
This commit is contained in:
7 files changed
+1401
-64
No files matched your search
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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|<)/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()
|
||||
@@ -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
@@ -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
@@ -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 ? (
|
||||
|
||||
Vendored
+37
@@ -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: {
|
||||
|
||||
Reference in new issue
Block a user