feat: add aiInsightWhitelist to settings page

Implement aiInsightWhitelist feature with UI and filtering logic.

Co-authored-by: Jason <159670257+Jasonzhu1207@users.noreply.github.com>
This commit is contained in:
v0
2026-04-05 16:42:43 +00:00
parent 1e16ea887b
commit 5971757a28
4 changed files with 240 additions and 8 deletions

View File

@@ -70,6 +70,8 @@ interface ConfigSchema {
aiInsightApiModel: string aiInsightApiModel: string
aiInsightSilenceDays: number aiInsightSilenceDays: number
aiInsightAllowContext: boolean aiInsightAllowContext: boolean
aiInsightWhitelistEnabled: boolean
aiInsightWhitelist: string[]
} }
// 需要 safeStorage 加密的字段(普通模式) // 需要 safeStorage 加密的字段(普通模式)
@@ -149,7 +151,9 @@ export class ConfigService {
aiInsightApiKey: '', aiInsightApiKey: '',
aiInsightApiModel: 'gpt-4o-mini', aiInsightApiModel: 'gpt-4o-mini',
aiInsightSilenceDays: 3, aiInsightSilenceDays: 3,
aiInsightAllowContext: false aiInsightAllowContext: false,
aiInsightWhitelistEnabled: false,
aiInsightWhitelist: []
} }
const storeOptions: any = { const storeOptions: any = {
@@ -697,7 +701,7 @@ export class ConfigService {
// === 工具方法 === // === 工具方法 ===
/** /**
* 获取当前 wxid 对应的图片密钥,优先从 wxidConfigs 中取,找不到则回退到全局 * 获取当前 wxid 对应的图片密钥,优先从 wxidConfigs 中取,找不到则回退到全局<EFBFBD><EFBFBD>
*/ */
getImageKeysForCurrentWxid(): { xorKey: unknown; aesKey: string } { getImageKeysForCurrentWxid(): { xorKey: unknown; aesKey: string } {
const wxid = this.get('myWxid') const wxid = this.get('myWxid')

View File

@@ -267,6 +267,18 @@ class InsightService {
return this.config.get('aiInsightEnabled') === true return this.config.get('aiInsightEnabled') === true
} }
/**
* 判断某个会话是否允许触发见解。
* 若白名单未启用,则所有私聊会话均允许;
* 若白名单已启用,则只有在白名单中的会话才允许。
*/
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)
}
private resetIfNewDay(): void { private resetIfNewDay(): void {
const todayStart = getStartOfDay() const todayStart = getStartOfDay()
if (todayStart > this.todayDate) { if (todayStart > this.todayDate) {
@@ -332,6 +344,7 @@ class InsightService {
const sessionId = session.username?.trim() || '' const sessionId = session.username?.trim() || ''
if (!sessionId || sessionId.endsWith('@chatroom')) continue // 跳过群聊 if (!sessionId || sessionId.endsWith('@chatroom')) continue // 跳过群聊
if (sessionId.toLowerCase().includes('placeholder')) continue if (sessionId.toLowerCase().includes('placeholder')) continue
if (!this.isSessionAllowed(sessionId)) continue // 白名单过滤
// lastTimestamp 单位是秒,需要转换为毫秒 // lastTimestamp 单位是秒,需要转换为毫秒
const lastTimestamp = (session.lastTimestamp || 0) * 1000 const lastTimestamp = (session.lastTimestamp || 0) * 1000
@@ -378,7 +391,7 @@ class InsightService {
const candidates = sessions const candidates = sessions
.filter((s) => { .filter((s) => {
const id = s.username?.trim() || '' const id = s.username?.trim() || ''
return id && !id.endsWith('@chatroom') && !id.toLowerCase().includes('placeholder') return id && !id.endsWith('@chatroom') && !id.toLowerCase().includes('placeholder') && this.isSessionAllowed(id)
}) })
.slice(0, 5) .slice(0, 5)

View File

@@ -225,6 +225,9 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
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)
const [aiInsightWhitelistEnabled, setAiInsightWhitelistEnabled] = useState(false)
const [aiInsightWhitelist, setAiInsightWhitelist] = useState<Set<string>>(new Set())
const [insightWhitelistSearch, setInsightWhitelistSearch] = useState('')
const [isWayland, setIsWayland] = useState(false) const [isWayland, setIsWayland] = useState(false)
useEffect(() => { useEffect(() => {
@@ -458,12 +461,16 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
const savedAiInsightApiModel = await configService.getAiInsightApiModel() const savedAiInsightApiModel = await configService.getAiInsightApiModel()
const savedAiInsightSilenceDays = await configService.getAiInsightSilenceDays() const savedAiInsightSilenceDays = await configService.getAiInsightSilenceDays()
const savedAiInsightAllowContext = await configService.getAiInsightAllowContext() const savedAiInsightAllowContext = await configService.getAiInsightAllowContext()
const savedAiInsightWhitelistEnabled = await configService.getAiInsightWhitelistEnabled()
const savedAiInsightWhitelist = await configService.getAiInsightWhitelist()
setAiInsightEnabled(savedAiInsightEnabled) setAiInsightEnabled(savedAiInsightEnabled)
setAiInsightApiBaseUrl(savedAiInsightApiBaseUrl) setAiInsightApiBaseUrl(savedAiInsightApiBaseUrl)
setAiInsightApiKey(savedAiInsightApiKey) setAiInsightApiKey(savedAiInsightApiKey)
setAiInsightApiModel(savedAiInsightApiModel) setAiInsightApiModel(savedAiInsightApiModel)
setAiInsightSilenceDays(savedAiInsightSilenceDays) setAiInsightSilenceDays(savedAiInsightSilenceDays)
setAiInsightAllowContext(savedAiInsightAllowContext) setAiInsightAllowContext(savedAiInsightAllowContext)
setAiInsightWhitelistEnabled(savedAiInsightWhitelistEnabled)
setAiInsightWhitelist(new Set(savedAiInsightWhitelist))
} catch (e: any) { } catch (e: any) {
console.error('加载配置失败:', e) console.error('加载配置失败:', e)
@@ -1434,7 +1441,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
</div> </div>
<div className="form-group quote-layout-group"> <div className="form-group quote-layout-group">
<label></label> <label><EFBFBD><EFBFBD><EFBFBD></label>
<span className="form-hint"></span> <span className="form-hint"></span>
<div className="quote-layout-picker" role="radiogroup" aria-label="引用样式选择"> <div className="quote-layout-picker" role="radiogroup" aria-label="引用样式选择">
{[ {[
@@ -2676,6 +2683,194 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
<div className="divider" /> <div className="divider" />
{/* 对话白名单 */}
{(() => {
const sortedSessions = [...chatSessions].sort((a, b) => (b.sortTimestamp || 0) - (a.sortTimestamp || 0))
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
if (!keyword) return true
return (
String(s.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 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 saveWhitelist = async (next: Set<string>) => {
await configService.setAiInsightWhitelist(Array.from(next))
}
const selectAllFiltered = () => {
setAiInsightWhitelist((prev) => {
const next = new Set(prev)
for (const id of filteredIds) next.add(id)
void saveWhitelist(next)
return next
})
}
const clearSelection = () => {
const next = new Set<string>()
setAiInsightWhitelist(next)
void saveWhitelist(next)
}
return (
<div className="anti-revoke-tab">
<div className="anti-revoke-hero">
<div className="anti-revoke-hero-main">
<h3></h3>
<p>
AI
</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>
</div>
<div className="anti-revoke-metric is-installed">
<span className="label"></span>
<span className="value">{selectedCount}</span>
</div>
</div>
</div>
<div className="log-toggle-line" style={{ marginBottom: 12 }}>
<span className="log-status" style={{ fontWeight: 600 }}>
{aiInsightWhitelistEnabled ? '白名单已启用(仅对勾选对话生效)' : '白名单未启用(对所有私聊生效)'}
</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>
<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={insightWhitelistSearch}
onChange={(e) => setInsightWhitelistSearch(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={filteredIds.length === 0 || allFilteredSelected}
>
</button>
<button
className="btn btn-secondary btn-sm"
onClick={clearSelection}
disabled={selectedCount === 0}
>
</button>
</div>
</div>
</div>
<div className="anti-revoke-batch-actions">
<div className="anti-revoke-selected-count">
<span> <strong>{selectedCount}</strong> </span>
<span> <strong>{selectedInFilteredCount}</strong> / {filteredIds.length}</span>
</div>
</div>
</div>
<div className="anti-revoke-list">
{filteredSessions.length === 0 ? (
<div className="anti-revoke-empty">
{insightWhitelistSearch ? '没有匹配的对话' : '暂无私聊对话'}
</div>
) : (
<>
<div className="anti-revoke-list-header">
<span>{filteredSessions.length}</span>
<span></span>
</div>
{filteredSessions.map((session) => {
const isSelected = aiInsightWhitelist.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={async () => {
setAiInsightWhitelist((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))
return next
})
}}
/>
<span className="check-indicator" aria-hidden="true">
<Check size={12} />
</span>
</span>
<Avatar
src={session.avatarUrl}
name={session.displayName || session.username}
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>
)
})()}
<div className="divider" />
{/* 工作原理说明 */} {/* 工作原理说明 */}
<div className="form-group"> <div className="form-group">
<label></label> <label></label>
@@ -2794,7 +2989,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
value={`http://${httpApiHost}:${httpApiPort}`} value={`http://${httpApiHost}:${httpApiPort}`}
readOnly readOnly
/> />
<button className="btn btn-secondary" onClick={handleCopyApiUrl} title="复"> <button className="btn btn-secondary" onClick={handleCopyApiUrl} title="复<EFBFBD><EFBFBD><EFBFBD>">
<Copy size={16} /> <Copy size={16} />
</button> </button>
</div> </div>
@@ -3160,7 +3355,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
onClick={handleSetupHello} onClick={handleSetupHello}
disabled={!helloAvailable || isSettingHello || !authEnabled || !helloPassword} disabled={!helloAvailable || isSettingHello || !authEnabled || !helloPassword}
> >
{isSettingHello ? '置中...' : '开启与设置'} {isSettingHello ? '<EFBFBD><EFBFBD><EFBFBD>置中...' : '开启与设置'}
</button> </button>
)} )}
</div> </div>

View File

@@ -87,7 +87,9 @@ export const CONFIG_KEYS = {
AI_INSIGHT_API_KEY: 'aiInsightApiKey', AI_INSIGHT_API_KEY: 'aiInsightApiKey',
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_WHITELIST_ENABLED: 'aiInsightWhitelistEnabled',
AI_INSIGHT_WHITELIST: 'aiInsightWhitelist'
} as const } as const
export interface WxidConfig { export interface WxidConfig {
@@ -496,7 +498,7 @@ export async function setExportDefaultTxtColumns(columns: string[]): Promise<voi
await config.set(CONFIG_KEYS.EXPORT_DEFAULT_TXT_COLUMNS, columns) await config.set(CONFIG_KEYS.EXPORT_DEFAULT_TXT_COLUMNS, columns)
} }
// 获取导出默认并发 // 获取导出默认并发<EFBFBD><EFBFBD>
export async function getExportDefaultConcurrency(): Promise<number | null> { export async function getExportDefaultConcurrency(): Promise<number | null> {
const value = await config.get(CONFIG_KEYS.EXPORT_DEFAULT_CONCURRENCY) const value = await config.get(CONFIG_KEYS.EXPORT_DEFAULT_CONCURRENCY)
if (typeof value === 'number' && Number.isFinite(value)) return value if (typeof value === 'number' && Number.isFinite(value)) return value
@@ -1615,3 +1617,21 @@ export async function getAiInsightAllowContext(): Promise<boolean> {
export async function setAiInsightAllowContext(allow: boolean): Promise<void> { 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 getAiInsightWhitelistEnabled(): Promise<boolean> {
const value = await config.get(CONFIG_KEYS.AI_INSIGHT_WHITELIST_ENABLED)
return value === true
}
export async function setAiInsightWhitelistEnabled(enabled: boolean): Promise<void> {
await config.set(CONFIG_KEYS.AI_INSIGHT_WHITELIST_ENABLED, enabled)
}
export async function getAiInsightWhitelist(): Promise<string[]> {
const value = await config.get(CONFIG_KEYS.AI_INSIGHT_WHITELIST)
return Array.isArray(value) ? (value as string[]) : []
}
export async function setAiInsightWhitelist(list: string[]): Promise<void> {
await config.set(CONFIG_KEYS.AI_INSIGHT_WHITELIST, list)
}