This commit is contained in:
cc
2026-04-30 00:00:04 +08:00
12 changed files with 912 additions and 103 deletions

View File

@@ -85,7 +85,13 @@ interface ConfigSchema {
aiInsightApiModel: string
aiInsightSilenceDays: number
aiInsightAllowContext: boolean
aiInsightAllowMomentsContext: boolean
aiInsightMomentsContextCount: number
aiInsightMomentsBindings: Record<string, { enabled: boolean; updatedAt: number }>
aiInsightAllowSocialContext: boolean
aiInsightSocialContextCount: number
aiInsightWeiboCookie: string
aiInsightWeiboBindings: Record<string, { uid: string; screenName?: string; updatedAt: number }>
aiInsightFilterMode: 'whitelist' | 'blacklist'
aiInsightFilterList: string[]
aiInsightWhitelistEnabled: boolean
@@ -110,6 +116,8 @@ interface ConfigSchema {
aiFootprintSystemPrompt: string
/** 是否将 AI 见解调试日志输出到桌面 */
aiInsightDebugLogEnabled: boolean
autoDownloadHighRes: boolean
autoDownloadWhitelist: string[]
}
// 需要 safeStorage 加密的字段(普通模式)
@@ -205,6 +213,9 @@ export class ConfigService {
aiInsightApiModel: 'gpt-4o-mini',
aiInsightSilenceDays: 3,
aiInsightAllowContext: false,
aiInsightAllowMomentsContext: false,
aiInsightMomentsContextCount: 5,
aiInsightMomentsBindings: {},
aiInsightAllowSocialContext: false,
aiInsightFilterMode: 'whitelist',
aiInsightFilterList: [],
@@ -222,7 +233,9 @@ export class ConfigService {
aiInsightWeiboBindings: {},
aiFootprintEnabled: false,
aiFootprintSystemPrompt: '',
aiInsightDebugLogEnabled: false
aiInsightDebugLogEnabled: false,
autoDownloadHighRes: false,
autoDownloadWhitelist: []
}
const storeOptions: any = {

View File

@@ -0,0 +1,203 @@
import { app } from 'electron'
import { join } from 'path'
import { existsSync } from 'fs'
import { execFile } from 'child_process'
import { promisify } from 'util'
// import { ConfigService } from './config'
const execFileAsync = promisify(execFile)
export class ImageDownloadService {
private static instance: ImageDownloadService
private koffi: any = null
private lib: any = null
private initialized = false
private initImgHelper: any = null
private uninstallImgHelper: any = null
private getImgHelperError: any = null
private currentPid: number | null = null
private pollTimer: NodeJS.Timeout | null = null
private isHooked = false
private lastWhitelist: string[] = []
static getInstance(): ImageDownloadService {
if (!ImageDownloadService.instance) {
ImageDownloadService.instance = new ImageDownloadService()
}
return ImageDownloadService.instance
}
private constructor() {
}
private async ensureInitialized(): Promise<boolean> {
if (this.initialized) return true
if (process.platform !== 'win32' || process.arch !== 'x64') return false
try {
this.koffi = require('koffi')
const dllPath = this.getDllPath()
if (!existsSync(dllPath)) return false
this.lib = this.koffi.load(dllPath)
this.initImgHelper = this.lib.func('bool InitImgHelper(uint32, const char*)')
this.uninstallImgHelper = this.lib.func('void UninstallImgHelper()')
this.getImgHelperError = this.lib.func('const char* GetImgHelperError()')
this.initialized = true
return true
} catch (error) {
console.error('[ImageDownloadService] failed to initialize:', error)
return false
}
}
private getDllPath(): string {
const isPackaged = app.isPackaged
const candidates: string[] = []
if (isPackaged) {
candidates.push(join(process.resourcesPath, 'resources', 'image', 'win32', 'x64', 'img_helper.dll'))
} else {
candidates.push(join(process.cwd(), 'resources', 'image', 'win32', 'x64', 'img_helper.dll'))
}
for (const path of candidates) {
if (existsSync(path)) return path
}
return candidates[0]
}
private async findMainWeChatPid(): Promise<number | null> {
try {
const script = `
Get-CimInstance Win32_Process -Filter "Name = 'Weixin.exe'" |
Select-Object ProcessId, CommandLine |
ConvertTo-Json -Compress
`;
const { stdout } = await execFileAsync('powershell', ['-NoProfile', '-Command', script])
if (!stdout || !stdout.trim()) return null
let processes = JSON.parse(stdout.trim())
if (!Array.isArray(processes)) processes = [processes]
const target = processes
.filter((p: any) => p.CommandLine && p.CommandLine.toLowerCase().includes('weixin.exe'))
.sort((a: any, b: any) => a.CommandLine.length - b.CommandLine.length)[0]
return target ? target.ProcessId : null;
} catch (e) {
return null
}
}
async startAutoDownload(whitelist: string[] | string = []): Promise<{ success: boolean; error?: string }> {
if (!await this.ensureInitialized()) {
return { success: false, error: '核心组件初始化失败' }
}
if (this.isHooked) {
await this.unhook()
}
this.lastWhitelist = whitelist
if (!this.pollTimer) {
this.pollTimer = setInterval(() => this.checkAndHook(this.lastWhitelist, false), 30000)
}
return await this.checkAndHook(whitelist, true)
}
async stopAutoDownload() {
if (this.pollTimer) {
clearInterval(this.pollTimer)
this.pollTimer = null
}
await this.unhook()
}
private async checkAndHook(whitelist: string[] | string = [], isManualStart = false): Promise<{ success: boolean; error?: string }> {
const pid = await this.findMainWeChatPid()
if (!pid) {
if (this.isHooked) {
console.log('[ImageDownloadService] WeChat exited, unhooking')
await this.unhook()
}
return { success: true, error: '等待微信启动' }
}
if (this.isHooked && this.currentPid === pid) {
return { success: true }
}
if (this.isHooked && this.currentPid !== pid) {
console.log('[ImageDownloadService] WeChat PID changed, re-hooking')
await this.unhook()
}
console.log(`[ImageDownloadService] attempting to hook PID: ${pid}`)
try {
let whitelistBuffer: Buffer | null = null;
if (typeof whitelist === 'string') {
if (whitelist.length > 0) {
whitelistBuffer = Buffer.from(whitelist, 'utf8');
}
} else if (Array.isArray(whitelist) && whitelist.length > 0) {
whitelistBuffer = Buffer.from(whitelist.join('\0') + '\0\0', 'utf8');
}
const success = this.initImgHelper(pid, whitelistBuffer)
if (success) {
this.isHooked = true
this.currentPid = pid
console.log('[ImageDownloadService] hook successful')
return { success: true }
} else {
const err = this.getImgHelperError()
console.error(`[ImageDownloadService] hook failed: ${err}`)
if (isManualStart && this.pollTimer) {
clearInterval(this.pollTimer)
this.pollTimer = null
}
return { success: false, error: err || 'Hook 失败' }
}
} catch (e: any) {
console.error('[ImageDownloadService] InitImgHelper call crashed:', e)
if (isManualStart && this.pollTimer) {
clearInterval(this.pollTimer)
this.pollTimer = null
}
return { success: false, error: `调用异常: ${e.message || String(e)}` }
}
}
private async unhook() {
if (this.isHooked && this.uninstallImgHelper) {
try {
this.uninstallImgHelper()
} catch (e) {
console.error('[ImageDownloadService] uninstall failed:', e)
}
}
this.isHooked = false
this.currentPid = null
}
async getStatus() {
return {
isHooked: this.isHooked,
pid: this.currentPid,
supported: process.platform === 'win32' && process.arch === 'x64'
}
}
}
export const imageDownloadService = ImageDownloadService.getInstance()

View File

@@ -10,7 +10,7 @@
* 设计原则:
* - 不引入任何额外 npm 依赖,使用 Node 原生 https 模块调用 OpenAI 兼容 API
* - 所有失败静默处理,不影响主流程
* - 当日触发记录sessionId + 时间列表)随 prompt 一起发送,让模型自行判断是否克制
* - 触发频率、冷却与名单过滤均在本地完成,不把调度统计塞进模型 prompt
*/
import https from 'https'
@@ -21,6 +21,7 @@ import { URL } from 'url'
import { app, Notification } from 'electron'
import { ConfigService } from './config'
import { chatService, ChatSession, Message } from './chatService'
import { snsService } from './snsService'
import { weiboService } from './social/weiboService'
// ─── 常量 ────────────────────────────────────────────────────────────────────
@@ -52,6 +53,9 @@ const INSIGHT_CONFIG_KEYS = new Set([
'aiModelApiMaxTokens',
'aiInsightFilterMode',
'aiInsightFilterList',
'aiInsightAllowMomentsContext',
'aiInsightMomentsContextCount',
'aiInsightMomentsBindings',
'aiInsightAllowSocialContext',
'aiInsightSocialContextCount',
'aiInsightWeiboCookie',
@@ -445,7 +449,7 @@ class InsightService {
try {
const endpoint = buildApiUrl(apiBaseUrl, '/chat/completions')
const requestMessages = [{ role: 'user', content: appendPromptCurrentTime('请回复"连接成功"四个字。') }]
const requestMessages = [{ role: 'user', content: '请回复"连接成功"四个字。' }]
insightDebugSection(
'INFO',
'AI 测试连接请求',
@@ -823,26 +827,13 @@ ${topMentionText}
}
/**
* 记录触发并返回该会话今日所有触发时间(用于组装 prompt
* 记录成功推送的见解,用于设置页展示今日触发统计
*/
private recordTrigger(sessionId: string): string[] {
private recordTrigger(sessionId: string): void {
this.resetIfNewDay()
const existing = this.todayTriggers.get(sessionId) ?? { timestamps: [] }
existing.timestamps.push(Date.now())
this.todayTriggers.set(sessionId, existing)
return existing.timestamps.map(formatTimestamp)
}
/**
* 获取今日全局已触发次数(所有会话合计),用于 prompt 中告知模型全局上下文。
*/
private getTodayTotalTriggerCount(): number {
this.resetIfNewDay()
let total = 0
for (const record of this.todayTriggers.values()) {
total += record.timestamps.length
}
return total
}
private formatWeiboTimestamp(raw: string): string {
@@ -853,12 +844,66 @@ ${topMentionText}
return new Date(parsed).toLocaleString('zh-CN')
}
private formatMomentsTimestamp(raw: unknown): string {
const numeric = Number(raw)
if (!Number.isFinite(numeric) || numeric <= 0) {
return ''
}
const ms = numeric > 1_000_000_000_000 ? numeric : numeric * 1000
return new Date(ms).toLocaleString('zh-CN')
}
private extractMomentReadableText(post: { contentDesc?: unknown; linkTitle?: unknown }): string {
const contentDesc = this.normalizeInsightText(String(post.contentDesc || '')).replace(/\s+/g, ' ').trim()
if (contentDesc) return contentDesc
const linkTitle = this.normalizeInsightText(String(post.linkTitle || '')).replace(/\s+/g, ' ').trim()
if (linkTitle) return `[链接] ${linkTitle}`
return ''
}
private async getMomentsContextSection(sessionId: string): Promise<string> {
const allowMomentsContext = this.config.get('aiInsightAllowMomentsContext') === true
if (!allowMomentsContext) return ''
const bindings =
(this.config.get('aiInsightMomentsBindings') as Record<string, { enabled?: boolean }> | undefined) || {}
const isEnabledForSession = bindings[sessionId]?.enabled === true
if (!isEnabledForSession) return ''
const countRaw = Number(this.config.get('aiInsightMomentsContextCount') || 5)
const momentsCount = Math.max(1, Math.min(20, Math.floor(countRaw) || 5))
try {
const result = await snsService.getTimeline(momentsCount, 0, [sessionId])
const posts = result.success && Array.isArray(result.timeline) ? result.timeline : []
if (posts.length === 0) return ''
const lines = posts
.map((post) => {
const text = this.extractMomentReadableText(post as { contentDesc?: unknown; linkTitle?: unknown })
if (!text) return ''
const shortText = text.length > 180 ? `${text.slice(0, 180)}...` : text
const time = this.formatMomentsTimestamp((post as { createTime?: unknown }).createTime)
return time ? `[朋友圈 ${time}] ${shortText}` : `[朋友圈] ${shortText}`
})
.filter(Boolean) as string[]
if (lines.length === 0) return ''
insightLog('INFO', `已加载 ${lines.length} 条朋友圈内容 (sessionId=${sessionId})`)
return `近期朋友圈内容(最近 ${lines.length} 条):\n${lines.join('\n')}`
} catch (error) {
insightLog('WARN', `拉取朋友圈内容失败 (sessionId=${sessionId}): ${(error as Error).message}`)
return ''
}
}
private async getSocialContextSection(sessionId: string): Promise<string> {
const allowSocialContext = this.config.get('aiInsightAllowSocialContext') === true
if (!allowSocialContext) return ''
const rawCookie = String(this.config.get('aiInsightWeiboCookie') || '').trim()
const hasCookie = rawCookie.length > 0
const bindings =
(this.config.get('aiInsightWeiboBindings') as Record<string, { uid?: string; screenName?: string }> | undefined) || {}
@@ -879,10 +924,7 @@ ${topMentionText}
return `[微博 ${time}] ${text}`
})
insightLog('INFO', `已加载 ${lines.length} 条微博公开内容 (uid=${uid})`)
const riskHint = hasCookie
? ''
: '\n提示未配置微博 Cookie使用移动端公开接口抓取可能因平台风控导致获取失败或内容较少。'
return `近期公开社交平台内容(来源:微博,最近 ${lines.length} 条):\n${lines.join('\n')}${riskHint}`
return `近期公开社交平台内容(来源:微博,最近 ${lines.length} 条):\n${lines.join('\n')}`
} catch (error) {
insightLog('WARN', `拉取微博公开内容失败 (uid=${uid}): ${(error as Error).message}`)
return ''
@@ -1118,10 +1160,6 @@ ${topMentionText}
// ── 构建 prompt ────────────────────────────────────────────────────────────
// 今日触发统计(让模型具备时间与克制感)
const sessionTriggerTimes = this.recordTrigger(sessionId)
const totalTodayTriggers = this.getTodayTotalTriggerCount()
let contextSection = ''
if (allowContext) {
try {
@@ -1136,6 +1174,7 @@ ${topMentionText}
}
}
const momentsContextSection = await this.getMomentsContextSection(sessionId)
const socialContextSection = await this.getSocialContextSection(sessionId)
// ── 默认 system prompt稳定内容有利于 provider 端 prompt cache 命中)────
@@ -1151,25 +1190,12 @@ ${topMentionText}
const customPrompt = (this.config.get('aiInsightSystemPrompt') as string) || ''
const systemPrompt = customPrompt.trim() || DEFAULT_SYSTEM_PROMPT
// 可变的上下文统计信息放在 user message 里,保持 system prompt 稳定不变
// 这样 provider 端Anthropic/OpenAI能最大化命中 prompt cache降低费用
const triggerDesc =
triggerReason === 'silence'
? `你已经 ${silentDays} 天没有和「${resolvedDisplayName}」聊天了。`
: `你最近和「${resolvedDisplayName}」有新的聊天动态。`
const todayStatsDesc =
sessionTriggerTimes.length > 1
? `今天你已经针对「${resolvedDisplayName}」收到过 ${sessionTriggerTimes.length - 1} 条见解(时间:${sessionTriggerTimes.slice(0, -1).join('、')}),请适当克制。`
: `今天你还没有针对「${resolvedDisplayName}」发出过见解。`
const globalStatsDesc = `今天全部联系人合计已触发 ${totalTodayTriggers} 条见解。`
const userPromptBase = [
`触发原因:${triggerDesc}`,
`时间统计:${todayStatsDesc}`,
`全局统计:${globalStatsDesc}`,
triggerReason === 'silence' && silentDays
? `${silentDays} 天未联系「${resolvedDisplayName}」。`
: '',
contextSection,
momentsContextSection,
socialContextSection,
'请给出你的见解≤80字'
].filter(Boolean).join('\n\n')
@@ -1189,7 +1215,7 @@ ${topMentionText}
`接口地址:${endpoint}`,
`模型:${model}`,
`Max Tokens${maxTokens}`,
`触发原因${triggerReason}`,
`触发类型${triggerReason}`,
`上下文开关:${allowContext ? '开启' : '关闭'}`,
`上下文条数:${contextCount}`,
'',
@@ -1253,6 +1279,7 @@ ${topMentionText}
}
insightLog('INFO', `已为 ${resolvedDisplayName} 推送见解`)
this.recordTrigger(sessionId)
} catch (e) {
insightDebugSection(
'ERROR',