mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-05-01 07:26:48 +00:00
Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev
This commit is contained in:
@@ -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 = {
|
||||
|
||||
203
electron/services/imageDownloadService.ts
Normal file
203
electron/services/imageDownloadService.ts
Normal 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()
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user