mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-05-27 15:11:23 +00:00
1002 lines
34 KiB
TypeScript
1002 lines
34 KiB
TypeScript
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()
|