mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-04-08 15:08:44 +00:00
Merge pull request #3 from Jasonzhu1207/v0/jasonzhu081207-4751-c1e23024
Enable AI insights and whitelist management in settings
This commit is contained in:
@@ -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')
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user