feat(insight): add whitelist/blacklist mode and typed batch selection

This commit is contained in:
Jason
2026-04-18 17:45:44 +08:00
parent 55885449a3
commit 678c08b507
4 changed files with 227 additions and 120 deletions

View File

@@ -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<string[]>([])
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<Set<string>>(new Set())
const [aiInsightFilterMode, setAiInsightFilterMode] = useState<configService.AiInsightFilterMode>('whitelist')
const [aiInsightFilterList, setAiInsightFilterList] = useState<Set<string>>(new Set())
const [insightFilterType, setInsightFilterType] = useState<InsightSessionFilterTypeValue>('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 = {}) {
<div className="divider" />
{/* 对话名单 */}
{/* 对话过滤名单 */}
{(() => {
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<string>) => {
await configService.setAiInsightFilterList(Array.from(next))
}
const saveWhitelist = async (next: Set<string>) => {
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<string>()
setAiInsightWhitelist(next)
void saveWhitelist(next)
setAiInsightFilterList(next)
void saveFilterList(next)
}
return (
<div className="anti-revoke-tab insight-social-tab">
<div className="anti-revoke-hero">
<div className="anti-revoke-hero-main">
<h3></h3>
<h3></h3>
<p>
AI UID
/
</p>
</div>
<div className="anti-revoke-metrics">
<div className="anti-revoke-metric is-total">
<span className="label"></span>
<span className="value">{filteredIds.length + (keyword ? 0 : 0)}</span>
<span className="label"></span>
<span className="value">{selectableSessions.length}</span>
</div>
<div className="anti-revoke-metric is-installed">
<span className="label"></span>
<span className="label"></span>
<span className="value">{selectedCount}</span>
</div>
</div>
@@ -3459,29 +3471,57 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
<div className="log-toggle-line" style={{ marginBottom: 12 }}>
<span className="log-status" style={{ fontWeight: 600 }}>
{aiInsightWhitelistEnabled ? '白名单已启用(仅对勾选对话生效)' : '白名单未启用(对所有私聊生效)'}
{aiInsightFilterMode === 'whitelist'
? '白名单模式(仅对名单内会话生效)'
: '黑名单模式(名单内会话将被忽略)'}
</span>
<label className="switch">
<input
type="checkbox"
checked={aiInsightWhitelistEnabled}
onChange={async (e) => {
const val = e.target.checked
setAiInsightWhitelistEnabled(val)
await configService.setAiInsightWhitelistEnabled(val)
}}
/>
<span className="switch-slider" />
</label>
<div className="custom-select" style={{ minWidth: 210 }}>
<div
className={`custom-select-trigger ${insightFilterModeDropdownOpen ? 'open' : ''}`}
onClick={() => setInsightFilterModeDropdownOpen(!insightFilterModeDropdownOpen)}
>
<span className="custom-select-value">
{aiInsightFilterMode === 'whitelist' ? '白名单模式' : '黑名单模式'}
</span>
<ChevronDown size={14} className={`custom-select-arrow ${insightFilterModeDropdownOpen ? 'rotate' : ''}`} />
</div>
<div className={`custom-select-dropdown ${insightFilterModeDropdownOpen ? 'open' : ''}`}>
{[
{ value: 'whitelist', label: '白名单模式' },
{ value: 'blacklist', label: '黑名单模式' }
].map(option => (
<div
key={option.value}
className={`custom-select-option ${aiInsightFilterMode === option.value ? 'selected' : ''}`}
onClick={() => { void saveFilterMode(option.value as configService.AiInsightFilterMode) }}
>
{option.label}
{aiInsightFilterMode === option.value && <Check size={14} />}
</div>
))}
</div>
</div>
</div>
<div className="anti-revoke-control-card">
<div className="push-filter-type-tabs" style={{ marginBottom: 10 }}>
{insightFilterTypeOptions.map(option => (
<button
key={option.value}
type="button"
className={`push-filter-type-tab ${insightFilterType === option.value ? 'active' : ''}`}
onClick={() => setInsightFilterType(option.value)}
>
{option.label}
</button>
))}
</div>
<div className="anti-revoke-toolbar">
<div className="filter-search-box anti-revoke-search">
<Search size={14} />
<input
type="text"
placeholder="搜索私聊对话..."
placeholder="搜索对话..."
value={insightWhitelistSearch}
onChange={(e) => setInsightWhitelistSearch(e.target.value)}
/>
@@ -3517,7 +3557,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
<div className="anti-revoke-list">
{filteredSessions.length === 0 ? (
<div className="anti-revoke-empty">
{insightWhitelistSearch ? '没有匹配的对话' : '暂无私聊对话'}
{insightWhitelistSearch || insightFilterType !== 'all' ? '没有匹配的对话' : '暂无可选对话'}
</div>
) : (
<>
@@ -3527,7 +3567,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
<span></span>
</div>
{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 = {}) {
/>
<div className="anti-revoke-row-text">
<span className="name">{session.displayName || session.username}</span>
<span className="desc">{getSessionFilterTypeLabel(session.type)}</span>
</div>
</label>
<div className="insight-social-binding-cell">
<div className="insight-social-binding-input-wrap">
<span className="binding-platform-chip"></span>
<input
type="text"
className="insight-social-binding-input"
value={weiboDraftValue}
placeholder="填写数字 UID"
onChange={(e) => updateWeiboBindingDraft(session.username, e.target.value)}
/>
</div>
<div className="insight-social-binding-actions">
<button
type="button"
className="btn btn-secondary btn-sm"
onClick={() => void handleSaveWeiboBinding(session.username, session.displayName || session.username)}
disabled={isBindingLoading || !weiboDraftValue.trim()}
>
{isBindingLoading ? '绑定中...' : (weiboBinding ? '更新' : '绑定')}
</button>
{weiboBinding && (
<button
type="button"
className="btn btn-secondary btn-sm"
onClick={() => void handleClearWeiboBinding(session.username)}
>
</button>
)}
</div>
<div className="insight-social-binding-feedback">
{weiboBindingError ? (
<span className="binding-feedback error">{weiboBindingError}</span>
) : weiboBinding?.screenName ? (
<span className="binding-feedback">@{weiboBinding.screenName}</span>
) : weiboBinding?.uid ? (
<span className="binding-feedback"> UID{weiboBinding.uid}</span>
) : (
<span className="binding-feedback muted"> UID</span>
)}
</div>
{session.type === 'private' ? (
<>
<div className="insight-social-binding-input-wrap">
<span className="binding-platform-chip"></span>
<input
type="text"
className="insight-social-binding-input"
value={weiboDraftValue}
placeholder="填写数字 UID"
onChange={(e) => updateWeiboBindingDraft(session.username, e.target.value)}
/>
</div>
<div className="insight-social-binding-actions">
<button
type="button"
className="btn btn-secondary btn-sm"
onClick={() => void handleSaveWeiboBinding(session.username, session.displayName || session.username)}
disabled={isBindingLoading || !weiboDraftValue.trim()}
>
{isBindingLoading ? '绑定中...' : (weiboBinding ? '更新' : '绑定')}
</button>
{weiboBinding && (
<button
type="button"
className="btn btn-secondary btn-sm"
onClick={() => void handleClearWeiboBinding(session.username)}
>
</button>
)}
</div>
<div className="insight-social-binding-feedback">
{weiboBindingError ? (
<span className="binding-feedback error">{weiboBindingError}</span>
) : weiboBinding?.screenName ? (
<span className="binding-feedback">@{weiboBinding.screenName}</span>
) : weiboBinding?.uid ? (
<span className="binding-feedback"> UID{weiboBinding.uid}</span>
) : (
<span className="binding-feedback muted"> UID</span>
)}
</div>
</>
) : (
<div className="insight-social-binding-feedback">
<span className="binding-feedback muted"></span>
</div>
)}
</div>
<div className="anti-revoke-row-status">
<span className={`status-badge ${isSelected ? 'installed' : 'not-installed'}`}>
<i className="status-dot" aria-hidden="true" />
{isSelected ? '已加入' : '未加入'}
{isSelected
? (aiInsightFilterMode === 'whitelist' ? '已允许' : '已屏蔽')
: (aiInsightFilterMode === 'whitelist' ? '未允许' : '允许')}
</span>
</div>
</div>
@@ -3631,7 +3682,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
<div className="api-docs">
<div className="api-item">
<p className="api-desc" style={{ lineHeight: 1.7 }}>
<strong></strong> 500ms <br />
<strong></strong> 500ms <br />
<strong></strong> 4 <br />
<strong></strong> AI AI <br />
<strong></strong> API WeFlow

View File

@@ -97,6 +97,8 @@ export const CONFIG_KEYS = {
AI_INSIGHT_SILENCE_DAYS: 'aiInsightSilenceDays',
AI_INSIGHT_ALLOW_CONTEXT: 'aiInsightAllowContext',
AI_INSIGHT_ALLOW_SOCIAL_CONTEXT: 'aiInsightAllowSocialContext',
AI_INSIGHT_FILTER_MODE: 'aiInsightFilterMode',
AI_INSIGHT_FILTER_LIST: 'aiInsightFilterList',
AI_INSIGHT_WHITELIST_ENABLED: 'aiInsightWhitelistEnabled',
AI_INSIGHT_WHITELIST: 'aiInsightWhitelist',
AI_INSIGHT_COOLDOWN_MINUTES: 'aiInsightCooldownMinutes',
@@ -1917,22 +1919,49 @@ export async function setAiInsightAllowSocialContext(allow: boolean): Promise<vo
await config.set(CONFIG_KEYS.AI_INSIGHT_ALLOW_SOCIAL_CONTEXT, allow)
}
export type AiInsightFilterMode = 'whitelist' | 'blacklist'
const normalizeAiInsightFilterList = (value: unknown): string[] => {
if (!Array.isArray(value)) return []
return Array.from(new Set(value.map((item) => String(item || '').trim()).filter(Boolean)))
}
export async function getAiInsightFilterMode(): Promise<AiInsightFilterMode> {
const value = await config.get(CONFIG_KEYS.AI_INSIGHT_FILTER_MODE)
if (value === 'blacklist') return 'blacklist'
if (value === 'whitelist') return 'whitelist'
return 'whitelist'
}
export async function setAiInsightFilterMode(mode: AiInsightFilterMode): Promise<void> {
const normalizedMode: AiInsightFilterMode = mode === 'blacklist' ? 'blacklist' : 'whitelist'
await config.set(CONFIG_KEYS.AI_INSIGHT_FILTER_MODE, normalizedMode)
}
export async function getAiInsightFilterList(): Promise<string[]> {
const value = await config.get(CONFIG_KEYS.AI_INSIGHT_FILTER_LIST)
return normalizeAiInsightFilterList(value)
}
export async function setAiInsightFilterList(list: string[]): Promise<void> {
await config.set(CONFIG_KEYS.AI_INSIGHT_FILTER_LIST, normalizeAiInsightFilterList(list))
}
// 兼容旧字段命名:内部已映射到新的黑白名单模式
export async function getAiInsightWhitelistEnabled(): Promise<boolean> {
const value = await config.get(CONFIG_KEYS.AI_INSIGHT_WHITELIST_ENABLED)
return value === true
return (await getAiInsightFilterMode()) === 'whitelist'
}
export async function setAiInsightWhitelistEnabled(enabled: boolean): Promise<void> {
await config.set(CONFIG_KEYS.AI_INSIGHT_WHITELIST_ENABLED, enabled)
await setAiInsightFilterMode(enabled ? 'whitelist' : 'blacklist')
}
export async function getAiInsightWhitelist(): Promise<string[]> {
const value = await config.get(CONFIG_KEYS.AI_INSIGHT_WHITELIST)
return Array.isArray(value) ? (value as string[]) : []
return getAiInsightFilterList()
}
export async function setAiInsightWhitelist(list: string[]): Promise<void> {
await config.set(CONFIG_KEYS.AI_INSIGHT_WHITELIST, list)
await setAiInsightFilterList(list)
}
export async function getAiInsightCooldownMinutes(): Promise<number> {