From 678c08b507b0bc3f9ce9f0436e27b821bd065418 Mon Sep 17 00:00:00 2001 From: Jason Date: Sat, 18 Apr 2026 17:45:44 +0800 Subject: [PATCH] feat(insight): add whitelist/blacklist mode and typed batch selection --- electron/services/config.ts | 4 + electron/services/insightService.ts | 61 ++++--- src/pages/SettingsPage.tsx | 241 +++++++++++++++++----------- src/services/config.ts | 41 ++++- 4 files changed, 227 insertions(+), 120 deletions(-) diff --git a/electron/services/config.ts b/electron/services/config.ts index a1066f6..35a382d 100644 --- a/electron/services/config.ts +++ b/electron/services/config.ts @@ -85,6 +85,8 @@ interface ConfigSchema { aiInsightSilenceDays: number aiInsightAllowContext: boolean aiInsightAllowSocialContext: boolean + aiInsightFilterMode: 'whitelist' | 'blacklist' + aiInsightFilterList: string[] aiInsightWhitelistEnabled: boolean aiInsightWhitelist: string[] /** 活跃分析冷却时间(分钟),0 表示无冷却 */ @@ -202,6 +204,8 @@ export class ConfigService { aiInsightSilenceDays: 3, aiInsightAllowContext: false, aiInsightAllowSocialContext: false, + aiInsightFilterMode: 'whitelist', + aiInsightFilterList: [], aiInsightWhitelistEnabled: false, aiInsightWhitelist: [], aiInsightCooldownMinutes: 120, diff --git a/electron/services/insightService.ts b/electron/services/insightService.ts index f1ee5b4..0566571 100644 --- a/electron/services/insightService.ts +++ b/electron/services/insightService.ts @@ -50,6 +50,8 @@ const INSIGHT_CONFIG_KEYS = new Set([ 'aiModelApiKey', 'aiModelApiModel', 'aiModelApiMaxTokens', + 'aiInsightFilterMode', + 'aiInsightFilterList', 'aiInsightAllowSocialContext', 'aiInsightSocialContextCount', 'aiInsightWeiboCookie', @@ -73,6 +75,8 @@ interface SharedAiModelConfig { maxTokens: number } +type InsightFilterMode = 'whitelist' | 'blacklist' + // ─── 日志 ───────────────────────────────────────────────────────────────────── type InsightLogLevel = 'INFO' | 'WARN' | 'ERROR' @@ -196,6 +200,11 @@ function normalizeApiMaxTokens(value: unknown): number { return Math.min(API_MAX_TOKENS_MAX, Math.max(API_MAX_TOKENS_MIN, Math.floor(numeric))) } +function normalizeSessionIdList(value: unknown): string[] { + if (!Array.isArray(value)) return [] + return Array.from(new Set(value.map((item) => String(item || '').trim()).filter(Boolean))) +} + /** * 调用 OpenAI 兼容 API(非流式),返回模型第一条消息内容。 * 使用 Node 原生 https/http 模块,无需任何第三方 SDK。 @@ -495,7 +504,7 @@ class InsightService { return id && !id.endsWith('@chatroom') && !id.toLowerCase().includes('placeholder') && this.isSessionAllowed(id) }) if (!session) { - return { success: false, message: '未找到任何私聊会话(若已启用白名单,请检查是否有勾选的私聊)' } + return { success: false, message: '未找到任何可触发的私聊会话(请检查黑白名单模式与选择列表)' } } const sessionId = session.username?.trim() || '' const displayName = session.displayName || sessionId @@ -747,14 +756,23 @@ ${topMentionText} /** * 判断某个会话是否允许触发见解。 - * 若白名单未启用,则所有私聊会话均允许; - * 若白名单已启用,则只有在白名单中的会话才允许。 + * white/black 模式二选一: + * - whitelist:仅名单内允许 + * - blacklist:名单内屏蔽,其他允许 */ + private getInsightFilterConfig(): { mode: InsightFilterMode; list: string[] } { + const modeRaw = String(this.config.get('aiInsightFilterMode') || '').trim().toLowerCase() + const mode: InsightFilterMode = modeRaw === 'blacklist' ? 'blacklist' : 'whitelist' + const list = normalizeSessionIdList(this.config.get('aiInsightFilterList')) + return { mode, list } + } + private isSessionAllowed(sessionId: string): boolean { - const whitelistEnabled = this.config.get('aiInsightWhitelistEnabled') as boolean - if (!whitelistEnabled) return true - const whitelist = (this.config.get('aiInsightWhitelist') as string[]) || [] - return whitelist.includes(sessionId) + const normalizedSessionId = String(sessionId || '').trim() + if (!normalizedSessionId) return false + const { mode, list } = this.getInsightFilterConfig() + if (mode === 'whitelist') return list.includes(normalizedSessionId) + return !list.includes(normalizedSessionId) } /** @@ -966,8 +984,8 @@ ${topMentionText} * 1. 会话有真正的新消息(lastTimestamp 比上次见到的更新) * 2. 该会话距上次活跃分析已超过冷却期 * - * 白名单启用时:直接使用白名单里的 sessionId,完全跳过 getSessions()。 - * 白名单未启用时:从缓存拉取全量会话后过滤私聊。 + * whitelist 模式:直接使用名单里的 sessionId,完全跳过 getSessions()。 + * blacklist 模式:从缓存拉取会话后过滤名单。 */ private async analyzeRecentActivity(): Promise { if (!this.isEnabled()) return @@ -978,12 +996,11 @@ ${topMentionText} const now = Date.now() const cooldownMinutes = (this.config.get('aiInsightCooldownMinutes') as number) ?? 120 const cooldownMs = cooldownMinutes * 60 * 1000 - const whitelistEnabled = this.config.get('aiInsightWhitelistEnabled') as boolean - const whitelist = (this.config.get('aiInsightWhitelist') as string[]) || [] + const { mode: filterMode, list: filterList } = this.getInsightFilterConfig() - // 白名单启用且有勾选项时,直接用白名单 sessionId,无需查数据库全量会话列表。 + // whitelist 模式且有勾选项时,直接用名单 sessionId,无需查数据库全量会话列表。 // 通过拉取该会话最新 1 条消息时间戳判断是否真正有新消息,开销极低。 - if (whitelistEnabled && whitelist.length > 0) { + if (filterMode === 'whitelist' && filterList.length > 0) { // 确保数据库已连接(首次时连接,之后复用) if (!this.dbConnected) { const connectResult = await chatService.connect() @@ -991,8 +1008,8 @@ ${topMentionText} this.dbConnected = true } - for (const sessionId of whitelist) { - if (!sessionId || sessionId.endsWith('@chatroom')) continue + for (const sessionId of filterList) { + if (!sessionId || sessionId.toLowerCase().includes('placeholder')) continue // 冷却期检查(先过滤,减少不必要的 DB 查询) if (cooldownMs > 0) { @@ -1029,16 +1046,22 @@ ${topMentionText} return } - // 白名单未启用:需要拉取全量会话列表,从中过滤私聊 + if (filterMode === 'whitelist' && filterList.length === 0) { + insightLog('INFO', '白名单模式且名单为空,跳过活跃分析') + return + } + + // blacklist 模式:拉取会话缓存后按过滤规则筛选 const sessions = await this.getSessionsCached() if (sessions.length === 0) return - const privateSessions = sessions.filter((s) => { + const candidateSessions = sessions.filter((s) => { const id = s.username?.trim() || '' - return id && !id.endsWith('@chatroom') && !id.toLowerCase().includes('placeholder') + if (!id || id.toLowerCase().includes('placeholder')) return false + return this.isSessionAllowed(id) }) - for (const session of privateSessions.slice(0, 10)) { + for (const session of candidateSessions.slice(0, 10)) { const sessionId = session.username?.trim() || '' if (!sessionId) continue diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index 851d8d1..b172e2f 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -75,6 +75,7 @@ interface WxidOption { type SessionFilterType = configService.MessagePushSessionType type SessionFilterTypeValue = 'all' | SessionFilterType type SessionFilterMode = 'all' | 'whitelist' | 'blacklist' +type InsightSessionFilterTypeValue = 'all' | 'private' | 'group' | 'official' interface SessionFilterOption { username: string @@ -91,6 +92,13 @@ const sessionFilterTypeOptions: Array<{ value: SessionFilterTypeValue; label: st { value: 'other', label: '其他/非好友' } ] +const insightFilterTypeOptions: Array<{ value: InsightSessionFilterTypeValue; label: string }> = [ + { value: 'all', label: '全部' }, + { value: 'private', label: '私聊' }, + { value: 'group', label: '群聊' }, + { value: 'official', label: '订阅号/服务号' } +] + interface SettingsPageProps { onClose?: () => void } @@ -194,6 +202,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { const [filterModeDropdownOpen, setFilterModeDropdownOpen] = useState(false) const [positionDropdownOpen, setPositionDropdownOpen] = useState(false) const [closeBehaviorDropdownOpen, setCloseBehaviorDropdownOpen] = useState(false) + const [insightFilterModeDropdownOpen, setInsightFilterModeDropdownOpen] = useState(false) const [wordCloudExcludeWords, setWordCloudExcludeWords] = useState([]) const [excludeWordsInput, setExcludeWordsInput] = useState('') @@ -275,8 +284,9 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { const [showInsightApiKey, setShowInsightApiKey] = useState(false) const [isTriggeringInsightTest, setIsTriggeringInsightTest] = useState(false) const [insightTriggerResult, setInsightTriggerResult] = useState<{ success: boolean; message: string } | null>(null) - const [aiInsightWhitelistEnabled, setAiInsightWhitelistEnabled] = useState(false) - const [aiInsightWhitelist, setAiInsightWhitelist] = useState>(new Set()) + const [aiInsightFilterMode, setAiInsightFilterMode] = useState('whitelist') + const [aiInsightFilterList, setAiInsightFilterList] = useState>(new Set()) + const [insightFilterType, setInsightFilterType] = useState('all') const [insightWhitelistSearch, setInsightWhitelistSearch] = useState('') const [aiInsightCooldownMinutes, setAiInsightCooldownMinutes] = useState(120) const [aiInsightScanIntervalHours, setAiInsightScanIntervalHours] = useState(4) @@ -397,15 +407,16 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { setPositionDropdownOpen(false) setCloseBehaviorDropdownOpen(false) setMessagePushFilterDropdownOpen(false) + setInsightFilterModeDropdownOpen(false) } } - if (filterModeDropdownOpen || positionDropdownOpen || closeBehaviorDropdownOpen || messagePushFilterDropdownOpen) { + if (filterModeDropdownOpen || positionDropdownOpen || closeBehaviorDropdownOpen || messagePushFilterDropdownOpen || insightFilterModeDropdownOpen) { document.addEventListener('click', handleClickOutside) } return () => { document.removeEventListener('click', handleClickOutside) } - }, [closeBehaviorDropdownOpen, filterModeDropdownOpen, messagePushFilterDropdownOpen, positionDropdownOpen]) + }, [closeBehaviorDropdownOpen, filterModeDropdownOpen, insightFilterModeDropdownOpen, messagePushFilterDropdownOpen, positionDropdownOpen]) const loadConfig = async () => { @@ -531,8 +542,8 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { const savedAiModelApiMaxTokens = await configService.getAiModelApiMaxTokens() const savedAiInsightSilenceDays = await configService.getAiInsightSilenceDays() const savedAiInsightAllowContext = await configService.getAiInsightAllowContext() - const savedAiInsightWhitelistEnabled = await configService.getAiInsightWhitelistEnabled() - const savedAiInsightWhitelist = await configService.getAiInsightWhitelist() + const savedAiInsightFilterMode = await configService.getAiInsightFilterMode() + const savedAiInsightFilterList = await configService.getAiInsightFilterList() const savedAiInsightCooldownMinutes = await configService.getAiInsightCooldownMinutes() const savedAiInsightScanIntervalHours = await configService.getAiInsightScanIntervalHours() const savedAiInsightContextCount = await configService.getAiInsightContextCount() @@ -555,8 +566,8 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { setAiModelApiMaxTokens(savedAiModelApiMaxTokens) setAiInsightSilenceDays(savedAiInsightSilenceDays) setAiInsightAllowContext(savedAiInsightAllowContext) - setAiInsightWhitelistEnabled(savedAiInsightWhitelistEnabled) - setAiInsightWhitelist(new Set(savedAiInsightWhitelist)) + setAiInsightFilterMode(savedAiInsightFilterMode) + setAiInsightFilterList(new Set(savedAiInsightFilterList)) setAiInsightCooldownMinutes(savedAiInsightCooldownMinutes) setAiInsightScanIntervalHours(savedAiInsightScanIntervalHours) setAiInsightContextCount(savedAiInsightContextCount) @@ -3390,68 +3401,69 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
- {/* 对话白名单 */} + {/* 对话过滤名单 */} {(() => { - const sortedSessions = [...chatSessions].sort((a, b) => (b.sortTimestamp || 0) - (a.sortTimestamp || 0)) + const selectableSessions = sessionFilterOptions.filter((session) => + session.type === 'private' || session.type === 'group' || session.type === 'official' + ) const keyword = insightWhitelistSearch.trim().toLowerCase() - const filteredSessions = sortedSessions.filter((s) => { - const id = s.username?.trim() || '' - if (!id || id.endsWith('@chatroom') || id.toLowerCase().includes('placeholder')) return false + const filteredSessions = selectableSessions.filter((session) => { + if (insightFilterType !== 'all' && session.type !== insightFilterType) return false + const id = session.username?.trim() || '' + if (!id || id.toLowerCase().includes('placeholder')) return false if (!keyword) return true return ( - String(s.displayName || '').toLowerCase().includes(keyword) || + String(session.displayName || '').toLowerCase().includes(keyword) || id.toLowerCase().includes(keyword) ) }) - const filteredIds = filteredSessions.map((s) => s.username) - const selectedCount = aiInsightWhitelist.size - const selectedInFilteredCount = filteredIds.filter((id) => aiInsightWhitelist.has(id)).length + const filteredIds = filteredSessions.map((session) => session.username) + const selectedCount = aiInsightFilterList.size + const selectedInFilteredCount = filteredIds.filter((id) => aiInsightFilterList.has(id)).length const allFilteredSelected = filteredIds.length > 0 && selectedInFilteredCount === filteredIds.length - const toggleSession = (id: string) => { - setAiInsightWhitelist((prev) => { - const next = new Set(prev) - if (next.has(id)) next.delete(id) - else next.add(id) - return next - }) + const saveFilterList = async (next: Set) => { + await configService.setAiInsightFilterList(Array.from(next)) } - const saveWhitelist = async (next: Set) => { - await configService.setAiInsightWhitelist(Array.from(next)) + const saveFilterMode = async (mode: configService.AiInsightFilterMode) => { + setAiInsightFilterMode(mode) + setInsightFilterModeDropdownOpen(false) + await configService.setAiInsightFilterMode(mode) + showMessage(mode === 'whitelist' ? '已切换为白名单模式' : '已切换为黑名单模式', true) } const selectAllFiltered = () => { - setAiInsightWhitelist((prev) => { + setAiInsightFilterList((prev) => { const next = new Set(prev) for (const id of filteredIds) next.add(id) - void saveWhitelist(next) + void saveFilterList(next) return next }) } const clearSelection = () => { const next = new Set() - setAiInsightWhitelist(next) - void saveWhitelist(next) + setAiInsightFilterList(next) + void saveFilterList(next) } return (
-

对话白名单

+

对话黑白名单

- 开启后,AI 见解仅对勾选的私聊对话生效,未勾选的对话将被完全忽略。关闭时对所有私聊均生效。中间可填写微博 UID。 + 白名单模式下仅对已选会话触发见解;黑名单模式下会跳过已选会话。默认白名单且不选择任何会话。支持私聊、群聊、订阅号/服务号分类筛选后批量选择。

- 私聊总数 - {filteredIds.length + (keyword ? 0 : 0)} + 可选会话总数 + {selectableSessions.length}
- 已选中 + 已加入名单 {selectedCount}
@@ -3459,29 +3471,57 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
- {aiInsightWhitelistEnabled ? '白名单已启用(仅对勾选对话生效)' : '白名单未启用(对所有私聊生效)'} + {aiInsightFilterMode === 'whitelist' + ? '白名单模式(仅对名单内会话生效)' + : '黑名单模式(名单内会话将被忽略)'} - +
+
setInsightFilterModeDropdownOpen(!insightFilterModeDropdownOpen)} + > + + {aiInsightFilterMode === 'whitelist' ? '白名单模式' : '黑名单模式'} + + +
+
+ {[ + { value: 'whitelist', label: '白名单模式' }, + { value: 'blacklist', label: '黑名单模式' } + ].map(option => ( +
{ void saveFilterMode(option.value as configService.AiInsightFilterMode) }} + > + {option.label} + {aiInsightFilterMode === option.value && } +
+ ))} +
+
+
+ {insightFilterTypeOptions.map(option => ( + + ))} +
setInsightWhitelistSearch(e.target.value)} /> @@ -3517,7 +3557,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
{filteredSessions.length === 0 ? (
- {insightWhitelistSearch ? '没有匹配的对话' : '暂无私聊对话'} + {insightWhitelistSearch || insightFilterType !== 'all' ? '没有匹配的对话' : '暂无可选对话'}
) : ( <> @@ -3527,7 +3567,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { 状态
{filteredSessions.map((session) => { - const isSelected = aiInsightWhitelist.has(session.username) + const isSelected = aiInsightFilterList.has(session.username) const weiboBinding = aiInsightWeiboBindings[session.username] const weiboDraftValue = getWeiboBindingDraftValue(session.username) const isBindingLoading = weiboBindingLoadingSessionId === session.username @@ -3543,11 +3583,11 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { type="checkbox" checked={isSelected} onChange={async () => { - setAiInsightWhitelist((prev) => { + setAiInsightFilterList((prev) => { const next = new Set(prev) if (next.has(session.username)) next.delete(session.username) else next.add(session.username) - void configService.setAiInsightWhitelist(Array.from(next)) + void configService.setAiInsightFilterList(Array.from(next)) return next }) }} @@ -3563,54 +3603,65 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { />
{session.displayName || session.username} + {getSessionFilterTypeLabel(session.type)}
-
- 微博 - updateWeiboBindingDraft(session.username, e.target.value)} - /> -
-
- - {weiboBinding && ( - - )} -
-
- {weiboBindingError ? ( - {weiboBindingError} - ) : weiboBinding?.screenName ? ( - @{weiboBinding.screenName} - ) : weiboBinding?.uid ? ( - 已绑定 UID:{weiboBinding.uid} - ) : ( - 仅支持手动填写数字 UID - )} -
+ {session.type === 'private' ? ( + <> +
+ 微博 + updateWeiboBindingDraft(session.username, e.target.value)} + /> +
+
+ + {weiboBinding && ( + + )} +
+
+ {weiboBindingError ? ( + {weiboBindingError} + ) : weiboBinding?.screenName ? ( + @{weiboBinding.screenName} + ) : weiboBinding?.uid ? ( + 已绑定 UID:{weiboBinding.uid} + ) : ( + 仅支持手动填写数字 UID + )} +
+ + ) : ( +
+ 仅私聊支持微博绑定 +
+ )}