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

@@ -350,6 +350,8 @@ jobs:
updpkgsums: true updpkgsums: true
assets: | assets: |
resources/installer/linux/weflow.desktop resources/installer/linux/weflow.desktop
resources/installer/linux/icon.png
resources/installer/linux/.gitignore
ssh_private_key: ${{ secrets.AUR_SSH_PRIVATE_KEY }} ssh_private_key: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
commit_username: H3CoF6 commit_username: H3CoF6

View File

@@ -34,6 +34,7 @@ import { insightService } from './services/insightService'
import { normalizeWeiboCookieInput, weiboService } from './services/social/weiboService' import { normalizeWeiboCookieInput, weiboService } from './services/social/weiboService'
import { bizService } from './services/bizService' import { bizService } from './services/bizService'
import { backupService } from './services/backupService' import { backupService } from './services/backupService'
import { imageDownloadService } from './services/imageDownloadService'
// 配置自动更新 // 配置自动更新
autoUpdater.autoDownload = false autoUpdater.autoDownload = false
@@ -3954,6 +3955,19 @@ function registerIpcHandlers() {
} }
}) })
// 自动下载原图
ipcMain.handle('image:startAutoDownload', async (_, whitelist?: string[]) => {
return await imageDownloadService.startAutoDownload(whitelist || [])
})
ipcMain.handle('image:stopAutoDownload', async () => {
await imageDownloadService.stopAutoDownload()
return { success: true }
})
ipcMain.handle('image:getAutoDownloadStatus', async () => {
return await imageDownloadService.getStatus()
})
} }
// 主窗口引用 // 主窗口引用
@@ -4081,6 +4095,13 @@ app.whenReady().then(async () => {
// 注册 IPC 处理器 // 注册 IPC 处理器
updateSplashProgress(28, '正在初始化...') updateSplashProgress(28, '正在初始化...')
registerIpcHandlers() registerIpcHandlers()
if (configService.get('autoDownloadHighRes')) {
const whitelistArr = configService.get('autoDownloadWhitelist') || []
const whitelistStr = (Array.isArray(whitelistArr) && whitelistArr.length > 0)
? (whitelistArr.join('\0') + '\0\0')
: ''
imageDownloadService.startAutoDownload(whitelistStr)
}
chatService.addDbMonitorListener((type, json) => { chatService.addDbMonitorListener((type, json) => {
messagePushService.handleDbMonitorChange(type, json) messagePushService.handleDbMonitorChange(type, json)
insightService.handleDbMonitorChange(type, json) insightService.handleDbMonitorChange(type, json)
@@ -4252,6 +4273,8 @@ const shutdownAppServices = async (): Promise<void> => {
}, 5000) }, 5000)
forceExitTimer.unref() forceExitTimer.unref()
try { await cloudControlService.stop() } catch {} try { await cloudControlService.stop() } catch {}
// 停止自动下载服务
try { await imageDownloadService.stopAutoDownload() } catch {}
// 停止 chatService内部会关闭 cursor 与 DB避免退出阶段仍触发监控回调 // 停止 chatService内部会关闭 cursor 与 DB避免退出阶段仍触发监控回调
try { chatService.close() } catch {} try { chatService.close() } catch {}
// 停止 HTTP 服务器,释放 TCP 端口占用,避免进程无法退出 // 停止 HTTP 服务器,释放 TCP 端口占用,避免进程无法退出

View File

@@ -365,7 +365,10 @@ contextBridge.exposeInMainWorld('electronAPI', {
}) => callback(payload) }) => callback(payload)
ipcRenderer.on('image:decryptProgress', listener) ipcRenderer.on('image:decryptProgress', listener)
return () => ipcRenderer.removeListener('image:decryptProgress', listener) return () => ipcRenderer.removeListener('image:decryptProgress', listener)
} },
startAutoDownload: (whitelist: string[] | string) => ipcRenderer.invoke('image:startAutoDownload', whitelist),
stopAutoDownload: () => ipcRenderer.invoke('image:stopAutoDownload'),
getAutoDownloadStatus: () => ipcRenderer.invoke('image:getAutoDownloadStatus')
}, },
// 视频 // 视频
@@ -374,6 +377,11 @@ contextBridge.exposeInMainWorld('electronAPI', {
parseVideoMd5: (content: string) => ipcRenderer.invoke('video:parseVideoMd5', content) parseVideoMd5: (content: string) => ipcRenderer.invoke('video:parseVideoMd5', content)
}, },
process: {
platform: process.platform,
arch: process.arch
},
// 数据分析 // 数据分析
analytics: { analytics: {
getOverallStatistics: (force?: boolean) => ipcRenderer.invoke('analytics:getOverallStatistics', force), getOverallStatistics: (force?: boolean) => ipcRenderer.invoke('analytics:getOverallStatistics', force),

View File

@@ -85,7 +85,13 @@ interface ConfigSchema {
aiInsightApiModel: string aiInsightApiModel: string
aiInsightSilenceDays: number aiInsightSilenceDays: number
aiInsightAllowContext: boolean aiInsightAllowContext: boolean
aiInsightAllowMomentsContext: boolean
aiInsightMomentsContextCount: number
aiInsightMomentsBindings: Record<string, { enabled: boolean; updatedAt: number }>
aiInsightAllowSocialContext: boolean aiInsightAllowSocialContext: boolean
aiInsightSocialContextCount: number
aiInsightWeiboCookie: string
aiInsightWeiboBindings: Record<string, { uid: string; screenName?: string; updatedAt: number }>
aiInsightFilterMode: 'whitelist' | 'blacklist' aiInsightFilterMode: 'whitelist' | 'blacklist'
aiInsightFilterList: string[] aiInsightFilterList: string[]
aiInsightWhitelistEnabled: boolean aiInsightWhitelistEnabled: boolean
@@ -110,6 +116,8 @@ interface ConfigSchema {
aiFootprintSystemPrompt: string aiFootprintSystemPrompt: string
/** 是否将 AI 见解调试日志输出到桌面 */ /** 是否将 AI 见解调试日志输出到桌面 */
aiInsightDebugLogEnabled: boolean aiInsightDebugLogEnabled: boolean
autoDownloadHighRes: boolean
autoDownloadWhitelist: string[]
} }
// 需要 safeStorage 加密的字段(普通模式) // 需要 safeStorage 加密的字段(普通模式)
@@ -205,6 +213,9 @@ export class ConfigService {
aiInsightApiModel: 'gpt-4o-mini', aiInsightApiModel: 'gpt-4o-mini',
aiInsightSilenceDays: 3, aiInsightSilenceDays: 3,
aiInsightAllowContext: false, aiInsightAllowContext: false,
aiInsightAllowMomentsContext: false,
aiInsightMomentsContextCount: 5,
aiInsightMomentsBindings: {},
aiInsightAllowSocialContext: false, aiInsightAllowSocialContext: false,
aiInsightFilterMode: 'whitelist', aiInsightFilterMode: 'whitelist',
aiInsightFilterList: [], aiInsightFilterList: [],
@@ -222,7 +233,9 @@ export class ConfigService {
aiInsightWeiboBindings: {}, aiInsightWeiboBindings: {},
aiFootprintEnabled: false, aiFootprintEnabled: false,
aiFootprintSystemPrompt: '', aiFootprintSystemPrompt: '',
aiInsightDebugLogEnabled: false aiInsightDebugLogEnabled: false,
autoDownloadHighRes: false,
autoDownloadWhitelist: []
} }
const storeOptions: any = { 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 * - 不引入任何额外 npm 依赖,使用 Node 原生 https 模块调用 OpenAI 兼容 API
* - 所有失败静默处理,不影响主流程 * - 所有失败静默处理,不影响主流程
* - 当日触发记录sessionId + 时间列表)随 prompt 一起发送,让模型自行判断是否克制 * - 触发频率、冷却与名单过滤均在本地完成,不把调度统计塞进模型 prompt
*/ */
import https from 'https' import https from 'https'
@@ -21,6 +21,7 @@ import { URL } from 'url'
import { app, Notification } from 'electron' import { app, Notification } from 'electron'
import { ConfigService } from './config' import { ConfigService } from './config'
import { chatService, ChatSession, Message } from './chatService' import { chatService, ChatSession, Message } from './chatService'
import { snsService } from './snsService'
import { weiboService } from './social/weiboService' import { weiboService } from './social/weiboService'
// ─── 常量 ──────────────────────────────────────────────────────────────────── // ─── 常量 ────────────────────────────────────────────────────────────────────
@@ -52,6 +53,9 @@ const INSIGHT_CONFIG_KEYS = new Set([
'aiModelApiMaxTokens', 'aiModelApiMaxTokens',
'aiInsightFilterMode', 'aiInsightFilterMode',
'aiInsightFilterList', 'aiInsightFilterList',
'aiInsightAllowMomentsContext',
'aiInsightMomentsContextCount',
'aiInsightMomentsBindings',
'aiInsightAllowSocialContext', 'aiInsightAllowSocialContext',
'aiInsightSocialContextCount', 'aiInsightSocialContextCount',
'aiInsightWeiboCookie', 'aiInsightWeiboCookie',
@@ -445,7 +449,7 @@ class InsightService {
try { try {
const endpoint = buildApiUrl(apiBaseUrl, '/chat/completions') const endpoint = buildApiUrl(apiBaseUrl, '/chat/completions')
const requestMessages = [{ role: 'user', content: appendPromptCurrentTime('请回复"连接成功"四个字。') }] const requestMessages = [{ role: 'user', content: '请回复"连接成功"四个字。' }]
insightDebugSection( insightDebugSection(
'INFO', 'INFO',
'AI 测试连接请求', 'AI 测试连接请求',
@@ -823,26 +827,13 @@ ${topMentionText}
} }
/** /**
* 记录触发并返回该会话今日所有触发时间(用于组装 prompt * 记录成功推送的见解,用于设置页展示今日触发统计
*/ */
private recordTrigger(sessionId: string): string[] { private recordTrigger(sessionId: string): void {
this.resetIfNewDay() this.resetIfNewDay()
const existing = this.todayTriggers.get(sessionId) ?? { timestamps: [] } const existing = this.todayTriggers.get(sessionId) ?? { timestamps: [] }
existing.timestamps.push(Date.now()) existing.timestamps.push(Date.now())
this.todayTriggers.set(sessionId, existing) 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 { private formatWeiboTimestamp(raw: string): string {
@@ -853,12 +844,66 @@ ${topMentionText}
return new Date(parsed).toLocaleString('zh-CN') 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> { private async getSocialContextSection(sessionId: string): Promise<string> {
const allowSocialContext = this.config.get('aiInsightAllowSocialContext') === true const allowSocialContext = this.config.get('aiInsightAllowSocialContext') === true
if (!allowSocialContext) return '' if (!allowSocialContext) return ''
const rawCookie = String(this.config.get('aiInsightWeiboCookie') || '').trim() const rawCookie = String(this.config.get('aiInsightWeiboCookie') || '').trim()
const hasCookie = rawCookie.length > 0
const bindings = const bindings =
(this.config.get('aiInsightWeiboBindings') as Record<string, { uid?: string; screenName?: string }> | undefined) || {} (this.config.get('aiInsightWeiboBindings') as Record<string, { uid?: string; screenName?: string }> | undefined) || {}
@@ -879,10 +924,7 @@ ${topMentionText}
return `[微博 ${time}] ${text}` return `[微博 ${time}] ${text}`
}) })
insightLog('INFO', `已加载 ${lines.length} 条微博公开内容 (uid=${uid})`) insightLog('INFO', `已加载 ${lines.length} 条微博公开内容 (uid=${uid})`)
const riskHint = hasCookie return `近期公开社交平台内容(来源:微博,最近 ${lines.length} 条):\n${lines.join('\n')}`
? ''
: '\n提示未配置微博 Cookie使用移动端公开接口抓取可能因平台风控导致获取失败或内容较少。'
return `近期公开社交平台内容(来源:微博,最近 ${lines.length} 条):\n${lines.join('\n')}${riskHint}`
} catch (error) { } catch (error) {
insightLog('WARN', `拉取微博公开内容失败 (uid=${uid}): ${(error as Error).message}`) insightLog('WARN', `拉取微博公开内容失败 (uid=${uid}): ${(error as Error).message}`)
return '' return ''
@@ -1118,10 +1160,6 @@ ${topMentionText}
// ── 构建 prompt ──────────────────────────────────────────────────────────── // ── 构建 prompt ────────────────────────────────────────────────────────────
// 今日触发统计(让模型具备时间与克制感)
const sessionTriggerTimes = this.recordTrigger(sessionId)
const totalTodayTriggers = this.getTodayTotalTriggerCount()
let contextSection = '' let contextSection = ''
if (allowContext) { if (allowContext) {
try { try {
@@ -1136,6 +1174,7 @@ ${topMentionText}
} }
} }
const momentsContextSection = await this.getMomentsContextSection(sessionId)
const socialContextSection = await this.getSocialContextSection(sessionId) const socialContextSection = await this.getSocialContextSection(sessionId)
// ── 默认 system prompt稳定内容有利于 provider 端 prompt cache 命中)──── // ── 默认 system prompt稳定内容有利于 provider 端 prompt cache 命中)────
@@ -1151,25 +1190,12 @@ ${topMentionText}
const customPrompt = (this.config.get('aiInsightSystemPrompt') as string) || '' const customPrompt = (this.config.get('aiInsightSystemPrompt') as string) || ''
const systemPrompt = customPrompt.trim() || DEFAULT_SYSTEM_PROMPT 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 = [ const userPromptBase = [
`触发原因:${triggerDesc}`, triggerReason === 'silence' && silentDays
`时间统计:${todayStatsDesc}`, ? `${silentDays} 天未联系「${resolvedDisplayName}」。`
`全局统计:${globalStatsDesc}`, : '',
contextSection, contextSection,
momentsContextSection,
socialContextSection, socialContextSection,
'请给出你的见解≤80字' '请给出你的见解≤80字'
].filter(Boolean).join('\n\n') ].filter(Boolean).join('\n\n')
@@ -1189,7 +1215,7 @@ ${topMentionText}
`接口地址:${endpoint}`, `接口地址:${endpoint}`,
`模型:${model}`, `模型:${model}`,
`Max Tokens${maxTokens}`, `Max Tokens${maxTokens}`,
`触发原因${triggerReason}`, `触发类型${triggerReason}`,
`上下文开关:${allowContext ? '开启' : '关闭'}`, `上下文开关:${allowContext ? '开启' : '关闭'}`,
`上下文条数:${contextCount}`, `上下文条数:${contextCount}`,
'', '',
@@ -1253,6 +1279,7 @@ ${topMentionText}
} }
insightLog('INFO', `已为 ${resolvedDisplayName} 推送见解`) insightLog('INFO', `已为 ${resolvedDisplayName} 推送见解`)
this.recordTrigger(sessionId)
} catch (e) { } catch (e) {
insightDebugSection( insightDebugSection(
'ERROR', 'ERROR',

View File

@@ -0,0 +1 @@
> 目前只适配了x64 win32平台其它平台同样原理但是代码还没写

Binary file not shown.

6
resources/installer/linux/.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
*.tar.gz
*.tar.xz
*.zip
src/
pkg/
weflow-*/

View File

@@ -915,6 +915,31 @@
color: var(--text-secondary); color: var(--text-secondary);
} }
.insight-collapsible-setting {
max-height: 0;
opacity: 0;
overflow: hidden;
transform: translate3d(0, -4px, 0);
contain: layout paint;
will-change: max-height, opacity, transform;
transition: max-height 0.2s ease, opacity 0.18s ease, transform 0.2s ease;
&.expanded {
max-height: 128px;
opacity: 1;
transform: translate3d(0, 0, 0);
}
&.collapsed {
pointer-events: none;
}
}
.insight-collapsible-setting-inner {
padding-top: 2px;
backface-visibility: hidden;
}
/* Premium Switch Style */ /* Premium Switch Style */
.switch { .switch {
position: relative; position: relative;
@@ -3616,17 +3641,35 @@
} }
&.insight-social-tab { &.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);
.anti-revoke-list-header { .anti-revoke-list-header {
grid-template-columns: minmax(0, 1fr) minmax(300px, 420px) auto; grid-template-columns: var(--insight-social-list-grid);
gap: 14px;
.insight-moments-column-title {
display: flex;
justify-content: center;
color: var(--text-tertiary);
}
.insight-social-column-title { .insight-social-column-title {
min-width: 0;
color: var(--text-tertiary);
}
.anti-revoke-status-column-title {
justify-self: end;
color: var(--text-tertiary); color: var(--text-tertiary);
} }
} }
.anti-revoke-row { .anti-revoke-row {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) minmax(300px, 420px) auto; grid-template-columns: var(--insight-social-list-grid);
align-items: center; align-items: center;
gap: 14px; gap: 14px;
} }
@@ -3635,6 +3678,67 @@
min-width: 0; min-width: 0;
} }
.insight-moments-cell {
min-width: 0;
display: flex;
align-items: center;
justify-content: center;
min-height: 30px;
}
.insight-moments-toggle {
position: relative;
width: 18px;
height: 18px;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
input[type='checkbox'] {
position: absolute;
inset: 0;
margin: 0;
opacity: 0;
cursor: pointer;
}
.check-indicator {
width: 100%;
height: 100%;
border-radius: 6px;
border: 1px solid color-mix(in srgb, var(--border-color) 78%, var(--primary) 22%);
background: color-mix(in srgb, var(--bg-primary) 86%, var(--bg-secondary) 14%);
color: var(--on-primary, #fff);
display: inline-flex;
align-items: center;
justify-content: center;
transition: all 0.16s ease;
svg {
opacity: 0;
transform: scale(0.75);
transition: opacity 0.16s ease, transform 0.16s ease;
}
}
input[type='checkbox']:checked + .check-indicator {
background: var(--primary);
border-color: var(--primary);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 18%, transparent);
svg {
opacity: 1;
transform: scale(1);
}
}
input[type='checkbox']:focus-visible + .check-indicator {
outline: 2px solid color-mix(in srgb, var(--primary) 42%, transparent);
outline-offset: 1px;
}
}
.insight-social-binding-cell { .insight-social-binding-cell {
min-width: 0; min-width: 0;
display: grid; display: grid;
@@ -3653,7 +3757,7 @@
.binding-platform-chip { .binding-platform-chip {
flex-shrink: 0; flex-shrink: 0;
border-radius: 999px; border-radius: 999px;
padding: 2px 8px; padding: 2px 7px;
font-size: 11px; font-size: 11px;
color: var(--text-secondary); color: var(--text-secondary);
border: 1px solid color-mix(in srgb, var(--border-color) 84%, transparent); border: 1px solid color-mix(in srgb, var(--border-color) 84%, transparent);
@@ -3663,7 +3767,7 @@
.insight-social-binding-input { .insight-social-binding-input {
width: 100%; width: 100%;
min-width: 0; min-width: 0;
height: 30px; height: 28px;
border-radius: 8px; border-radius: 8px;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
background: color-mix(in srgb, var(--bg-primary) 92%, var(--bg-secondary) 8%); background: color-mix(in srgb, var(--bg-primary) 92%, var(--bg-secondary) 8%);
@@ -3706,9 +3810,10 @@
} }
.anti-revoke-row-status { .anti-revoke-row-status {
justify-self: flex-end; justify-self: end;
align-items: flex-end; align-items: flex-end;
max-width: none; max-width: none;
min-width: 0;
} }
} }
@@ -3752,6 +3857,7 @@
.anti-revoke-list-header { .anti-revoke-list-header {
grid-template-columns: minmax(0, 1fr) auto; grid-template-columns: minmax(0, 1fr) auto;
.insight-moments-column-title,
.insight-social-column-title { .insight-social-column-title {
display: none; display: none;
} }
@@ -3763,11 +3869,16 @@
flex-direction: column; flex-direction: column;
} }
.insight-moments-cell,
.insight-social-binding-cell, .insight-social-binding-cell,
.anti-revoke-row-status { .anti-revoke-row-status {
width: 100%; width: 100%;
} }
.insight-moments-cell {
justify-content: flex-start;
}
.insight-social-binding-cell { .insight-social-binding-cell {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }

View File

@@ -32,6 +32,7 @@ type SettingsTab =
| 'aiCommon' | 'aiCommon'
| 'insight' | 'insight'
| 'aiFootprint' | 'aiFootprint'
| 'autoDownload'
const tabs: { id: Exclude<SettingsTab, 'insight' | 'aiFootprint'>; label: string; icon: React.ElementType }[] = [ const tabs: { id: Exclude<SettingsTab, 'insight' | 'aiFootprint'>; label: string; icon: React.ElementType }[] = [
{ id: 'appearance', label: '外观', icon: Palette }, { id: 'appearance', label: '外观', icon: Palette },
@@ -39,6 +40,7 @@ const tabs: { id: Exclude<SettingsTab, 'insight' | 'aiFootprint'>; label: string
{ id: 'antiRevoke', label: '防撤回', icon: RotateCcw }, { id: 'antiRevoke', label: '防撤回', icon: RotateCcw },
{ id: 'database', label: '数据库连接', icon: Database }, { id: 'database', label: '数据库连接', icon: Database },
{ id: 'models', label: '模型管理', icon: Mic }, { id: 'models', label: '模型管理', icon: Mic },
{ id: 'autoDownload', label: '自动下载', icon: Download },
{ id: 'cache', label: '缓存', icon: HardDrive }, { id: 'cache', label: '缓存', icon: HardDrive },
{ id: 'api', label: 'API 服务', icon: Globe }, { id: 'api', label: 'API 服务', icon: Globe },
{ id: 'analytics', label: '分析', icon: BarChart2 }, { id: 'analytics', label: '分析', icon: BarChart2 },
@@ -47,6 +49,13 @@ const tabs: { id: Exclude<SettingsTab, 'insight' | 'aiFootprint'>; label: string
{ id: 'about', label: '关于', icon: Info } { id: 'about', label: '关于', icon: Info }
] ]
const filteredTabs = tabs.filter(tab => {
if (tab.id === 'autoDownload') {
return (window as any).electronAPI.process.platform === 'win32' && (window as any).electronAPI.process.arch === 'x64'
}
return true
})
const aiTabs: Array<{ id: Extract<SettingsTab, 'aiCommon' | 'insight' | 'aiFootprint'>; label: string }> = [ const aiTabs: Array<{ id: Extract<SettingsTab, 'aiCommon' | 'insight' | 'aiFootprint'>; label: string }> = [
{ id: 'aiCommon', label: '基础配置' }, { id: 'aiCommon', label: '基础配置' },
{ id: 'insight', label: 'AI 见解' }, { id: 'insight', label: 'AI 见解' },
@@ -149,6 +158,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
const [imageKeyPercent, setImageKeyPercent] = useState<number | null>(null) const [imageKeyPercent, setImageKeyPercent] = useState<number | null>(null)
const [logEnabled, setLogEnabled] = useState(false) const [logEnabled, setLogEnabled] = useState(false)
const [autoDownloadHighRes, setAutoDownloadHighRes] = useState(false)
const [whisperModelName, setWhisperModelName] = useState('base') const [whisperModelName, setWhisperModelName] = useState('base')
const [whisperModelDir, setWhisperModelDir] = useState('') const [whisperModelDir, setWhisperModelDir] = useState('')
const [isWhisperDownloading, setIsWhisperDownloading] = useState(false) const [isWhisperDownloading, setIsWhisperDownloading] = useState(false)
@@ -284,6 +294,9 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
const [aiModelApiMaxTokens, setAiModelApiMaxTokens] = useState(200) const [aiModelApiMaxTokens, setAiModelApiMaxTokens] = useState(200)
const [aiInsightSilenceDays, setAiInsightSilenceDays] = useState(3) const [aiInsightSilenceDays, setAiInsightSilenceDays] = useState(3)
const [aiInsightAllowContext, setAiInsightAllowContext] = useState(false) const [aiInsightAllowContext, setAiInsightAllowContext] = useState(false)
const [aiInsightAllowMomentsContext, setAiInsightAllowMomentsContext] = useState(false)
const [aiInsightMomentsContextCount, setAiInsightMomentsContextCount] = useState(5)
const [aiInsightMomentsBindings, setAiInsightMomentsBindings] = useState<Record<string, configService.AiInsightMomentsBinding>>({})
const [isTestingInsight, setIsTestingInsight] = useState(false) const [isTestingInsight, setIsTestingInsight] = useState(false)
const [insightTestResult, setInsightTestResult] = useState<{ success: boolean; message: string } | null>(null) const [insightTestResult, setInsightTestResult] = useState<{ success: boolean; message: string } | null>(null)
const [showInsightApiKey, setShowInsightApiKey] = useState(false) const [showInsightApiKey, setShowInsightApiKey] = useState(false)
@@ -315,6 +328,11 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
const [aiFootprintSystemPrompt, setAiFootprintSystemPrompt] = useState('') const [aiFootprintSystemPrompt, setAiFootprintSystemPrompt] = useState('')
const [aiInsightDebugLogEnabled, setAiInsightDebugLogEnabled] = useState(false) const [aiInsightDebugLogEnabled, setAiInsightDebugLogEnabled] = useState(false)
// 自动下载图片
const [autoDownloadStatus, setAutoDownloadStatus] = useState<{ isHooked: boolean; pid: number | null; supported: boolean } | null>(null)
const [autoDownloadSelectedIds, setAutoDownloadSelectedIds] = useState<Set<string>>(new Set())
const [autoDownloadSearchKeyword, setAutoDownloadSearchKeyword] = useState('')
// 检查 Hello 可用性 // 检查 Hello 可用性
useEffect(() => { useEffect(() => {
setHelloAvailable(isWindows) setHelloAvailable(isWindows)
@@ -526,9 +544,12 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
setWordCloudExcludeWords(savedExcludeWords) setWordCloudExcludeWords(savedExcludeWords)
setExcludeWordsInput(savedExcludeWords.join('\n')) setExcludeWordsInput(savedExcludeWords.join('\n'))
const savedAutoDownloadHighRes = await configService.getAutoDownloadHighRes()
const savedAutoDownloadWhitelist = await configService.getAutoDownloadWhitelist()
const savedAnalyticsConsent = await configService.getAnalyticsConsent() const savedAnalyticsConsent = await configService.getAnalyticsConsent()
setAnalyticsConsent(savedAnalyticsConsent ?? false) setAnalyticsConsent(savedAnalyticsConsent ?? false)
setAutoDownloadHighRes(savedAutoDownloadHighRes)
setAutoDownloadSelectedIds(new Set(savedAutoDownloadWhitelist))
// 如果语言列表为空,保存默认值 // 如果语言列表为空,保存默认值
@@ -549,6 +570,9 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
const savedAiModelApiMaxTokens = await configService.getAiModelApiMaxTokens() const savedAiModelApiMaxTokens = await configService.getAiModelApiMaxTokens()
const savedAiInsightSilenceDays = await configService.getAiInsightSilenceDays() const savedAiInsightSilenceDays = await configService.getAiInsightSilenceDays()
const savedAiInsightAllowContext = await configService.getAiInsightAllowContext() const savedAiInsightAllowContext = await configService.getAiInsightAllowContext()
const savedAiInsightAllowMomentsContext = await configService.getAiInsightAllowMomentsContext()
const savedAiInsightMomentsContextCount = await configService.getAiInsightMomentsContextCount()
const savedAiInsightMomentsBindings = await configService.getAiInsightMomentsBindings()
const savedAiInsightFilterMode = await configService.getAiInsightFilterMode() const savedAiInsightFilterMode = await configService.getAiInsightFilterMode()
const savedAiInsightFilterList = await configService.getAiInsightFilterList() const savedAiInsightFilterList = await configService.getAiInsightFilterList()
const savedAiInsightCooldownMinutes = await configService.getAiInsightCooldownMinutes() const savedAiInsightCooldownMinutes = await configService.getAiInsightCooldownMinutes()
@@ -573,6 +597,9 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
setAiModelApiMaxTokens(savedAiModelApiMaxTokens) setAiModelApiMaxTokens(savedAiModelApiMaxTokens)
setAiInsightSilenceDays(savedAiInsightSilenceDays) setAiInsightSilenceDays(savedAiInsightSilenceDays)
setAiInsightAllowContext(savedAiInsightAllowContext) setAiInsightAllowContext(savedAiInsightAllowContext)
setAiInsightAllowMomentsContext(savedAiInsightAllowMomentsContext)
setAiInsightMomentsContextCount(savedAiInsightMomentsContextCount)
setAiInsightMomentsBindings(savedAiInsightMomentsBindings)
setAiInsightFilterMode(savedAiInsightFilterMode) setAiInsightFilterMode(savedAiInsightFilterMode)
setAiInsightFilterList(new Set(savedAiInsightFilterList)) setAiInsightFilterList(new Set(savedAiInsightFilterList))
setAiInsightCooldownMinutes(savedAiInsightCooldownMinutes) setAiInsightCooldownMinutes(savedAiInsightCooldownMinutes)
@@ -685,6 +712,21 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
void refreshWhisperStatus(whisperModelDir) void refreshWhisperStatus(whisperModelDir)
}, [whisperModelDir]) }, [whisperModelDir])
useEffect(() => {
if (activeTab === 'autoDownload') {
fetchAutoDownloadStatus()
let interval: ReturnType<typeof setInterval> | undefined
if (autoDownloadHighRes) {
interval = setInterval(fetchAutoDownloadStatus, 2000)
}
return () => {
if (interval) clearInterval(interval)
}
}
}, [activeTab, autoDownloadHighRes])
const getErrorMessage = (error: any): string => { const getErrorMessage = (error: any): string => {
const raw = typeof error?.message === 'string' ? error.message : String(error ?? '') const raw = typeof error?.message === 'string' ? error.message : String(error ?? '')
const normalized = raw.replace(/^Error:\s*/i, '').trim() const normalized = raw.replace(/^Error:\s*/i, '').trim()
@@ -1013,11 +1055,11 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
} }
useEffect(() => { useEffect(() => {
if (activeTab !== 'antiRevoke' && activeTab !== 'insight') return if (activeTab !== 'antiRevoke' && activeTab !== 'insight' && activeTab !== 'autoDownload') return
let canceled = false let canceled = false
;(async () => { ;(async () => {
try { try {
if (activeTab === 'antiRevoke') { if (activeTab === 'antiRevoke' || activeTab === 'autoDownload') {
await ensureAntiRevokeSessionsLoaded() await ensureAntiRevokeSessionsLoaded()
} else { } else {
await ensureChatSessionsLoaded() await ensureChatSessionsLoaded()
@@ -1579,6 +1621,15 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
} }
} }
const fetchAutoDownloadStatus = async () => {
try {
const status = await (window as any).electronAPI.image.getAutoDownloadStatus()
setAutoDownloadStatus(status)
} catch (error) {
console.error('获取自动下载状态失败:', error)
}
}
const renderAppearanceTab = () => ( const renderAppearanceTab = () => (
<div className="tab-content"> <div className="tab-content">
<div className="theme-mode-toggle"> <div className="theme-mode-toggle">
@@ -3081,6 +3132,24 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
}) })
} }
const isMomentsEnabledForSession = (sessionId: string): boolean => {
return aiInsightMomentsBindings[sessionId]?.enabled === true
}
const handleToggleMomentsBinding = async (sessionId: string, enabled: boolean) => {
const nextBindings = { ...aiInsightMomentsBindings }
if (enabled) {
nextBindings[sessionId] = {
enabled: true,
updatedAt: Date.now()
}
} else {
delete nextBindings[sessionId]
}
setAiInsightMomentsBindings(nextBindings)
await configService.setAiInsightMomentsBindings(nextBindings)
}
const handleSaveWeiboBinding = async (sessionId: string, displayName: string) => { const handleSaveWeiboBinding = async (sessionId: string, displayName: string) => {
const draftUid = getWeiboBindingDraftValue(sessionId) const draftUid = getWeiboBindingDraftValue(sessionId)
setWeiboBindingLoadingSessionId(sessionId) setWeiboBindingLoadingSessionId(sessionId)
@@ -3274,7 +3343,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
<span className="form-hint"> <span className="form-hint">
N AI N AI
<br /> <br />
<strong></strong>AI <strong></strong>
<br /> <br />
<strong></strong> API <strong></strong> API
</span> </span>
@@ -3295,7 +3364,8 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
</div> </div>
</div> </div>
{aiInsightAllowContext && ( <div className={`insight-collapsible-setting ${aiInsightAllowContext ? 'expanded' : 'collapsed'}`} aria-hidden={!aiInsightAllowContext}>
<div className="insight-collapsible-setting-inner">
<div className="form-group"> <div className="form-group">
<label></label> <label></label>
<span className="form-hint"> <span className="form-hint">
@@ -3307,6 +3377,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
value={aiInsightContextCount} value={aiInsightContextCount}
min={1} min={1}
max={200} max={200}
disabled={!aiInsightAllowContext}
onChange={(e) => { onChange={(e) => {
const val = Math.max(1, Math.min(200, parseInt(e.target.value, 10) || 40)) const val = Math.max(1, Math.min(200, parseInt(e.target.value, 10) || 40))
setAiInsightContextCount(val) setAiInsightContextCount(val)
@@ -3315,7 +3386,57 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
style={{ width: 100 }} style={{ width: 100 }}
/> />
</div> </div>
)} </div>
</div>
<div className="divider" />
<div className="form-group">
<label></label>
<span className="form-hint">
</span>
<div className="log-toggle-line">
<span className="log-status">{aiInsightAllowMomentsContext ? '已开启' : '已关闭'}</span>
<label className="switch">
<input
type="checkbox"
checked={aiInsightAllowMomentsContext}
onChange={async (e) => {
const val = e.target.checked
setAiInsightAllowMomentsContext(val)
await configService.setAiInsightAllowMomentsContext(val)
}}
/>
<span className="switch-slider" />
</label>
</div>
</div>
<div className={`insight-collapsible-setting ${aiInsightAllowMomentsContext ? 'expanded' : 'collapsed'}`} aria-hidden={!aiInsightAllowMomentsContext}>
<div className="insight-collapsible-setting-inner">
<div className="form-group">
<label></label>
<span className="form-hint">
AI token
</span>
<input
type="number"
className="field-input"
value={aiInsightMomentsContextCount}
min={1}
max={20}
disabled={!aiInsightAllowMomentsContext}
onChange={(e) => {
const val = Math.max(1, Math.min(20, parseInt(e.target.value, 10) || 5))
setAiInsightMomentsContextCount(val)
scheduleConfigSave('aiInsightMomentsContextCount', () => configService.setAiInsightMomentsContextCount(val))
}}
style={{ width: 100 }}
/>
</div>
</div>
</div>
<div className="divider" /> <div className="divider" />
@@ -3354,7 +3475,8 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
)} )}
</div> </div>
{aiInsightAllowSocialContext && ( <div className={`insight-collapsible-setting ${aiInsightAllowSocialContext ? 'expanded' : 'collapsed'}`} aria-hidden={!aiInsightAllowSocialContext}>
<div className="insight-collapsible-setting-inner">
<div className="form-group"> <div className="form-group">
<label></label> <label></label>
<span className="form-hint"> <span className="form-hint">
@@ -3368,6 +3490,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
value={aiInsightSocialContextCount} value={aiInsightSocialContextCount}
min={1} min={1}
max={5} max={5}
disabled={!aiInsightAllowSocialContext}
onChange={(e) => { onChange={(e) => {
const val = Math.max(1, Math.min(5, parseInt(e.target.value, 10) || 3)) const val = Math.max(1, Math.min(5, parseInt(e.target.value, 10) || 3))
setAiInsightSocialContextCount(val) setAiInsightSocialContextCount(val)
@@ -3376,7 +3499,8 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
style={{ width: 100 }} style={{ width: 100 }}
/> />
</div> </div>
)} </div>
</div>
<div className="divider" /> <div className="divider" />
{/* 自定义 System Prompt */} {/* 自定义 System Prompt */}
@@ -3652,11 +3776,14 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
<> <>
<div className="anti-revoke-list-header"> <div className="anti-revoke-list-header">
<span>{filteredSessions.length}</span> <span>{filteredSessions.length}</span>
<span className="insight-moments-column-title"></span>
<span className="insight-social-column-title"></span> <span className="insight-social-column-title"></span>
<span></span> <span className="anti-revoke-status-column-title"></span>
</div> </div>
{filteredSessions.map((session) => { {filteredSessions.map((session) => {
const isSelected = aiInsightFilterList.has(session.username) const isSelected = aiInsightFilterList.has(session.username)
const isPrivateSession = session.type === 'private'
const isMomentsEnabled = isMomentsEnabledForSession(session.username)
const weiboBinding = aiInsightWeiboBindings[session.username] const weiboBinding = aiInsightWeiboBindings[session.username]
const weiboDraftValue = getWeiboBindingDraftValue(session.username) const weiboDraftValue = getWeiboBindingDraftValue(session.username)
const isBindingLoading = weiboBindingLoadingSessionId === session.username const isBindingLoading = weiboBindingLoadingSessionId === session.username
@@ -3695,8 +3822,24 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
<span className="desc">{getSessionFilterTypeLabel(session.type)}</span> <span className="desc">{getSessionFilterTypeLabel(session.type)}</span>
</div> </div>
</label> </label>
<div className="insight-moments-cell">
{isPrivateSession ? (
<label className="insight-moments-toggle">
<input
type="checkbox"
checked={isMomentsEnabled}
onChange={(e) => { void handleToggleMomentsBinding(session.username, e.target.checked) }}
/>
<span className="check-indicator" aria-hidden="true">
<Check size={12} />
</span>
</label>
) : (
<span className="binding-feedback muted">-</span>
)}
</div>
<div className="insight-social-binding-cell"> <div className="insight-social-binding-cell">
{session.type === 'private' ? ( {isPrivateSession ? (
<> <>
<div className="insight-social-binding-input-wrap"> <div className="insight-social-binding-input-wrap">
<span className="binding-platform-chip"></span> <span className="binding-platform-chip"></span>
@@ -3771,9 +3914,9 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
<div className="api-docs"> <div className="api-docs">
<div className="api-item"> <div className="api-item">
<p className="api-desc" style={{ lineHeight: 1.7 }}> <p className="api-desc" style={{ lineHeight: 1.7 }}>
<strong></strong> 500ms <br /> <strong></strong> 2 <br />
<strong></strong> 4 <br /> <strong></strong> 4 <br />
<strong></strong> AI AI <br /> <strong></strong> <br />
<strong></strong> API WeFlow <strong></strong> API WeFlow
</p> </p>
</div> </div>
@@ -4557,6 +4700,203 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
</div> </div>
) )
const renderAutoDownloadTab = () => {
const sortedSessions = [...antiRevokeSessions].sort((a, b) => (b.sortTimestamp || 0) - (a.sortTimestamp || 0))
const keyword = autoDownloadSearchKeyword.trim().toLowerCase()
const filteredSessions = sortedSessions.filter((session) => {
if (!keyword) return true
const displayName = String(session.displayName || '').toLowerCase()
const username = String(session.username || '').toLowerCase()
return displayName.includes(keyword) || username.includes(keyword)
})
const filteredSessionIds = filteredSessions.map((session) => session.username)
const selectedCount = autoDownloadSelectedIds.size
const selectedInFilteredCount = filteredSessionIds.filter((id) => autoDownloadSelectedIds.has(id)).length
const allFilteredSelected = filteredSessionIds.length > 0 && selectedInFilteredCount === filteredSessionIds.length
const isHooked = autoDownloadStatus?.isHooked
const persistWhitelist = (ids: Set<string>) => {
const whitelistArr = Array.from(ids)
configService.setAutoDownloadWhitelist(whitelistArr)
if (autoDownloadHighRes) {
const whitelistStr = whitelistArr.length > 0 ? (whitelistArr.join('\0') + '\0\0') : '';
(window as any).electronAPI.image.startAutoDownload(whitelistStr)
}
}
const toggleSelection = (id: string) => {
const next = new Set(autoDownloadSelectedIds)
if (next.has(id)) next.delete(id)
else next.add(id)
setAutoDownloadSelectedIds(next)
persistWhitelist(next)
}
const selectAllFiltered = () => {
const next = new Set(autoDownloadSelectedIds)
filteredSessionIds.forEach(id => next.add(id))
setAutoDownloadSelectedIds(next)
persistWhitelist(next)
}
const clearSelection = () => {
const next = new Set<string>()
setAutoDownloadSelectedIds(next)
persistWhitelist(next)
}
return (
<div className="tab-content anti-revoke-tab">
{/* 顶部 Hero 区域保持不变 */}
<div className="anti-revoke-hero" style={{ background: 'linear-gradient(110deg, var(--bg-primary) 0%, rgba(245, 158, 11, 0.1) 100%)', borderColor: 'rgba(245, 158, 11, 0.3)' }}>
<div className="anti-revoke-hero-main">
<span className="updates-chip" style={{ color: '#f59e0b', background: 'rgba(245, 158, 11, 0.15)', width: 'fit-content' }}> (Test)</span>
<h2 style={{ marginTop: '8px' }}></h2>
<p></p>
</div>
<div className="anti-revoke-metrics">
<div className={`anti-revoke-metric ${isHooked ? 'is-installed' : 'is-pending'}`}>
<span className="label"></span>
<span className="value" style={{ fontSize: '14px' }}>
{isHooked ? '正在监控' : autoDownloadHighRes ? '等待连接' : '未启用'}
</span>
</div>
<div className="anti-revoke-metric">
<span className="label"></span>
<span className="value">{selectedCount}</span>
</div>
</div>
</div>
<div className="anti-revoke-control-card">
<div className="anti-revoke-toolbar">
<div className="filter-search-box anti-revoke-search">
<Search size={14} />
<input
type="text"
placeholder="搜索联系人或群聊..."
value={autoDownloadSearchKeyword}
onChange={(e) => setAutoDownloadSearchKeyword(e.target.value)}
/>
</div>
<div className="anti-revoke-toolbar-actions">
<div className="anti-revoke-btn-group">
<button className="btn btn-secondary btn-sm" onClick={selectAllFiltered} disabled={filteredSessionIds.length === 0 || allFilteredSelected}>
</button>
<button className="btn btn-secondary btn-sm" onClick={clearSelection} disabled={selectedCount === 0}>
</button>
</div>
<div className="anti-revoke-btn-group" style={{ marginLeft: '12px', paddingLeft: '12px', borderLeft: '1px solid var(--border-color)' }}>
<label className="switch switch-md">
<input
type="checkbox"
checked={autoDownloadHighRes}
onChange={() => handleToggleAutoDownload(Array.from(autoDownloadSelectedIds))}
/>
<span className="switch-slider" />
</label>
<span style={{ fontSize: '12px', color: 'var(--text-secondary)', marginLeft: '8px' }}>
{autoDownloadHighRes ? '服务已开启' : '服务已关闭'}
</span>
</div>
</div>
</div>
<div className="anti-revoke-batch-actions">
<div className="anti-revoke-selected-count">
<span> <strong>{selectedCount}</strong> </span>
<span style={{ opacity: 0.6 }}></span>
</div>
</div>
</div>
<div className="anti-revoke-list">
<div className="anti-revoke-list-header">
<span>{filteredSessions.length}</span>
<span></span>
</div>
{filteredSessions.length === 0 ? (
<div className="anti-revoke-empty">{autoDownloadSearchKeyword ? '没有匹配的会话' : '暂无会话'}</div>
) : (
filteredSessions.map((session) => {
const isSelected = autoDownloadSelectedIds.has(session.username)
return (
<div key={session.username} className={`anti-revoke-row ${isSelected ? 'selected' : ''}`}>
<label className="anti-revoke-row-main">
<span className="anti-revoke-check">
<input
type="checkbox"
checked={isSelected}
onChange={() => toggleSelection(session.username)}
/>
<span className="check-indicator" aria-hidden="true">
<Check size={12} />
</span>
</span>
<Avatar src={session.avatarUrl} name={session.displayName} size={30} />
<div className="anti-revoke-row-text">
<span className="name">{session.displayName || session.username}</span>
</div>
</label>
<div className="anti-revoke-row-status">
<span className={`status-badge ${isSelected ? 'installed' : 'not-installed'}`}>
<i className="status-dot" aria-hidden="true" />
{isSelected ? '已监控' : '未开启'}
</span>
</div>
</div>
)
})
)}
</div>
{/* 风险提示部分保持不变 */}
<div className="api-warning-modal" style={{ width: '100%', border: '1px solid rgba(239, 68, 68, 0.2)', marginTop: '16px', background: 'rgba(239, 68, 68, 0.02)', animation: 'none', boxShadow: 'none', position: 'static' }}>
<div className="modal-header" style={{ border: 'none', padding: '12px 20px 0' }}>
<Lock size={16} color="#ef4444" />
<h3 style={{ fontSize: '13px', color: '#ef4444' }}></h3>
</div>
<div className="modal-body" style={{ fontSize: '12px', color: 'var(--text-secondary)', padding: '8px 20px 12px' }}>
Hook
</div>
</div>
</div>
)
}
const handleToggleAutoDownload = async (whitelist?: string[] | string) => {
const newVal = !autoDownloadHighRes
setAutoDownloadHighRes(newVal)
try {
if (newVal) {
let currentWhitelist: string[] | string = whitelist || Array.from(autoDownloadSelectedIds)
if (Array.isArray(currentWhitelist)) {
currentWhitelist = currentWhitelist.length > 0 ? (currentWhitelist.join('\0') + '\0\0') : ''
}
const result = await (window as any).electronAPI.image.startAutoDownload(currentWhitelist)
if (result && !result.success) {
// 如果底层明确返回了失败
throw new Error(result.error || '启动自动下载服务失败')
}
showMessage('自动下载已开启,正在尝试连接微信', true)
await fetchAutoDownloadStatus()
} else {
await (window as any).electronAPI.image.stopAutoDownload()
showMessage('自动下载已关闭', true)
setAutoDownloadStatus(null)
}
await configService.setAutoDownloadHighRes(newVal)
} catch (e: any) {
// 发生错误时,将开关拨回去
setAutoDownloadHighRes(!newVal)
showMessage(`操作失败: ${e.message || String(e)}`, false)
}
}
const renderUpdatesTab = () => { const renderUpdatesTab = () => {
const downloadPercent = Math.max(0, Math.min(100, Number(downloadProgress?.percent || 0))) const downloadPercent = Math.max(0, Math.min(100, Number(downloadProgress?.percent || 0)))
const channelCards: { id: configService.UpdateChannel; title: string; desc: string }[] = [ const channelCards: { id: configService.UpdateChannel; title: string; desc: string }[] = [
@@ -4691,7 +5031,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
<div className="settings-layout"> <div className="settings-layout">
<div className="settings-tabs" role="tablist" aria-label="设置项"> <div className="settings-tabs" role="tablist" aria-label="设置项">
{tabs.flatMap((tab) => { {filteredTabs.flatMap((tab) => {
const row: React.ReactNode[] = [ const row: React.ReactNode[] = [
<button <button
key={tab.id} key={tab.id}
@@ -4749,6 +5089,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
{activeTab === 'aiCommon' && renderAiCommonTab()} {activeTab === 'aiCommon' && renderAiCommonTab()}
{activeTab === 'insight' && renderInsightTab()} {activeTab === 'insight' && renderInsightTab()}
{activeTab === 'aiFootprint' && renderAiFootprintTab()} {activeTab === 'aiFootprint' && renderAiFootprintTab()}
{activeTab === 'autoDownload' && renderAutoDownloadTab()}
{activeTab === 'updates' && renderUpdatesTab()} {activeTab === 'updates' && renderUpdatesTab()}
{activeTab === 'analytics' && renderAnalyticsTab()} {activeTab === 'analytics' && renderAnalyticsTab()}
{activeTab === 'security' && renderSecurityTab()} {activeTab === 'security' && renderSecurityTab()}

View File

@@ -97,6 +97,9 @@ export const CONFIG_KEYS = {
AI_INSIGHT_API_MODEL: 'aiInsightApiModel', AI_INSIGHT_API_MODEL: 'aiInsightApiModel',
AI_INSIGHT_SILENCE_DAYS: 'aiInsightSilenceDays', AI_INSIGHT_SILENCE_DAYS: 'aiInsightSilenceDays',
AI_INSIGHT_ALLOW_CONTEXT: 'aiInsightAllowContext', AI_INSIGHT_ALLOW_CONTEXT: 'aiInsightAllowContext',
AI_INSIGHT_ALLOW_MOMENTS_CONTEXT: 'aiInsightAllowMomentsContext',
AI_INSIGHT_MOMENTS_CONTEXT_COUNT: 'aiInsightMomentsContextCount',
AI_INSIGHT_MOMENTS_BINDINGS: 'aiInsightMomentsBindings',
AI_INSIGHT_ALLOW_SOCIAL_CONTEXT: 'aiInsightAllowSocialContext', AI_INSIGHT_ALLOW_SOCIAL_CONTEXT: 'aiInsightAllowSocialContext',
AI_INSIGHT_FILTER_MODE: 'aiInsightFilterMode', AI_INSIGHT_FILTER_MODE: 'aiInsightFilterMode',
AI_INSIGHT_FILTER_LIST: 'aiInsightFilterList', AI_INSIGHT_FILTER_LIST: 'aiInsightFilterList',
@@ -116,7 +119,9 @@ export const CONFIG_KEYS = {
// AI 足迹 // AI 足迹
AI_FOOTPRINT_ENABLED: 'aiFootprintEnabled', AI_FOOTPRINT_ENABLED: 'aiFootprintEnabled',
AI_FOOTPRINT_SYSTEM_PROMPT: 'aiFootprintSystemPrompt', AI_FOOTPRINT_SYSTEM_PROMPT: 'aiFootprintSystemPrompt',
AI_INSIGHT_DEBUG_LOG_ENABLED: 'aiInsightDebugLogEnabled' AI_INSIGHT_DEBUG_LOG_ENABLED: 'aiInsightDebugLogEnabled',
AUTO_DOWNLOAD_HIGH_RES: 'autoDownloadHighRes',
AUTO_DOWNLOAD_WHITELIST: 'autoDownloadWhitelist'
} as const } as const
export interface WxidConfig { export interface WxidConfig {
@@ -132,6 +137,11 @@ export interface AiInsightWeiboBinding {
updatedAt: number updatedAt: number
} }
export interface AiInsightMomentsBinding {
enabled: boolean
updatedAt: number
}
export interface ExportDefaultMediaConfig { export interface ExportDefaultMediaConfig {
images: boolean images: boolean
videos: boolean videos: boolean
@@ -1922,6 +1932,24 @@ export async function setAiInsightAllowContext(allow: boolean): Promise<void> {
await config.set(CONFIG_KEYS.AI_INSIGHT_ALLOW_CONTEXT, allow) await config.set(CONFIG_KEYS.AI_INSIGHT_ALLOW_CONTEXT, allow)
} }
export async function getAiInsightAllowMomentsContext(): Promise<boolean> {
const value = await config.get(CONFIG_KEYS.AI_INSIGHT_ALLOW_MOMENTS_CONTEXT)
return value === true
}
export async function setAiInsightAllowMomentsContext(allow: boolean): Promise<void> {
await config.set(CONFIG_KEYS.AI_INSIGHT_ALLOW_MOMENTS_CONTEXT, allow)
}
export async function getAiInsightMomentsContextCount(): Promise<number> {
const value = await config.get(CONFIG_KEYS.AI_INSIGHT_MOMENTS_CONTEXT_COUNT)
return typeof value === 'number' && value > 0 ? value : 5
}
export async function setAiInsightMomentsContextCount(count: number): Promise<void> {
await config.set(CONFIG_KEYS.AI_INSIGHT_MOMENTS_CONTEXT_COUNT, count)
}
export async function getAiInsightAllowSocialContext(): Promise<boolean> { export async function getAiInsightAllowSocialContext(): Promise<boolean> {
const value = await config.get(CONFIG_KEYS.AI_INSIGHT_ALLOW_SOCIAL_CONTEXT) const value = await config.get(CONFIG_KEYS.AI_INSIGHT_ALLOW_SOCIAL_CONTEXT)
return value === true return value === true
@@ -2067,6 +2095,33 @@ export async function setAiInsightWeiboBindings(bindings: Record<string, AiInsig
await config.set(CONFIG_KEYS.AI_INSIGHT_WEIBO_BINDINGS, bindings) await config.set(CONFIG_KEYS.AI_INSIGHT_WEIBO_BINDINGS, bindings)
} }
const normalizeAiInsightMomentsBindings = (value: unknown): Record<string, AiInsightMomentsBinding> => {
if (!value || typeof value !== 'object') return {}
const result: Record<string, AiInsightMomentsBinding> = {}
for (const [sessionIdRaw, bindingRaw] of Object.entries(value as Record<string, unknown>)) {
const sessionId = String(sessionIdRaw || '').trim()
if (!sessionId) continue
if (!bindingRaw || typeof bindingRaw !== 'object') continue
const bindingObj = bindingRaw as { enabled?: unknown; updatedAt?: unknown }
if (bindingObj.enabled !== true) continue
const updatedAtRaw = Number(bindingObj.updatedAt)
result[sessionId] = {
enabled: true,
updatedAt: Number.isFinite(updatedAtRaw) && updatedAtRaw > 0 ? Math.floor(updatedAtRaw) : Date.now()
}
}
return result
}
export async function getAiInsightMomentsBindings(): Promise<Record<string, AiInsightMomentsBinding>> {
const value = await config.get(CONFIG_KEYS.AI_INSIGHT_MOMENTS_BINDINGS)
return normalizeAiInsightMomentsBindings(value)
}
export async function setAiInsightMomentsBindings(bindings: Record<string, AiInsightMomentsBinding>): Promise<void> {
await config.set(CONFIG_KEYS.AI_INSIGHT_MOMENTS_BINDINGS, normalizeAiInsightMomentsBindings(bindings))
}
export async function getAiFootprintEnabled(): Promise<boolean> { export async function getAiFootprintEnabled(): Promise<boolean> {
const value = await config.get(CONFIG_KEYS.AI_FOOTPRINT_ENABLED) const value = await config.get(CONFIG_KEYS.AI_FOOTPRINT_ENABLED)
return value === true return value === true
@@ -2094,3 +2149,22 @@ export async function setAiInsightDebugLogEnabled(enabled: boolean): Promise<voi
await config.set(CONFIG_KEYS.AI_INSIGHT_DEBUG_LOG_ENABLED, enabled) await config.set(CONFIG_KEYS.AI_INSIGHT_DEBUG_LOG_ENABLED, enabled)
} }
export async function getAutoDownloadHighRes(): Promise<boolean> {
const value = await config.get(CONFIG_KEYS.AUTO_DOWNLOAD_HIGH_RES)
return value === true
}
export async function setAutoDownloadHighRes(enabled: boolean): Promise<void> {
await config.set(CONFIG_KEYS.AUTO_DOWNLOAD_HIGH_RES, enabled)
}
export async function getAutoDownloadWhitelist(): Promise<string[]> {
const value = await config.get(CONFIG_KEYS.AUTO_DOWNLOAD_WHITELIST)
return Array.isArray(value) ? value : []
}
export async function setAutoDownloadWhitelist(list: string[]): Promise<void> {
const normalized = Array.from(new Set((list || []).map(item => String(item || '').trim()).filter(Boolean)))
await config.set(CONFIG_KEYS.AUTO_DOWNLOAD_WHITELIST, normalized)
}