一些更新

This commit is contained in:
cc
2026-04-11 19:52:40 +08:00
parent 726edfa850
commit b9af7ffc8c
11 changed files with 721 additions and 172 deletions

View File

@@ -1635,6 +1635,22 @@ function registerIpcHandlers() {
return insightService.triggerTest()
})
ipcMain.handle('insight:generateFootprintInsight', async (_, payload: {
rangeLabel: string
summary: {
private_inbound_people?: number
private_replied_people?: number
private_outbound_people?: number
private_reply_rate?: number
mention_count?: number
mention_group_count?: number
}
privateSegments?: Array<{ displayName?: string; session_id?: string; incoming_count?: number; outgoing_count?: number; message_count?: number; replied?: boolean }>
mentionGroups?: Array<{ displayName?: string; session_id?: string; count?: number }>
}) => {
return insightService.generateFootprintInsight(payload)
})
ipcMain.handle('config:clear', async () => {
if (isLaunchAtStartupSupported() && getSystemLaunchAtStartup()) {
const result = setSystemLaunchAtStartup(false)

View File

@@ -526,6 +526,19 @@ contextBridge.exposeInMainWorld('electronAPI', {
insight: {
testConnection: () => ipcRenderer.invoke('insight:testConnection'),
getTodayStats: () => ipcRenderer.invoke('insight:getTodayStats'),
triggerTest: () => ipcRenderer.invoke('insight:triggerTest')
triggerTest: () => ipcRenderer.invoke('insight:triggerTest'),
generateFootprintInsight: (payload: {
rangeLabel: string
summary: {
private_inbound_people?: number
private_replied_people?: number
private_outbound_people?: number
private_reply_rate?: number
mention_count?: number
mention_group_count?: number
}
privateSegments?: Array<{ displayName?: string; session_id?: string; incoming_count?: number; outgoing_count?: number; message_count?: number; replied?: boolean }>
mentionGroups?: Array<{ displayName?: string; session_id?: string; count?: number }>
}) => ipcRenderer.invoke('insight:generateFootprintInsight', payload)
}
})

View File

@@ -71,6 +71,9 @@ interface ConfigSchema {
exportWriteLayout: 'A' | 'B' | 'C'
// AI 见解
aiModelApiBaseUrl: string
aiModelApiKey: string
aiModelApiModel: string
aiInsightEnabled: boolean
aiInsightApiBaseUrl: string
aiInsightApiKey: string
@@ -93,10 +96,21 @@ interface ConfigSchema {
aiInsightTelegramToken: string
/** Telegram 接收 Chat ID逗号分隔支持多个 */
aiInsightTelegramChatIds: string
// AI 足迹
aiFootprintEnabled: boolean
aiFootprintSystemPrompt: string
}
// 需要 safeStorage 加密的字段(普通模式)
const ENCRYPTED_STRING_KEYS: Set<string> = new Set(['decryptKey', 'imageAesKey', 'authPassword', 'httpApiToken', 'aiInsightApiKey'])
const ENCRYPTED_STRING_KEYS: Set<string> = new Set([
'decryptKey',
'imageAesKey',
'authPassword',
'httpApiToken',
'aiModelApiKey',
'aiInsightApiKey'
])
const ENCRYPTED_BOOL_KEYS: Set<string> = new Set(['authEnabled', 'authUseHello'])
const ENCRYPTED_NUMBER_KEYS: Set<string> = new Set(['imageXorKey'])
@@ -167,6 +181,9 @@ export class ConfigService {
quoteLayout: 'quote-top',
wordCloudExcludeWords: [],
exportWriteLayout: 'A',
aiModelApiBaseUrl: '',
aiModelApiKey: '',
aiModelApiModel: 'gpt-4o-mini',
aiInsightEnabled: false,
aiInsightApiBaseUrl: '',
aiInsightApiKey: '',
@@ -181,7 +198,9 @@ export class ConfigService {
aiInsightSystemPrompt: '',
aiInsightTelegramEnabled: false,
aiInsightTelegramToken: '',
aiInsightTelegramChatIds: ''
aiInsightTelegramChatIds: '',
aiFootprintEnabled: false,
aiFootprintSystemPrompt: ''
}
const storeOptions: any = {
@@ -213,6 +232,7 @@ export class ConfigService {
}
}
this.migrateAuthFields()
this.migrateAiConfig()
}
// === 状态查询 ===
@@ -717,6 +737,26 @@ export class ConfigService {
}
}
private migrateAiConfig(): void {
const sharedBaseUrl = String(this.get('aiModelApiBaseUrl') || '').trim()
const sharedApiKey = String(this.get('aiModelApiKey') || '').trim()
const sharedModel = String(this.get('aiModelApiModel') || '').trim()
const legacyBaseUrl = String(this.get('aiInsightApiBaseUrl') || '').trim()
const legacyApiKey = String(this.get('aiInsightApiKey') || '').trim()
const legacyModel = String(this.get('aiInsightApiModel') || '').trim()
if (!sharedBaseUrl && legacyBaseUrl) {
this.set('aiModelApiBaseUrl', legacyBaseUrl)
}
if (!sharedApiKey && legacyApiKey) {
this.set('aiModelApiKey', legacyApiKey)
}
if (!sharedModel && legacyModel) {
this.set('aiModelApiModel', legacyModel)
}
}
// === 验证 ===
verifyAuthEnabled(): boolean {

View File

@@ -19,7 +19,8 @@ class ExportRecordService {
private resolveFilePath(): string {
if (this.filePath) return this.filePath
const userDataPath = app.getPath('userData')
const workerUserDataPath = String(process.env.WEFLOW_USER_DATA_PATH || process.env.WEFLOW_CONFIG_CWD || '').trim()
const userDataPath = workerUserDataPath || app?.getPath?.('userData') || process.cwd()
fs.mkdirSync(userDataPath, { recursive: true })
this.filePath = path.join(userDataPath, 'weflow-export-records.json')
return this.filePath

View File

@@ -39,6 +39,9 @@ const DEFAULT_SILENCE_DAYS = 3
const INSIGHT_CONFIG_KEYS = new Set([
'aiInsightEnabled',
'aiInsightScanIntervalHours',
'aiModelApiBaseUrl',
'aiModelApiKey',
'aiModelApiModel',
'dbPath',
'decryptKey',
'myWxid'
@@ -51,6 +54,12 @@ interface TodayTriggerRecord {
timestamps: number[]
}
interface SharedAiModelConfig {
apiBaseUrl: string
apiKey: string
model: string
}
// ─── 日志 ─────────────────────────────────────────────────────────────────────
/**
@@ -320,9 +329,7 @@ class InsightService {
* 供设置页"测试连接"按钮调用。
*/
async testConnection(): Promise<{ success: boolean; message: string }> {
const apiBaseUrl = this.config.get('aiInsightApiBaseUrl') as string
const apiKey = this.config.get('aiInsightApiKey') as string
const model = (this.config.get('aiInsightApiModel') as string) || 'gpt-4o-mini'
const { apiBaseUrl, apiKey, model } = this.getSharedAiModelConfig()
if (!apiBaseUrl || !apiKey) {
return { success: false, message: '请先填写 API 地址和 API Key' }
@@ -348,8 +355,7 @@ class InsightService {
*/
async triggerTest(): Promise<{ success: boolean; message: string }> {
insightLog('INFO', '手动触发测试见解...')
const apiBaseUrl = this.config.get('aiInsightApiBaseUrl') as string
const apiKey = this.config.get('aiInsightApiKey') as string
const { apiBaseUrl, apiKey } = this.getSharedAiModelConfig()
if (!apiBaseUrl || !apiKey) {
return { success: false, message: '请先填写 API 地址和 Key' }
}
@@ -398,12 +404,124 @@ class InsightService {
return result
}
async generateFootprintInsight(params: {
rangeLabel: string
summary: {
private_inbound_people?: number
private_replied_people?: number
private_outbound_people?: number
private_reply_rate?: number
mention_count?: number
mention_group_count?: number
}
privateSegments?: Array<{ displayName?: string; session_id?: string; incoming_count?: number; outgoing_count?: number; message_count?: number; replied?: boolean }>
mentionGroups?: Array<{ displayName?: string; session_id?: string; count?: number }>
}): Promise<{ success: boolean; message: string; insight?: string }> {
const enabled = this.config.get('aiFootprintEnabled') === true
if (!enabled) {
return { success: false, message: '请先在设置中开启「AI 足迹总结」' }
}
const { apiBaseUrl, apiKey, model } = this.getSharedAiModelConfig()
if (!apiBaseUrl || !apiKey) {
return { success: false, message: '请先填写通用 AI 模型配置API 地址和 Key' }
}
const summary = params?.summary || {}
const rangeLabel = String(params?.rangeLabel || '').trim() || '当前范围'
const privateSegments = Array.isArray(params?.privateSegments) ? params.privateSegments.slice(0, 6) : []
const mentionGroups = Array.isArray(params?.mentionGroups) ? params.mentionGroups.slice(0, 6) : []
const topPrivateText = privateSegments.length > 0
? privateSegments
.map((item, idx) => {
const name = String(item.displayName || item.session_id || `联系人${idx + 1}`).trim()
const inbound = Number(item.incoming_count) || 0
const outbound = Number(item.outgoing_count) || 0
const total = Math.max(Number(item.message_count) || 0, inbound + outbound)
return `${idx + 1}. ${name}(收${inbound}/发${outbound}/总${total}${item.replied ? '/已回复' : ''}`
})
.join('\n')
: '无'
const topMentionText = mentionGroups.length > 0
? mentionGroups
.map((item, idx) => {
const name = String(item.displayName || item.session_id || `群聊${idx + 1}`).trim()
const count = Number(item.count) || 0
return `${idx + 1}. ${name}@我 ${count} 次)`
})
.join('\n')
: '无'
const defaultSystemPrompt = `你是用户的聊天足迹教练,负责基于统计数据给出一段简明复盘。
要求:
1. 输出 2-3 句,总长度不超过 180 字。
2. 必须包含:总体观察 + 一个可执行建议。
3. 语气务实,不夸张,不使用 Markdown。`
const customPrompt = String(this.config.get('aiFootprintSystemPrompt') || '').trim()
const systemPrompt = customPrompt || defaultSystemPrompt
const userPrompt = `统计范围:${rangeLabel}
有聊天的人数:${Number(summary.private_inbound_people) || 0}
我有回复的人数:${Number(summary.private_outbound_people) || 0}
回复率:${(((Number(summary.private_reply_rate) || 0) * 100)).toFixed(1)}%
@我次数:${Number(summary.mention_count) || 0}
涉及群聊:${Number(summary.mention_group_count) || 0}
私聊重点:
${topPrivateText}
群聊@我重点:
${topMentionText}
请给出足迹复盘2-3句含建议`
try {
const result = await callApi(
apiBaseUrl,
apiKey,
model,
[
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt }
],
25_000
)
const insight = result.trim().slice(0, 400)
if (!insight) return { success: false, message: '模型返回为空' }
return { success: true, message: '生成成功', insight }
} catch (error) {
return { success: false, message: `生成失败:${(error as Error).message}` }
}
}
// ── 私有方法 ────────────────────────────────────────────────────────────────
private isEnabled(): boolean {
return this.config.get('aiInsightEnabled') === true
}
private getSharedAiModelConfig(): SharedAiModelConfig {
const apiBaseUrl = String(
this.config.get('aiModelApiBaseUrl')
|| this.config.get('aiInsightApiBaseUrl')
|| ''
).trim()
const apiKey = String(
this.config.get('aiModelApiKey')
|| this.config.get('aiInsightApiKey')
|| ''
).trim()
const model = String(
this.config.get('aiModelApiModel')
|| this.config.get('aiInsightApiModel')
|| 'gpt-4o-mini'
).trim() || 'gpt-4o-mini'
return { apiBaseUrl, apiKey, model }
}
/**
* 判断某个会话是否允许触发见解。
* 若白名单未启用,则所有私聊会话均允许;
@@ -696,9 +814,7 @@ class InsightService {
if (!sessionId) return
if (!this.isEnabled()) return
const apiBaseUrl = this.config.get('aiInsightApiBaseUrl') as string
const apiKey = this.config.get('aiInsightApiKey') as string
const model = (this.config.get('aiInsightApiModel') as string) || 'gpt-4o-mini'
const { apiBaseUrl, apiKey, model } = this.getSharedAiModelConfig()
const allowContext = this.config.get('aiInsightAllowContext') as boolean
const contextCount = (this.config.get('aiInsightContextCount') as number) || 40

View File

@@ -258,6 +258,42 @@
display: none; /* Minimalistic, hide icon in KPI */
}
.footprint-ai-result {
border-radius: 10px;
padding: 14px 16px;
background: color-mix(in srgb, var(--text-tertiary) 8%, transparent);
border: 1px solid color-mix(in srgb, var(--border-color) 40%, transparent);
.footprint-ai-head {
display: flex;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
strong {
font-size: 14px;
color: var(--text-primary);
}
span {
font-size: 12px;
color: var(--text-tertiary);
}
}
p {
margin: 0;
white-space: pre-wrap;
line-height: 1.6;
color: var(--text-secondary);
font-size: 13px;
}
&.footprint-ai-result-error {
border-color: color-mix(in srgb, #ef4444 50%, transparent);
}
}
.kpi-label {
font-size: 13px;
font-weight: 500;

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { useNavigate } from 'react-router-dom'
import { AlertCircle, AtSign, CheckCircle2, Download, MessageCircle, RefreshCw, Search, Users } from 'lucide-react'
import { AlertCircle, AtSign, CheckCircle2, Download, Loader2, MessageCircle, RefreshCw, Search, Sparkles, Users } from 'lucide-react'
import DateRangePicker from '../components/DateRangePicker'
import './MyFootprintPage.scss'
@@ -9,6 +9,7 @@ type TimelineMode = 'all' | 'mention' | 'private'
type TimelineTimeMode = 'clock' | 'month_day_clock' | 'full_date_clock'
type PrivateDotVariant = 'both' | 'inbound_only' | 'outbound_only'
type ExportModalStatus = 'idle' | 'progress' | 'success' | 'error'
type FootprintAiStatus = 'idle' | 'loading' | 'success' | 'error'
interface MyFootprintSummary {
private_inbound_people: number
@@ -336,6 +337,8 @@ function MyFootprintPage() {
const [exportModalDescription, setExportModalDescription] = useState('')
const [exportModalPath, setExportModalPath] = useState('')
const [error, setError] = useState<string | null>(null)
const [footprintAiStatus, setFootprintAiStatus] = useState<FootprintAiStatus>('idle')
const [footprintAiText, setFootprintAiText] = useState('')
const inflightRangeKeyRef = useRef<string | null>(null)
const currentRange = useMemo(() => buildRange(preset, customStartDate, customEndDate), [preset, customStartDate, customEndDate])
@@ -638,6 +641,41 @@ function MyFootprintPage() {
}
}, [currentRange.begin, currentRange.end, currentRange.label])
const handleGenerateAiSummary = useCallback(async () => {
setFootprintAiStatus('loading')
setFootprintAiText('')
try {
const privateSegments = (data.private_segments.length > 0 ? data.private_segments : data.private_sessions).slice(0, 12)
const result = await window.electronAPI.insight.generateFootprintInsight({
rangeLabel: currentRange.label,
summary: data.summary,
privateSegments: privateSegments.map((item: MyFootprintPrivateSegment | MyFootprintPrivateSession) => ({
session_id: item.session_id,
displayName: item.displayName,
incoming_count: item.incoming_count,
outgoing_count: item.outgoing_count,
message_count: 'message_count' in item ? item.message_count : item.incoming_count + item.outgoing_count,
replied: item.replied
})),
mentionGroups: data.mention_groups.slice(0, 12).map((item) => ({
session_id: item.session_id,
displayName: item.displayName,
count: item.count
}))
})
if (!result.success || !result.insight) {
setFootprintAiStatus('error')
setFootprintAiText(result.message || '生成失败')
return
}
setFootprintAiStatus('success')
setFootprintAiText(result.insight)
} catch (generateError) {
setFootprintAiStatus('error')
setFootprintAiText(String(generateError))
}
}, [currentRange.label, data])
return (
<div className="my-footprint-page">
<section className="footprint-header">
@@ -690,6 +728,10 @@ function MyFootprintPage() {
<RefreshCw size={15} className={loading ? 'spin' : ''} />
<span></span>
</button>
<button type="button" className="action-btn" onClick={() => void handleGenerateAiSummary()} disabled={loading || footprintAiStatus === 'loading'}>
{footprintAiStatus === 'loading' ? <Loader2 size={15} className="spin" /> : <Sparkles size={15} />}
<span>{footprintAiStatus === 'loading' ? '生成中...' : 'AI 总结'}</span>
</button>
<button type="button" className="action-btn" onClick={() => void handleExport('csv')} disabled={exporting || loading}>
<Download size={15} />
<span> CSV</span>
@@ -749,6 +791,16 @@ function MyFootprintPage() {
</button>
</section>
{footprintAiStatus !== 'idle' && (
<section className={`footprint-ai-result footprint-ai-result-${footprintAiStatus}`}>
<div className="footprint-ai-head">
<strong>AI </strong>
<span>{currentRange.label}</span>
</div>
<p>{footprintAiText}</p>
</section>
)}
<section
className={`footprint-timeline timeline-time-${timelineTimeMode}`}
key={`${timelineMode}:${currentRange.begin}:${currentRange.end}`}

View File

@@ -177,6 +177,66 @@
box-shadow: var(--shadow-sm);
}
}
.tab-group {
display: flex;
flex-direction: column;
gap: 4px;
}
.tab-group-trigger {
position: relative;
}
.tab-group-arrow {
margin-left: auto;
color: var(--text-tertiary);
transition: transform 0.2s ease;
&.expanded {
transform: rotate(180deg);
}
}
.tab-sublist {
display: flex;
flex-direction: column;
gap: 4px;
padding-left: 8px;
}
.tab-sublist-wrap {
display: grid;
grid-template-rows: 0fr;
opacity: 0;
transition: grid-template-rows 0.22s ease, opacity 0.18s ease;
&.expanded {
grid-template-rows: 1fr;
opacity: 1;
}
&.collapsed {
pointer-events: none;
}
}
.tab-sublist {
min-height: 0;
overflow: hidden;
}
.tab-sub-btn {
padding-left: 24px;
font-size: 13px;
}
.tab-sub-dot {
width: 5px;
height: 5px;
border-radius: 999px;
background: color-mix(in srgb, var(--text-tertiary) 70%, transparent);
}
}
.settings-body {
@@ -199,6 +259,12 @@
}
}
.ai-prompt-textarea {
font-family: inherit !important;
font-size: 14px !important;
line-height: 1.6;
}
.tab-content {
background: var(--bg-secondary);
border: 1px solid var(--border-color);

View File

@@ -16,9 +16,23 @@ import {
import { Avatar } from '../components/Avatar'
import './SettingsPage.scss'
type SettingsTab = 'appearance' | 'notification' | 'antiRevoke' | 'database' | 'models' | 'cache' | 'api' | 'updates' | 'security' | 'about' | 'analytics' | 'insight'
type SettingsTab =
| 'appearance'
| 'notification'
| 'antiRevoke'
| 'database'
| 'models'
| 'cache'
| 'api'
| 'updates'
| 'security'
| 'about'
| 'analytics'
| 'aiCommon'
| 'insight'
| 'aiFootprint'
const tabs: { id: SettingsTab; label: string; icon: React.ElementType }[] = [
const tabs: { id: Exclude<SettingsTab, 'insight' | 'aiFootprint'>; label: string; icon: React.ElementType }[] = [
{ id: 'appearance', label: '外观', icon: Palette },
{ id: 'notification', label: '通知', icon: Bell },
{ id: 'antiRevoke', label: '防撤回', icon: RotateCcw },
@@ -27,12 +41,17 @@ const tabs: { id: SettingsTab; label: string; icon: React.ElementType }[] = [
{ id: 'cache', label: '缓存', icon: HardDrive },
{ id: 'api', label: 'API 服务', icon: Globe },
{ id: 'analytics', label: '分析', icon: BarChart2 },
{ id: 'insight', label: 'AI 见解', icon: Sparkles },
{ id: 'security', label: '安全', icon: ShieldCheck },
{ id: 'updates', label: '版本更新', icon: RefreshCw },
{ id: 'about', label: '关于', icon: Info }
]
const aiTabs: Array<{ id: Extract<SettingsTab, 'aiCommon' | 'insight' | 'aiFootprint'>; label: string }> = [
{ id: 'aiCommon', label: 'AI 通用' },
{ id: 'insight', label: 'AI 见解' },
{ id: 'aiFootprint', label: 'AI 足迹' }
]
const isMac = navigator.userAgent.toLowerCase().includes('mac')
const isLinux = navigator.userAgent.toLowerCase().includes('linux')
const isWindows = !isMac && !isLinux
@@ -88,6 +107,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
const clearAnalyticsStoreCache = useAnalyticsStore((state) => state.clearCache)
const [activeTab, setActiveTab] = useState<SettingsTab>('appearance')
const [aiGroupExpanded, setAiGroupExpanded] = useState(false)
const [decryptKey, setDecryptKey] = useState('')
const [imageXorKey, setImageXorKey] = useState('')
const [imageAesKey, setImageAesKey] = useState('')
@@ -217,9 +237,9 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
// AI 见解 state
const [aiInsightEnabled, setAiInsightEnabled] = useState(false)
const [aiInsightApiBaseUrl, setAiInsightApiBaseUrl] = useState('')
const [aiInsightApiKey, setAiInsightApiKey] = useState('')
const [aiInsightApiModel, setAiInsightApiModel] = useState('gpt-4o-mini')
const [aiModelApiBaseUrl, setAiModelApiBaseUrl] = useState('')
const [aiModelApiKey, setAiModelApiKey] = useState('')
const [aiModelApiModel, setAiModelApiModel] = useState('gpt-4o-mini')
const [aiInsightSilenceDays, setAiInsightSilenceDays] = useState(3)
const [aiInsightAllowContext, setAiInsightAllowContext] = useState(false)
const [isTestingInsight, setIsTestingInsight] = useState(false)
@@ -237,6 +257,8 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
const [aiInsightTelegramEnabled, setAiInsightTelegramEnabled] = useState(false)
const [aiInsightTelegramToken, setAiInsightTelegramToken] = useState('')
const [aiInsightTelegramChatIds, setAiInsightTelegramChatIds] = useState('')
const [aiFootprintEnabled, setAiFootprintEnabled] = useState(false)
const [aiFootprintSystemPrompt, setAiFootprintSystemPrompt] = useState('')
// 检查 Hello 可用性
useEffect(() => {
@@ -276,6 +298,12 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
setActiveTab(initialTab)
}, [location.state])
useEffect(() => {
if (activeTab === 'aiCommon' || activeTab === 'insight' || activeTab === 'aiFootprint') {
setAiGroupExpanded(true)
}
}, [activeTab])
useEffect(() => {
if (!onClose) return
const handleKeyDown = (event: KeyboardEvent) => {
@@ -448,9 +476,9 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
// 加载 AI 见解配置
const savedAiInsightEnabled = await configService.getAiInsightEnabled()
const savedAiInsightApiBaseUrl = await configService.getAiInsightApiBaseUrl()
const savedAiInsightApiKey = await configService.getAiInsightApiKey()
const savedAiInsightApiModel = await configService.getAiInsightApiModel()
const savedAiModelApiBaseUrl = await configService.getAiModelApiBaseUrl()
const savedAiModelApiKey = await configService.getAiModelApiKey()
const savedAiModelApiModel = await configService.getAiModelApiModel()
const savedAiInsightSilenceDays = await configService.getAiInsightSilenceDays()
const savedAiInsightAllowContext = await configService.getAiInsightAllowContext()
const savedAiInsightWhitelistEnabled = await configService.getAiInsightWhitelistEnabled()
@@ -462,10 +490,12 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
const savedAiInsightTelegramEnabled = await configService.getAiInsightTelegramEnabled()
const savedAiInsightTelegramToken = await configService.getAiInsightTelegramToken()
const savedAiInsightTelegramChatIds = await configService.getAiInsightTelegramChatIds()
const savedAiFootprintEnabled = await configService.getAiFootprintEnabled()
const savedAiFootprintSystemPrompt = await configService.getAiFootprintSystemPrompt()
setAiInsightEnabled(savedAiInsightEnabled)
setAiInsightApiBaseUrl(savedAiInsightApiBaseUrl)
setAiInsightApiKey(savedAiInsightApiKey)
setAiInsightApiModel(savedAiInsightApiModel)
setAiModelApiBaseUrl(savedAiModelApiBaseUrl)
setAiModelApiKey(savedAiModelApiKey)
setAiModelApiModel(savedAiModelApiModel)
setAiInsightSilenceDays(savedAiInsightSilenceDays)
setAiInsightAllowContext(savedAiInsightAllowContext)
setAiInsightWhitelistEnabled(savedAiInsightWhitelistEnabled)
@@ -477,6 +507,8 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
setAiInsightTelegramEnabled(savedAiInsightTelegramEnabled)
setAiInsightTelegramToken(savedAiInsightTelegramToken)
setAiInsightTelegramChatIds(savedAiInsightTelegramChatIds)
setAiFootprintEnabled(savedAiFootprintEnabled)
setAiFootprintSystemPrompt(savedAiFootprintSystemPrompt)
} catch (e: any) {
console.error('加载配置失败:', e)
@@ -2498,6 +2530,118 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
}
}
const renderAiCommonTab = () => (
<div className="tab-content">
<div className="form-group">
<label> API </label>
<span className="form-hint">
AI AI OpenAI <strong>Base URL</strong><strong></strong>
<code>/chat/completions</code>
<br />
<code>https://api.ohmygpt.com/v1</code> 或 <code>https://api.openai.com/v1</code>
</span>
<input
type="text"
className="field-input"
value={aiModelApiBaseUrl}
placeholder="https://api.ohmygpt.com/v1"
onChange={(e) => {
const val = e.target.value
setAiModelApiBaseUrl(val)
scheduleConfigSave('aiModelApiBaseUrl', () => configService.setAiModelApiBaseUrl(val))
}}
/>
</div>
<div className="form-group">
<label> API Key</label>
<span className="form-hint">
API Key
</span>
<div style={{ display: 'flex', gap: '8px', marginTop: '8px' }}>
<input
type={showInsightApiKey ? 'text' : 'password'}
className="field-input"
value={aiModelApiKey}
placeholder="sk-..."
onChange={(e) => {
const val = e.target.value
setAiModelApiKey(val)
scheduleConfigSave('aiModelApiKey', () => configService.setAiModelApiKey(val))
}}
style={{ flex: 1 }}
/>
<button
className="btn btn-secondary"
onClick={() => setShowInsightApiKey(!showInsightApiKey)}
title={showInsightApiKey ? '隐藏' : '显示'}
>
{showInsightApiKey ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
{aiModelApiKey && (
<button
className="btn btn-danger"
onClick={async () => {
setAiModelApiKey('')
await configService.setAiModelApiKey('')
}}
title="清除 Key"
>
<Trash2 size={14} />
</button>
)}
</div>
</div>
<div className="form-group">
<label></label>
<span className="form-hint">
API
<br />
<code>gpt-4o-mini</code><code>gpt-4o</code><code>deepseek-chat</code><code>claude-3-5-haiku-20241022</code>
</span>
<input
type="text"
className="field-input"
value={aiModelApiModel}
placeholder="gpt-4o-mini"
onChange={(e) => {
const val = e.target.value.trim() || 'gpt-4o-mini'
setAiModelApiModel(val)
scheduleConfigSave('aiModelApiModel', () => configService.setAiModelApiModel(val))
}}
style={{ width: 260 }}
/>
</div>
<div className="form-group">
<label></label>
<span className="form-hint">
使
</span>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', flexWrap: 'wrap', marginTop: '10px' }}>
<button
className="btn btn-secondary"
onClick={handleTestInsightConnection}
disabled={isTestingInsight || !aiModelApiBaseUrl || !aiModelApiKey}
>
{isTestingInsight ? (
<><Loader2 size={14} style={{ marginRight: 4, animation: 'spin 1s linear infinite' }} />...</>
) : (
<> API </>
)}
</button>
{insightTestResult && (
<span style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13, color: insightTestResult.success ? 'var(--color-success, #22c55e)' : 'var(--color-danger, #ef4444)' }}>
{insightTestResult.success ? <CheckCircle2 size={14} /> : <XCircle size={14} />}
{insightTestResult.message}
</span>
)}
</div>
</div>
</div>
)
const renderInsightTab = () => (
<div className="tab-content">
{/* 总开关 */}
@@ -2526,119 +2670,12 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
<div className="divider" />
{/* API 配置 */}
<div className="form-group">
<label>API </label>
<span className="form-hint">
OpenAI <strong>Base URL</strong><strong></strong>
<code>/chat/completions</code>
<br />
<code>https://api.ohmygpt.com/v1</code> 或 <code>https://api.openai.com/v1</code>
</span>
<input
type="text"
className="field-input"
value={aiInsightApiBaseUrl}
placeholder="https://api.ohmygpt.com/v1"
onChange={(e) => {
const val = e.target.value
setAiInsightApiBaseUrl(val)
scheduleConfigSave('aiInsightApiBaseUrl', () => configService.setAiInsightApiBaseUrl(val))
}}
style={{ fontFamily: 'monospace' }}
/>
</div>
<div className="form-group">
<label>API Key</label>
<span className="form-hint">
API Key
</span>
<div style={{ display: 'flex', gap: '8px', marginTop: '8px' }}>
<input
type={showInsightApiKey ? 'text' : 'password'}
className="field-input"
value={aiInsightApiKey}
placeholder="sk-..."
onChange={(e) => {
const val = e.target.value
setAiInsightApiKey(val)
scheduleConfigSave('aiInsightApiKey', () => configService.setAiInsightApiKey(val))
}}
style={{ flex: 1, fontFamily: 'monospace' }}
/>
<button
className="btn btn-secondary"
onClick={() => setShowInsightApiKey(!showInsightApiKey)}
title={showInsightApiKey ? '隐藏' : '显示'}
>
{showInsightApiKey ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
{aiInsightApiKey && (
<button
className="btn btn-danger"
onClick={async () => {
setAiInsightApiKey('')
await configService.setAiInsightApiKey('')
}}
title="清除 Key"
>
<Trash2 size={14} />
</button>
)}
</div>
</div>
<div className="form-group">
<label></label>
<span className="form-hint">
API 使
<br />
<code>gpt-4o-mini</code><code>gpt-4o</code><code>deepseek-chat</code><code>claude-3-5-haiku-20241022</code>
</span>
<input
type="text"
className="field-input"
value={aiInsightApiModel}
placeholder="gpt-4o-mini"
onChange={(e) => {
const val = e.target.value.trim() || 'gpt-4o-mini'
setAiInsightApiModel(val)
scheduleConfigSave('aiInsightApiModel', () => configService.setAiInsightApiModel(val))
}}
style={{ width: 260, fontFamily: 'monospace' }}
/>
</div>
{/* 测试连接 + 触发测试 */}
<div className="form-group">
<label></label>
<span className="form-hint">
"测试 API 连接" Key URL "立即触发测试见解"API
AI API
</span>
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px', marginTop: '10px' }}>
{/* 测试 API 连接 */}
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', flexWrap: 'wrap' }}>
<button
className="btn btn-secondary"
onClick={handleTestInsightConnection}
disabled={isTestingInsight || !aiInsightApiBaseUrl || !aiInsightApiKey}
>
{isTestingInsight ? (
<><Loader2 size={14} style={{ marginRight: 4, animation: 'spin 1s linear infinite' }} />...</>
) : (
<> API </>
)}
</button>
{insightTestResult && (
<span style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13, color: insightTestResult.success ? 'var(--color-success, #22c55e)' : 'var(--color-danger, #ef4444)' }}>
{insightTestResult.success ? <CheckCircle2 size={14} /> : <XCircle size={14} />}
{insightTestResult.message}
</span>
)}
</div>
{/* 触发测试见解 */}
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', flexWrap: 'wrap' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', flexWrap: 'wrap', marginTop: '10px' }}>
<button
className="btn btn-secondary"
onClick={async () => {
@@ -2653,7 +2690,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
setIsTriggeringInsightTest(false)
}
}}
disabled={isTriggeringInsightTest || !aiInsightEnabled || !aiInsightApiBaseUrl || !aiInsightApiKey}
disabled={isTriggeringInsightTest || !aiInsightEnabled || !aiModelApiBaseUrl || !aiModelApiKey}
title={!aiInsightEnabled ? '请先开启 AI 见解总开关' : ''}
>
{isTriggeringInsightTest ? (
@@ -2670,7 +2707,6 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
)}
</div>
</div>
</div>
<div className="divider" />
@@ -2824,9 +2860,9 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
</span>
<textarea
className="field-input"
className="field-input ai-prompt-textarea"
rows={8}
style={{ width: '100%', resize: 'vertical', fontFamily: 'monospace', fontSize: 12 }}
style={{ width: '100%', resize: 'vertical' }}
value={displayValue}
onChange={(e) => {
const val = e.target.value
@@ -3106,6 +3142,74 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
</div>
)
const renderAiFootprintTab = () => (
<div className="tab-content">
{(() => {
const DEFAULT_FOOTPRINT_PROMPT = `你是用户的聊天足迹教练,负责基于统计数据给出一段简明复盘。
要求:
1. 输出 2-3 句,总长度不超过 180 字。
2. 必须包含:总体观察 + 一个可执行建议。
3. 语气务实,不夸张,不使用 Markdown。`
const displayValue = aiFootprintSystemPrompt || DEFAULT_FOOTPRINT_PROMPT
return (
<>
<div className="form-group">
<label>AI </label>
<span className="form-hint">
AI
</span>
<div className="log-toggle-line">
<span className="log-status">{aiFootprintEnabled ? '已开启' : '已关闭'}</span>
<label className="switch">
<input
type="checkbox"
checked={aiFootprintEnabled}
onChange={async (e) => {
const val = e.target.checked
setAiFootprintEnabled(val)
await configService.setAiFootprintEnabled(val)
}}
/>
<span className="switch-slider" />
</label>
</div>
</div>
<div className="form-group">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 6 }}>
<label style={{ marginBottom: 0 }}></label>
<button
className="button-secondary"
style={{ fontSize: 12, padding: '3px 10px' }}
onClick={async () => {
setAiFootprintSystemPrompt('')
await configService.setAiFootprintSystemPrompt('')
}}
>
</button>
</div>
<span className="form-hint">
使
</span>
<textarea
className="field-input ai-prompt-textarea"
rows={6}
style={{ width: '100%', resize: 'vertical' }}
value={displayValue}
onChange={(e) => {
const val = e.target.value
setAiFootprintSystemPrompt(val)
scheduleConfigSave('aiFootprintSystemPrompt', () => configService.setAiFootprintSystemPrompt(val))
}}
/>
</div>
</>
)
})()}
</div>
)
const renderApiTab = () => (
<div className="tab-content">
<div className="form-group">
@@ -3780,6 +3884,33 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
<span>{tab.label}</span>
</button>
))}
<div className={`tab-group ${aiGroupExpanded ? 'expanded' : ''}`}>
<button
className={`tab-btn tab-group-trigger ${(activeTab === 'aiCommon' || activeTab === 'insight' || activeTab === 'aiFootprint') ? 'active' : ''}`}
onClick={() => setAiGroupExpanded((prev) => !prev)}
aria-expanded={aiGroupExpanded}
>
<Sparkles size={16} />
<span>AI </span>
<ChevronDown size={14} className={`tab-group-arrow ${aiGroupExpanded ? 'expanded' : ''}`} />
</button>
<div className={`tab-sublist-wrap ${aiGroupExpanded ? 'expanded' : 'collapsed'}`}>
<div className="tab-sublist">
{aiTabs.map((tab) => (
<button
key={tab.id}
className={`tab-btn tab-sub-btn ${activeTab === tab.id ? 'active' : ''}`}
onClick={() => setActiveTab(tab.id)}
tabIndex={aiGroupExpanded ? 0 : -1}
>
<span className="tab-sub-dot" />
<span>{tab.label}</span>
</button>
))}
</div>
</div>
</div>
</div>
<div className="settings-body">
@@ -3790,7 +3921,9 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
{activeTab === 'models' && renderModelsTab()}
{activeTab === 'cache' && renderCacheTab()}
{activeTab === 'api' && renderApiTab()}
{activeTab === 'aiCommon' && renderAiCommonTab()}
{activeTab === 'insight' && renderInsightTab()}
{activeTab === 'aiFootprint' && renderAiFootprintTab()}
{activeTab === 'updates' && renderUpdatesTab()}
{activeTab === 'analytics' && renderAnalyticsTab()}
{activeTab === 'security' && renderSecurityTab()}

View File

@@ -83,6 +83,9 @@ export const CONFIG_KEYS = {
ANALYTICS_DENY_COUNT: 'analyticsDenyCount',
// AI 见解
AI_MODEL_API_BASE_URL: 'aiModelApiBaseUrl',
AI_MODEL_API_KEY: 'aiModelApiKey',
AI_MODEL_API_MODEL: 'aiModelApiModel',
AI_INSIGHT_ENABLED: 'aiInsightEnabled',
AI_INSIGHT_API_BASE_URL: 'aiInsightApiBaseUrl',
AI_INSIGHT_API_KEY: 'aiInsightApiKey',
@@ -97,7 +100,11 @@ export const CONFIG_KEYS = {
AI_INSIGHT_SYSTEM_PROMPT: 'aiInsightSystemPrompt',
AI_INSIGHT_TELEGRAM_ENABLED: 'aiInsightTelegramEnabled',
AI_INSIGHT_TELEGRAM_TOKEN: 'aiInsightTelegramToken',
AI_INSIGHT_TELEGRAM_CHAT_IDS: 'aiInsightTelegramChatIds'
AI_INSIGHT_TELEGRAM_CHAT_IDS: 'aiInsightTelegramChatIds',
// AI 足迹
AI_FOOTPRINT_ENABLED: 'aiFootprintEnabled',
AI_FOOTPRINT_SYSTEM_PROMPT: 'aiFootprintSystemPrompt'
} as const
export interface WxidConfig {
@@ -1586,6 +1593,39 @@ export async function setHttpApiHost(host: string): Promise<void> {
// ─── AI 见解 ──────────────────────────────────────────────────────────────────
export async function getAiModelApiBaseUrl(): Promise<string> {
const value = await config.get(CONFIG_KEYS.AI_MODEL_API_BASE_URL)
if (typeof value === 'string' && value.trim()) return value
const legacy = await config.get(CONFIG_KEYS.AI_INSIGHT_API_BASE_URL)
return typeof legacy === 'string' ? legacy : ''
}
export async function setAiModelApiBaseUrl(url: string): Promise<void> {
await config.set(CONFIG_KEYS.AI_MODEL_API_BASE_URL, url)
}
export async function getAiModelApiKey(): Promise<string> {
const value = await config.get(CONFIG_KEYS.AI_MODEL_API_KEY)
if (typeof value === 'string' && value.trim()) return value
const legacy = await config.get(CONFIG_KEYS.AI_INSIGHT_API_KEY)
return typeof legacy === 'string' ? legacy : ''
}
export async function setAiModelApiKey(key: string): Promise<void> {
await config.set(CONFIG_KEYS.AI_MODEL_API_KEY, key)
}
export async function getAiModelApiModel(): Promise<string> {
const value = await config.get(CONFIG_KEYS.AI_MODEL_API_MODEL)
if (typeof value === 'string' && value.trim()) return value.trim()
const legacy = await config.get(CONFIG_KEYS.AI_INSIGHT_API_MODEL)
return typeof legacy === 'string' && legacy.trim() ? legacy.trim() : 'gpt-4o-mini'
}
export async function setAiModelApiModel(model: string): Promise<void> {
await config.set(CONFIG_KEYS.AI_MODEL_API_MODEL, model)
}
export async function getAiInsightEnabled(): Promise<boolean> {
const value = await config.get(CONFIG_KEYS.AI_INSIGHT_ENABLED)
return value === true
@@ -1596,30 +1636,30 @@ export async function setAiInsightEnabled(enabled: boolean): Promise<void> {
}
export async function getAiInsightApiBaseUrl(): Promise<string> {
const value = await config.get(CONFIG_KEYS.AI_INSIGHT_API_BASE_URL)
return typeof value === 'string' ? value : ''
return getAiModelApiBaseUrl()
}
export async function setAiInsightApiBaseUrl(url: string): Promise<void> {
await config.set(CONFIG_KEYS.AI_INSIGHT_API_BASE_URL, url)
await setAiModelApiBaseUrl(url)
}
export async function getAiInsightApiKey(): Promise<string> {
const value = await config.get(CONFIG_KEYS.AI_INSIGHT_API_KEY)
return typeof value === 'string' ? value : ''
return getAiModelApiKey()
}
export async function setAiInsightApiKey(key: string): Promise<void> {
await config.set(CONFIG_KEYS.AI_INSIGHT_API_KEY, key)
await setAiModelApiKey(key)
}
export async function getAiInsightApiModel(): Promise<string> {
const value = await config.get(CONFIG_KEYS.AI_INSIGHT_API_MODEL)
return typeof value === 'string' && value.trim() ? value.trim() : 'gpt-4o-mini'
return getAiModelApiModel()
}
export async function setAiInsightApiModel(model: string): Promise<void> {
await config.set(CONFIG_KEYS.AI_INSIGHT_API_MODEL, model)
await setAiModelApiModel(model)
}
export async function getAiInsightSilenceDays(): Promise<number> {
@@ -1720,3 +1760,21 @@ export async function getAiInsightTelegramChatIds(): Promise<string> {
export async function setAiInsightTelegramChatIds(chatIds: string): Promise<void> {
await config.set(CONFIG_KEYS.AI_INSIGHT_TELEGRAM_CHAT_IDS, chatIds)
}
export async function getAiFootprintEnabled(): Promise<boolean> {
const value = await config.get(CONFIG_KEYS.AI_FOOTPRINT_ENABLED)
return value === true
}
export async function setAiFootprintEnabled(enabled: boolean): Promise<void> {
await config.set(CONFIG_KEYS.AI_FOOTPRINT_ENABLED, enabled)
}
export async function getAiFootprintSystemPrompt(): Promise<string> {
const value = await config.get(CONFIG_KEYS.AI_FOOTPRINT_SYSTEM_PROMPT)
return typeof value === 'string' ? value : ''
}
export async function setAiFootprintSystemPrompt(prompt: string): Promise<void> {
await config.set(CONFIG_KEYS.AI_FOOTPRINT_SYSTEM_PROMPT, prompt)
}

View File

@@ -1075,6 +1075,24 @@ export interface ElectronAPI {
stop: () => Promise<{ success: boolean }>
status: () => Promise<{ running: boolean; port: number; mediaExportPath: string }>
}
insight: {
testConnection: () => Promise<{ success: boolean; message: string }>
getTodayStats: () => Promise<Array<{ sessionId: string; count: number; times: string[] }>>
triggerTest: () => Promise<{ success: boolean; message: string }>
generateFootprintInsight: (payload: {
rangeLabel: string
summary: {
private_inbound_people?: number
private_replied_people?: number
private_outbound_people?: number
private_reply_rate?: number
mention_count?: number
mention_group_count?: number
}
privateSegments?: Array<{ displayName?: string; session_id?: string; incoming_count?: number; outgoing_count?: number; message_count?: number; replied?: boolean }>
mentionGroups?: Array<{ displayName?: string; session_id?: string; count?: number }>
}) => Promise<{ success: boolean; message: string; insight?: string }>
}
}
export interface ExportOptions {