Merge pull request #9 from Jasonzhu1207/v0/jasonzhu081207-4751-0177d73e

Enable AI insights and Telegram push notifications
This commit is contained in:
Jason
2026-04-06 18:13:03 +08:00
committed by GitHub
4 changed files with 253 additions and 24 deletions

View File

@@ -85,6 +85,14 @@ interface ConfigSchema {
aiInsightScanIntervalHours: number aiInsightScanIntervalHours: number
/** 发送上下文时的最大消息条数 */ /** 发送上下文时的最大消息条数 */
aiInsightContextCount: number aiInsightContextCount: number
/** 自定义 system prompt空字符串表示使用内置默认值 */
aiInsightSystemPrompt: string
/** 是否启用 Telegram 推送 */
aiInsightTelegramEnabled: boolean
/** Telegram Bot Token */
aiInsightTelegramToken: string
/** Telegram 接收 Chat ID逗号分隔支持多个 */
aiInsightTelegramChatIds: string
} }
// 需要 safeStorage 加密的字段(普通模式) // 需要 safeStorage 加密的字段(普通模式)
@@ -169,7 +177,11 @@ export class ConfigService {
aiInsightWhitelist: [], aiInsightWhitelist: [],
aiInsightCooldownMinutes: 120, aiInsightCooldownMinutes: 120,
aiInsightScanIntervalHours: 4, aiInsightScanIntervalHours: 4,
aiInsightContextCount: 40 aiInsightContextCount: 40,
aiInsightSystemPrompt: '',
aiInsightTelegramEnabled: false,
aiInsightTelegramToken: '',
aiInsightTelegramChatIds: ''
} }
const storeOptions: any = { const storeOptions: any = {

View File

@@ -413,7 +413,7 @@ class InsightService {
} }
/** /**
* 获取今日全局已触发次数(所有会话合计),用于 prompt 中告知模全局上下文。 * 获取今日全局已触发次数(所有会话合计),用于 prompt 中告知模<EFBFBD><EFBFBD><EFBFBD>全局上下文。
*/ */
private getTodayTotalTriggerCount(): number { private getTodayTotalTriggerCount(): number {
this.resetIfNewDay() this.resetIfNewDay()
@@ -611,7 +611,7 @@ class InsightService {
return return
} }
// ── 构建 prompt ─────────────<E29480><E29480><EFBFBD>─────────────────────────────────────────── // ── 构建 prompt ─────────────<E29480><E29480><EFBFBD>───────────────────────────────<EFBFBD><EFBFBD><EFBFBD>────────────
// 今日触发统计(让模型具备时间与克制感) // 今日触发统计(让模型具备时间与克制感)
const sessionTriggerTimes = this.recordTrigger(sessionId) const sessionTriggerTimes = this.recordTrigger(sessionId)
@@ -637,6 +637,21 @@ class InsightService {
} }
} }
// ── 默认 system prompt稳定内容有利于 provider 端 prompt cache 命中)────
const DEFAULT_SYSTEM_PROMPT = `你是用户的私人关系观察助手,名叫"见解"。你的任务是主动提供有价值的观察和建议。
要求:
1. 必须给出见解。基于聊天记录分析对方情绪、话题趋势、关系动态,或给出回复建议、聊天话题推荐。
2. 控制在 80 字以内,直接、具体、一针见血。不要废话。
3. 输出纯文本,不使用 Markdown。
4. 只有在完全没有任何可说的内容时(比如对话只有一条"嗯"),才回复"SKIP"。绝大多数情况下你应该输出见解。`
// 优先使用用户自定义 prompt为空则使用默认值
const customPrompt = (this.config.get('aiInsightSystemPrompt') as string) || ''
const systemPrompt = customPrompt.trim() || DEFAULT_SYSTEM_PROMPT
// 可变的上下文统计信息放在 user message 里,保持 system prompt 稳定不变
// 这样 provider 端Anthropic/OpenAI能最大化命中 prompt cache降低费用
const triggerDesc = const triggerDesc =
triggerReason === 'silence' triggerReason === 'silence'
? `你已经 ${silentDays} 天没有和「${displayName}」聊天了。` ? `你已经 ${silentDays} 天没有和「${displayName}」聊天了。`
@@ -644,22 +659,13 @@ class InsightService {
const todayStatsDesc = const todayStatsDesc =
sessionTriggerTimes.length > 1 sessionTriggerTimes.length > 1
? `今天你已经针对「${displayName}」收到过 ${sessionTriggerTimes.length - 1} 条见解(时间:${sessionTriggerTimes.slice(0, -1).join('、')}),请适当克制,避免过度打扰` ? `今天你已经针对「${displayName}」收到过 ${sessionTriggerTimes.length - 1} 条见解(时间:${sessionTriggerTimes.slice(0, -1).join('、')}),请适当克制。`
: `今天你还没有针对「${displayName}」发出过见解。` : `今天你还没有针对「${displayName}」发出过见解。`
const globalStatsDesc = `今天全部联系人合计已触发 ${totalTodayTriggers} 条见解。` const globalStatsDesc = `今天全部联系人合计已触发 ${totalTodayTriggers} 条见解。`
const systemPrompt = `你是用户的私人关系观察助手,名叫"见解"。你的任务是主动提供有价值的观察和建议。 const userPrompt = `触发原因:${triggerDesc}
时间统计:${todayStatsDesc} ${globalStatsDesc}${contextSection}
要求:
1. 必须给出见解。基于聊天记录分析对方情绪、话题趋势、关系动态,或给出回复建议、聊天话题推荐。
2. 控制在 80 字以内,直接、具体、一针见血。不要废话。
3. 输出纯文本,不使用 Markdown。
4. 只有在完全没有任何可说的内容时(比如对话只有一条"嗯"),才回复"SKIP"。绝大多数情况下你应该输出见解。
时间感知:${todayStatsDesc} ${globalStatsDesc}`
const userPrompt = `触发原因:${triggerDesc}${contextSection}
请给出你的见解≤80字` 请给出你的见解≤80字`
@@ -686,26 +692,80 @@ class InsightService {
} }
const insight = result.slice(0, 120) const insight = result.slice(0, 120)
const notifTitle = `见解 · ${displayName}`
insightLog('INFO', `推送通知 → ${displayName}: ${insight}`) insightLog('INFO', `推送通知 → ${displayName}: ${insight}`)
// 使用 Electron 原生系统通知,确保 Windows 右下角弹窗可靠显示 // 渠道一:Electron 原生系统通知
if (Notification.isSupported()) { if (Notification.isSupported()) {
const notif = new Notification({ const notif = new Notification({ title: notifTitle, body: insight, silent: false })
title: `见解 · ${displayName}`,
body: insight,
silent: false
})
notif.show() notif.show()
} else { } else {
insightLog('WARN', '当前系统不支持原生通知') insightLog('WARN', '当前系统不支持原生通知')
} }
// 渠道二Telegram Bot 推送(可选)
const telegramEnabled = this.config.get('aiInsightTelegramEnabled') as boolean
if (telegramEnabled) {
const telegramToken = (this.config.get('aiInsightTelegramToken') as string) || ''
const telegramChatIds = (this.config.get('aiInsightTelegramChatIds') as string) || ''
if (telegramToken && telegramChatIds) {
const chatIds = telegramChatIds.split(',').map((s) => s.trim()).filter(Boolean)
for (const chatId of chatIds) {
this.sendTelegram(telegramToken, chatId, `${notifTitle}\n\n${insight}`).catch((e) => {
insightLog('WARN', `Telegram 推送失败 (chatId=${chatId}): ${(e as Error).message}`)
})
}
} else {
insightLog('WARN', 'Telegram 已启用但 Token 或 Chat ID 未填写,跳过')
}
}
insightLog('INFO', `已为 ${displayName} 推送见解`) insightLog('INFO', `已为 ${displayName} 推送见解`)
} catch (e) { } catch (e) {
insightLog('ERROR', `API 调用失败 (${displayName}): ${(e as Error).message}`) insightLog('ERROR', `API 调用失败 (${displayName}): ${(e as Error).message}`)
} }
} }
/**
* 通过 Telegram Bot API 发送消息。
* 使用 Node 原生 https 模块,无需第三方依赖。
*/
private sendTelegram(token: string, chatId: string, text: string): Promise<void> {
return new Promise((resolve, reject) => {
const body = JSON.stringify({ chat_id: chatId, text, parse_mode: 'HTML' })
const options = {
hostname: 'api.telegram.org',
port: 443,
path: `/bot${token}/sendMessage`,
method: 'POST' as const,
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body).toString()
}
}
const req = https.request(options, (res) => {
let data = ''
res.on('data', (chunk) => { data += chunk })
res.on('end', () => {
try {
const parsed = JSON.parse(data)
if (parsed.ok) {
resolve()
} else {
reject(new Error(parsed.description || '未知错误'))
}
} catch {
reject(new Error(`响应解析失败: ${data.slice(0, 100)}`))
}
})
})
req.setTimeout(15_000, () => { req.destroy(); reject(new Error('Telegram 请求超时')) })
req.on('error', reject)
req.write(body)
req.end()
})
}
} }
export const insightService = new InsightService() export const insightService = new InsightService()

View File

@@ -125,7 +125,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
setHttpApiToken(token) setHttpApiToken(token)
await configService.setHttpApiToken(token) await configService.setHttpApiToken(token)
showMessage('已生成保存新的 Access Token', true) showMessage('已生成<EFBFBD><EFBFBD>保存新的 Access Token', true)
} }
const clearApiToken = async () => { const clearApiToken = async () => {
@@ -233,6 +233,10 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
const [aiInsightCooldownMinutes, setAiInsightCooldownMinutes] = useState(120) const [aiInsightCooldownMinutes, setAiInsightCooldownMinutes] = useState(120)
const [aiInsightScanIntervalHours, setAiInsightScanIntervalHours] = useState(4) const [aiInsightScanIntervalHours, setAiInsightScanIntervalHours] = useState(4)
const [aiInsightContextCount, setAiInsightContextCount] = useState(40) const [aiInsightContextCount, setAiInsightContextCount] = useState(40)
const [aiInsightSystemPrompt, setAiInsightSystemPrompt] = useState('')
const [aiInsightTelegramEnabled, setAiInsightTelegramEnabled] = useState(false)
const [aiInsightTelegramToken, setAiInsightTelegramToken] = useState('')
const [aiInsightTelegramChatIds, setAiInsightTelegramChatIds] = useState('')
const [isWayland, setIsWayland] = useState(false) const [isWayland, setIsWayland] = useState(false)
useEffect(() => { useEffect(() => {
@@ -471,6 +475,10 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
const savedAiInsightCooldownMinutes = await configService.getAiInsightCooldownMinutes() const savedAiInsightCooldownMinutes = await configService.getAiInsightCooldownMinutes()
const savedAiInsightScanIntervalHours = await configService.getAiInsightScanIntervalHours() const savedAiInsightScanIntervalHours = await configService.getAiInsightScanIntervalHours()
const savedAiInsightContextCount = await configService.getAiInsightContextCount() const savedAiInsightContextCount = await configService.getAiInsightContextCount()
const savedAiInsightSystemPrompt = await configService.getAiInsightSystemPrompt()
const savedAiInsightTelegramEnabled = await configService.getAiInsightTelegramEnabled()
const savedAiInsightTelegramToken = await configService.getAiInsightTelegramToken()
const savedAiInsightTelegramChatIds = await configService.getAiInsightTelegramChatIds()
setAiInsightEnabled(savedAiInsightEnabled) setAiInsightEnabled(savedAiInsightEnabled)
setAiInsightApiBaseUrl(savedAiInsightApiBaseUrl) setAiInsightApiBaseUrl(savedAiInsightApiBaseUrl)
setAiInsightApiKey(savedAiInsightApiKey) setAiInsightApiKey(savedAiInsightApiKey)
@@ -482,6 +490,10 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
setAiInsightCooldownMinutes(savedAiInsightCooldownMinutes) setAiInsightCooldownMinutes(savedAiInsightCooldownMinutes)
setAiInsightScanIntervalHours(savedAiInsightScanIntervalHours) setAiInsightScanIntervalHours(savedAiInsightScanIntervalHours)
setAiInsightContextCount(savedAiInsightContextCount) setAiInsightContextCount(savedAiInsightContextCount)
setAiInsightSystemPrompt(savedAiInsightSystemPrompt)
setAiInsightTelegramEnabled(savedAiInsightTelegramEnabled)
setAiInsightTelegramToken(savedAiInsightTelegramToken)
setAiInsightTelegramChatIds(savedAiInsightTelegramChatIds)
} catch (e: any) { } catch (e: any) {
console.error('加载配置失败:', e) console.error('加载配置失败:', e)
@@ -1660,7 +1672,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
<div className="tab-content"> <div className="tab-content">
<div className="form-group"> <div className="form-group">
<label></label> <label></label>
<span className="form-hint"></span> <span className="form-hint"><EFBFBD><EFBFBD><EFBFBD></span>
<div className="log-toggle-line"> <div className="log-toggle-line">
<span className="log-status">{notificationEnabled ? '已开启' : '已关闭'}</span> <span className="log-status">{notificationEnabled ? '已开启' : '已关闭'}</span>
<label className="switch" htmlFor="notification-enabled-toggle"> <label className="switch" htmlFor="notification-enabled-toggle">
@@ -2804,6 +2816,111 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
<div className="divider" /> <div className="divider" />
{/* 自定义 System Prompt */}
{(() => {
const DEFAULT_SYSTEM_PROMPT = `你是用户的私人关系观察助手,名叫"见解"。你的任务是主动提供有价值的观察和建议。
要求:
1. 必须给出见解。基于聊天记录分析对方情绪、话题趋势、关系动态,或给出回复建议、聊天话题推荐。
2. 控制在 80 字以内,直接、具体、一针见血。不要废话。
3. 输出纯文本,不使用 Markdown。
4. 只有在完全没有任何可说的内容时(比如对话只有一条"嗯"),才回复"SKIP"。绝大多数情况下你应该输出见解。`
return (
<div className="form-group">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 6 }}>
<label style={{ marginBottom: 0 }}> AI </label>
<button
className="button-secondary"
style={{ fontSize: 12, padding: '3px 10px' }}
onClick={async () => {
setAiInsightSystemPrompt('')
await configService.setAiInsightSystemPrompt('')
}}
>
</button>
</div>
<span className="form-hint">
使
</span>
<textarea
className="field-input"
rows={8}
style={{ width: '100%', resize: 'vertical', fontFamily: 'monospace', fontSize: 12 }}
placeholder={DEFAULT_SYSTEM_PROMPT}
value={aiInsightSystemPrompt}
onChange={(e) => {
const val = e.target.value
setAiInsightSystemPrompt(val)
scheduleConfigSave('aiInsightSystemPrompt', () => configService.setAiInsightSystemPrompt(val))
}}
/>
</div>
)
})()}
<div className="divider" />
{/* Telegram 推送 */}
<div className="form-group">
<label>Telegram Bot </label>
<span className="form-hint">
Telegram /便 Bot Token @BotFatherChat ID @userinfobot ID
</span>
<div className="log-toggle-line">
<span className="log-status">{aiInsightTelegramEnabled ? '已启用' : '未启用'}</span>
<label className="switch">
<input
type="checkbox"
checked={aiInsightTelegramEnabled}
onChange={async (e) => {
const val = e.target.checked
setAiInsightTelegramEnabled(val)
await configService.setAiInsightTelegramEnabled(val)
}}
/>
<span className="switch-slider" />
</label>
</div>
</div>
{aiInsightTelegramEnabled && (
<>
<div className="form-group">
<label>Bot Token</label>
<input
type="password"
className="field-input"
style={{ width: '100%' }}
placeholder="110201543:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsaw"
value={aiInsightTelegramToken}
onChange={(e) => {
const val = e.target.value
setAiInsightTelegramToken(val)
scheduleConfigSave('aiInsightTelegramToken', () => configService.setAiInsightTelegramToken(val))
}}
/>
</div>
<div className="form-group">
<label>Chat ID</label>
<input
type="text"
className="field-input"
style={{ width: '100%' }}
placeholder="123456789, -987654321"
value={aiInsightTelegramChatIds}
onChange={(e) => {
const val = e.target.value
setAiInsightTelegramChatIds(val)
scheduleConfigSave('aiInsightTelegramChatIds', () => configService.setAiInsightTelegramChatIds(val))
}}
/>
</div>
</>
)}
<div className="divider" />
{/* 对话白名单 */} {/* 对话白名单 */}
{(() => { {(() => {
const sortedSessions = [...chatSessions].sort((a, b) => (b.sortTimestamp || 0) - (a.sortTimestamp || 0)) const sortedSessions = [...chatSessions].sort((a, b) => (b.sortTimestamp || 0) - (a.sortTimestamp || 0))

View File

@@ -92,7 +92,11 @@ export const CONFIG_KEYS = {
AI_INSIGHT_WHITELIST: 'aiInsightWhitelist', AI_INSIGHT_WHITELIST: 'aiInsightWhitelist',
AI_INSIGHT_COOLDOWN_MINUTES: 'aiInsightCooldownMinutes', AI_INSIGHT_COOLDOWN_MINUTES: 'aiInsightCooldownMinutes',
AI_INSIGHT_SCAN_INTERVAL_HOURS: 'aiInsightScanIntervalHours', AI_INSIGHT_SCAN_INTERVAL_HOURS: 'aiInsightScanIntervalHours',
AI_INSIGHT_CONTEXT_COUNT: 'aiInsightContextCount' AI_INSIGHT_CONTEXT_COUNT: 'aiInsightContextCount',
AI_INSIGHT_SYSTEM_PROMPT: 'aiInsightSystemPrompt',
AI_INSIGHT_TELEGRAM_ENABLED: 'aiInsightTelegramEnabled',
AI_INSIGHT_TELEGRAM_TOKEN: 'aiInsightTelegramToken',
AI_INSIGHT_TELEGRAM_CHAT_IDS: 'aiInsightTelegramChatIds'
} as const } as const
export interface WxidConfig { export interface WxidConfig {
@@ -1665,3 +1669,39 @@ export async function getAiInsightContextCount(): Promise<number> {
export async function setAiInsightContextCount(count: number): Promise<void> { export async function setAiInsightContextCount(count: number): Promise<void> {
await config.set(CONFIG_KEYS.AI_INSIGHT_CONTEXT_COUNT, count) await config.set(CONFIG_KEYS.AI_INSIGHT_CONTEXT_COUNT, count)
} }
export async function getAiInsightSystemPrompt(): Promise<string> {
const value = await config.get(CONFIG_KEYS.AI_INSIGHT_SYSTEM_PROMPT)
return typeof value === 'string' ? value : ''
}
export async function setAiInsightSystemPrompt(prompt: string): Promise<void> {
await config.set(CONFIG_KEYS.AI_INSIGHT_SYSTEM_PROMPT, prompt)
}
export async function getAiInsightTelegramEnabled(): Promise<boolean> {
const value = await config.get(CONFIG_KEYS.AI_INSIGHT_TELEGRAM_ENABLED)
return value === true
}
export async function setAiInsightTelegramEnabled(enabled: boolean): Promise<void> {
await config.set(CONFIG_KEYS.AI_INSIGHT_TELEGRAM_ENABLED, enabled)
}
export async function getAiInsightTelegramToken(): Promise<string> {
const value = await config.get(CONFIG_KEYS.AI_INSIGHT_TELEGRAM_TOKEN)
return typeof value === 'string' ? value : ''
}
export async function setAiInsightTelegramToken(token: string): Promise<void> {
await config.set(CONFIG_KEYS.AI_INSIGHT_TELEGRAM_TOKEN, token)
}
export async function getAiInsightTelegramChatIds(): Promise<string> {
const value = await config.get(CONFIG_KEYS.AI_INSIGHT_TELEGRAM_CHAT_IDS)
return typeof value === 'string' ? value : ''
}
export async function setAiInsightTelegramChatIds(chatIds: string): Promise<void> {
await config.set(CONFIG_KEYS.AI_INSIGHT_TELEGRAM_CHAT_IDS, chatIds)
}