diff --git a/electron/main.ts b/electron/main.ts index 19fad71..792366f 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1,4 +1,4 @@ -import './preload-env' +import './preload-env' import { app, BrowserWindow, ipcMain, nativeTheme, session, Tray, Menu, nativeImage } from 'electron' import { Worker } from 'worker_threads' import { randomUUID } from 'crypto' @@ -31,6 +31,7 @@ import { destroyNotificationWindow, registerNotificationHandlers, showNotificati import { httpService } from './services/httpService' import { messagePushService } from './services/messagePushService' import { insightService } from './services/insightService' +import { normalizeWeiboCookieInput, weiboService } from './services/social/weiboService' import { bizService } from './services/bizService' // 配置自动更新 @@ -1660,6 +1661,32 @@ function registerIpcHandlers() { return insightService.generateFootprintInsight(payload) }) + ipcMain.handle('social:saveWeiboCookie', async (_, rawInput: string) => { + try { + if (!configService) { + return { success: false, error: 'Config service is not initialized' } + } + const normalized = normalizeWeiboCookieInput(rawInput) + configService.set('aiInsightWeiboCookie' as any, normalized as any) + weiboService.clearCache() + return { success: true, normalized, hasCookie: Boolean(normalized) } + } catch (error) { + return { success: false, error: (error as Error).message || 'Failed to save Weibo cookie' } + } + }) + + ipcMain.handle('social:validateWeiboUid', async (_, uid: string) => { + try { + if (!configService) { + return { success: false, error: 'Config service is not initialized' } + } + const cookie = String(configService.get('aiInsightWeiboCookie' as any) || '') + return await weiboService.validateUid(uid, cookie) + } catch (error) { + return { success: false, error: (error as Error).message || 'Failed to validate Weibo UID' } + } + }) + ipcMain.handle('config:clear', async () => { if (isLaunchAtStartupSupported() && getSystemLaunchAtStartup()) { const result = setSystemLaunchAtStartup(false) @@ -3866,3 +3893,7 @@ app.on('window-all-closed', () => { app.quit() } }) + + + + diff --git a/electron/preload.ts b/electron/preload.ts index 6bd505c..09126a7 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -1,4 +1,4 @@ -import { contextBridge, ipcRenderer } from 'electron' +import { contextBridge, ipcRenderer } from 'electron' // 暴露给渲染进程的 API contextBridge.exposeInMainWorld('electronAPI', { @@ -557,5 +557,11 @@ contextBridge.exposeInMainWorld('electronAPI', { 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) + }, + + social: { + saveWeiboCookie: (rawInput: string) => ipcRenderer.invoke('social:saveWeiboCookie', rawInput), + validateWeiboUid: (uid: string) => ipcRenderer.invoke('social:validateWeiboUid', uid) } }) + diff --git a/electron/services/config.ts b/electron/services/config.ts index d6c0b39..1124c77 100644 --- a/electron/services/config.ts +++ b/electron/services/config.ts @@ -1,4 +1,4 @@ -import { join } from 'path' +import { join } from 'path' import { app, safeStorage } from 'electron' import crypto from 'crypto' import Store from 'electron-store' @@ -82,6 +82,7 @@ interface ConfigSchema { aiInsightApiModel: string aiInsightSilenceDays: number aiInsightAllowContext: boolean + aiInsightAllowSocialContext: boolean aiInsightWhitelistEnabled: boolean aiInsightWhitelist: string[] /** 活跃分析冷却时间(分钟),0 表示无冷却 */ @@ -113,7 +114,8 @@ const ENCRYPTED_STRING_KEYS: Set = new Set([ 'authPassword', 'httpApiToken', 'aiModelApiKey', - 'aiInsightApiKey' + 'aiInsightApiKey', + 'aiInsightWeiboCookie' ]) const ENCRYPTED_BOOL_KEYS: Set = new Set(['authEnabled', 'authUseHello']) const ENCRYPTED_NUMBER_KEYS: Set = new Set(['imageXorKey']) @@ -196,15 +198,19 @@ export class ConfigService { aiInsightApiModel: 'gpt-4o-mini', aiInsightSilenceDays: 3, aiInsightAllowContext: false, + aiInsightAllowSocialContext: false, aiInsightWhitelistEnabled: false, aiInsightWhitelist: [], aiInsightCooldownMinutes: 120, aiInsightScanIntervalHours: 4, aiInsightContextCount: 40, + aiInsightSocialContextCount: 3, aiInsightSystemPrompt: '', aiInsightTelegramEnabled: false, aiInsightTelegramToken: '', aiInsightTelegramChatIds: '', + aiInsightWeiboCookie: '', + aiInsightWeiboBindings: {}, aiFootprintEnabled: false, aiFootprintSystemPrompt: '', aiInsightDebugLogEnabled: false @@ -825,3 +831,4 @@ export class ConfigService { this.unlockPassword = null } } + diff --git a/electron/services/insightService.ts b/electron/services/insightService.ts index 911af51..1366751 100644 --- a/electron/services/insightService.ts +++ b/electron/services/insightService.ts @@ -1,4 +1,4 @@ -/** +/** * insightService.ts * * AI 见解后台服务: @@ -21,6 +21,7 @@ import { URL } from 'url' import { app, Notification } from 'electron' import { ConfigService } from './config' import { chatService, ChatSession, Message } from './chatService' +import { weiboService } from './social/weiboService' // ─── 常量 ──────────────────────────────────────────────────────────────────── @@ -46,6 +47,10 @@ const INSIGHT_CONFIG_KEYS = new Set([ 'aiModelApiBaseUrl', 'aiModelApiKey', 'aiModelApiModel', + 'aiInsightAllowSocialContext', + 'aiInsightSocialContextCount', + 'aiInsightWeiboCookie', + 'aiInsightWeiboBindings', 'dbPath', 'decryptKey', 'myWxid' @@ -318,6 +323,10 @@ class InsightService { if (!INSIGHT_CONFIG_KEYS.has(normalizedKey)) return // 数据库相关配置变更后,丢弃缓存并强制下次重连 + if (normalizedKey === 'aiInsightAllowSocialContext' || normalizedKey === 'aiInsightSocialContextCount' || normalizedKey === 'aiInsightWeiboCookie' || normalizedKey === 'aiInsightWeiboBindings') { + weiboService.clearCache() + } + if (normalizedKey === 'dbPath' || normalizedKey === 'decryptKey' || normalizedKey === 'myWxid') { this.clearRuntimeCache() } @@ -350,6 +359,7 @@ class InsightService { this.lastSeenTimestamp.clear() this.todayTriggers.clear() this.todayDate = getStartOfDay() + weiboService.clearCache() } private clearTimers(): void { @@ -786,6 +796,50 @@ ${topMentionText} return total } + private formatWeiboTimestamp(raw: string): string { + const parsed = Date.parse(String(raw || '')) + if (!Number.isFinite(parsed)) { + return String(raw || '').trim() + } + return new Date(parsed).toLocaleString('zh-CN') + } + + private async getSocialContextSection(sessionId: string): Promise { + const allowSocialContext = this.config.get('aiInsightAllowSocialContext') === true + if (!allowSocialContext) return '' + + const rawCookie = String(this.config.get('aiInsightWeiboCookie') || '').trim() + const hasCookie = rawCookie.length > 0 + + const bindings = + (this.config.get('aiInsightWeiboBindings') as Record | undefined) || {} + const binding = bindings[sessionId] + const uid = String(binding?.uid || '').trim() + if (!uid) return '' + + const socialCountRaw = Number(this.config.get('aiInsightSocialContextCount') || 3) + const socialCount = Math.max(1, Math.min(5, Math.floor(socialCountRaw) || 3)) + + try { + const posts = await weiboService.fetchRecentPosts(uid, rawCookie, socialCount) + if (posts.length === 0) return '' + + const lines = posts.map((post) => { + const time = this.formatWeiboTimestamp(post.createdAt) + const text = post.text.length > 180 ? `${post.text.slice(0, 180)}...` : post.text + return `[微博 ${time}] ${text}` + }) + insightLog('INFO', `已加载 ${lines.length} 条微博公开内容 (uid=${uid})`) + const riskHint = hasCookie + ? '' + : '\n提示:未配置微博 Cookie,使用移动端公开接口抓取,可能因平台风控导致获取失败或内容较少。' + return `近期公开社交平台内容(来源:微博,最近 ${lines.length} 条):\n${lines.join('\n')}${riskHint}` + } catch (error) { + insightLog('WARN', `拉取微博公开内容失败 (uid=${uid}): ${(error as Error).message}`) + return '' + } + } + // ── 沉默联系人扫描 ────────────────────────────────────────────────────────── private scheduleSilenceScan(): void { @@ -1028,6 +1082,8 @@ ${topMentionText} } } + const socialContextSection = await this.getSocialContextSection(sessionId) + // ── 默认 system prompt(稳定内容,有利于 provider 端 prompt cache 命中)──── const DEFAULT_SYSTEM_PROMPT = `你是用户的私人关系观察助手,名叫"见解"。你的任务是主动提供有价值的观察和建议。 @@ -1060,6 +1116,7 @@ ${topMentionText} `时间统计:${todayStatsDesc}`, `全局统计:${globalStatsDesc}`, contextSection, + socialContextSection, '请给出你的见解(≤80字):' ].filter(Boolean).join('\n\n') @@ -1190,3 +1247,5 @@ ${topMentionText} } export const insightService = new InsightService() + + diff --git a/electron/services/social/weiboService.ts b/electron/services/social/weiboService.ts new file mode 100644 index 0000000..30a9a5f --- /dev/null +++ b/electron/services/social/weiboService.ts @@ -0,0 +1,367 @@ +import https from 'https' +import { createHash } from 'crypto' +import { URL } from 'url' + +const WEIBO_TIMEOUT_MS = 10_000 +const WEIBO_MAX_POSTS = 5 +const WEIBO_CACHE_TTL_MS = 30 * 60 * 1000 +const WEIBO_USER_AGENT = + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36' +const WEIBO_MOBILE_USER_AGENT = + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1' + +interface BrowserCookieEntry { + domain?: string + name?: string + value?: string +} + +interface WeiboUserInfo { + id?: number | string + screen_name?: string +} + +interface WeiboWaterFallItem { + id?: number | string + idstr?: string + mblogid?: string + created_at?: string + text_raw?: string + isLongText?: boolean + user?: WeiboUserInfo + retweeted_status?: WeiboWaterFallItem +} + +interface WeiboWaterFallResponse { + ok?: number + data?: { + list?: WeiboWaterFallItem[] + next_cursor?: string + } +} + +interface WeiboStatusShowResponse { + id?: number | string + idstr?: string + mblogid?: string + created_at?: string + text_raw?: string + user?: WeiboUserInfo + retweeted_status?: WeiboWaterFallItem +} + +interface MWeiboCard { + mblog?: WeiboWaterFallItem + card_group?: MWeiboCard[] +} + +interface MWeiboContainerResponse { + ok?: number + data?: { + cards?: MWeiboCard[] + } +} + +export interface WeiboRecentPost { + id: string + createdAt: string + url: string + text: string + screenName?: string +} + +interface CachedRecentPosts { + expiresAt: number + posts: WeiboRecentPost[] +} + +function requestJson(url: string, options: { cookie?: string; referer?: string; userAgent?: string }): Promise { + return new Promise((resolve, reject) => { + let urlObj: URL + try { + urlObj = new URL(url) + } catch { + reject(new Error(`无效的微博请求地址:${url}`)) + return + } + + const headers: Record = { + Accept: 'application/json, text/plain, */*', + Referer: options.referer || 'https://weibo.com', + 'User-Agent': options.userAgent || WEIBO_USER_AGENT, + 'X-Requested-With': 'XMLHttpRequest' + } + if (options.cookie) { + headers.Cookie = options.cookie + } + + const req = https.request( + { + hostname: urlObj.hostname, + port: urlObj.port || 443, + path: urlObj.pathname + urlObj.search, + method: 'GET', + headers + }, + (res) => { + let raw = '' + res.setEncoding('utf8') + res.on('data', (chunk) => { + raw += chunk + }) + res.on('end', () => { + const statusCode = res.statusCode || 0 + if (statusCode < 200 || statusCode >= 300) { + reject(new Error(`微博接口返回异常状态码 ${statusCode}`)) + return + } + try { + resolve(JSON.parse(raw) as T) + } catch { + reject(new Error('微博接口返回了非 JSON 响应')) + } + }) + } + ) + + req.setTimeout(WEIBO_TIMEOUT_MS, () => { + req.destroy() + reject(new Error('微博请求超时')) + }) + + req.on('error', reject) + req.end() + }) +} + +function normalizeCookieArray(entries: BrowserCookieEntry[]): string { + const picked = new Map() + + for (const entry of entries) { + const name = String(entry?.name || '').trim() + const value = String(entry?.value || '').trim() + const domain = String(entry?.domain || '').trim().toLowerCase() + + if (!name || !value) continue + if (domain && !domain.includes('weibo.com') && !domain.includes('weibo.cn')) continue + + picked.set(name, value) + } + + return Array.from(picked.entries()) + .map(([name, value]) => `${name}=${value}`) + .join('; ') +} + +export function normalizeWeiboCookieInput(rawInput: string): string { + const trimmed = String(rawInput || '').trim() + if (!trimmed) return '' + + try { + const parsed = JSON.parse(trimmed) as unknown + if (Array.isArray(parsed)) { + const normalized = normalizeCookieArray(parsed as BrowserCookieEntry[]) + if (normalized) return normalized + throw new Error('Cookie JSON 中未找到可用的微博 Cookie 项') + } + } catch (error) { + if (!(error instanceof SyntaxError)) { + throw error + } + } + + return trimmed.replace(/^Cookie:\s*/i, '').trim() +} + +function normalizeWeiboUid(input: string): string { + const trimmed = String(input || '').trim() + const directMatch = trimmed.match(/^\d{5,}$/) + if (directMatch) return directMatch[0] + + const linkMatch = trimmed.match(/(?:weibo\.com|m\.weibo\.cn)\/u\/(\d{5,})/i) + if (linkMatch) return linkMatch[1] + + throw new Error('请输入有效的微博 UID(纯数字)') +} + +function sanitizeWeiboText(text: string): string { + return String(text || '') + .replace(/\u200b|\u200c|\u200d|\ufeff/g, '') + .replace(/https?:\/\/t\.cn\/[A-Za-z0-9]+/g, ' ') + .replace(/ +/g, ' ') + .replace(/\n{3,}/g, '\n\n') + .trim() +} + +function mergeRetweetText(item: Pick): string { + const baseText = sanitizeWeiboText(item.text_raw || '') + const retweetText = sanitizeWeiboText(item.retweeted_status?.text_raw || '') + if (!retweetText) return baseText + if (!baseText || baseText === '转发微博') return `转发:${retweetText}` + return `${baseText}\n\n转发内容:${retweetText}` +} + +function buildCacheKey(uid: string, count: number, cookie: string): string { + const cookieHash = createHash('sha1').update(cookie).digest('hex') + return `${uid}:${count}:${cookieHash}` +} + +class WeiboService { + private recentPostsCache = new Map() + + clearCache(): void { + this.recentPostsCache.clear() + } + + async validateUid( + uidInput: string, + cookieInput: string + ): Promise<{ success: boolean; uid?: string; screenName?: string; error?: string }> { + try { + const uid = normalizeWeiboUid(uidInput) + const cookie = normalizeWeiboCookieInput(cookieInput) + if (!cookie) { + return { success: true, uid } + } + + const timeline = await this.fetchTimeline(uid, cookie) + const firstItem = timeline.data?.list?.[0] + if (!firstItem) { + return { success: false, error: '该微博账号暂无可读取的近期公开内容,或当前 Cookie 已失效' } + } + + return { + success: true, + uid, + screenName: firstItem.user?.screen_name + } + } catch (error) { + return { + success: false, + error: (error as Error).message || '微博 UID 校验失败' + } + } + } + + async fetchRecentPosts( + uidInput: string, + cookieInput: string, + requestedCount: number + ): Promise { + const uid = normalizeWeiboUid(uidInput) + const cookie = normalizeWeiboCookieInput(cookieInput) + const hasCookie = Boolean(cookie) + + const count = Math.max(1, Math.min(WEIBO_MAX_POSTS, Math.floor(Number(requestedCount) || 0))) + const cacheKey = buildCacheKey(uid, count, hasCookie ? cookie : '__no_cookie_mobile__') + const cached = this.recentPostsCache.get(cacheKey) + const now = Date.now() + + if (cached && cached.expiresAt > now) { + return cached.posts + } + + const rawItems = hasCookie + ? (await this.fetchTimeline(uid, cookie)).data?.list || [] + : await this.fetchMobileTimeline(uid) + const posts: WeiboRecentPost[] = [] + + for (const item of rawItems) { + if (posts.length >= count) break + + const id = String(item.idstr || item.id || '').trim() + if (!id) continue + + let text = mergeRetweetText(item) + if (item.isLongText && hasCookie) { + try { + const detail = await this.fetchDetail(id, cookie) + text = mergeRetweetText(detail) + } catch { + // 长文补抓失败时回退到列表摘要 + } + } + + text = sanitizeWeiboText(text) + if (!text) continue + + posts.push({ + id, + createdAt: String(item.created_at || ''), + url: `https://m.weibo.cn/detail/${id}`, + text, + screenName: item.user?.screen_name + }) + } + + this.recentPostsCache.set(cacheKey, { + expiresAt: now + WEIBO_CACHE_TTL_MS, + posts + }) + + return posts + } + + private fetchTimeline(uid: string, cookie: string): Promise { + return requestJson( + `https://weibo.com/ajax/profile/getWaterFallContent?uid=${encodeURIComponent(uid)}`, + { + cookie, + referer: `https://weibo.com/u/${encodeURIComponent(uid)}` + } + ).then((response) => { + if (response.ok !== 1 || !Array.isArray(response.data?.list)) { + throw new Error('微博时间线获取失败,请检查 Cookie 是否仍然有效') + } + return response + }) + } + + private fetchMobileTimeline(uid: string): Promise { + const containerid = `107603${uid}` + return requestJson( + `https://m.weibo.cn/api/container/getIndex?type=uid&value=${encodeURIComponent(uid)}&containerid=${encodeURIComponent(containerid)}`, + { + referer: `https://m.weibo.cn/u/${encodeURIComponent(uid)}`, + userAgent: WEIBO_MOBILE_USER_AGENT + } + ).then((response) => { + if (response.ok !== 1 || !Array.isArray(response.data?.cards)) { + throw new Error('微博时间线获取失败,请稍后重试') + } + + const rows: WeiboWaterFallItem[] = [] + for (const card of response.data.cards) { + if (card?.mblog) rows.push(card.mblog) + if (Array.isArray(card?.card_group)) { + for (const subCard of card.card_group) { + if (subCard?.mblog) rows.push(subCard.mblog) + } + } + } + + if (rows.length === 0) { + throw new Error('该微博账号暂无可读取的近期公开内容') + } + + return rows + }) + } + + private fetchDetail(id: string, cookie: string): Promise { + return requestJson( + `https://weibo.com/ajax/statuses/show?id=${encodeURIComponent(id)}&isGetLongText=true`, + { + cookie, + referer: `https://weibo.com/detail/${encodeURIComponent(id)}` + } + ).then((response) => { + if (!response || (!response.id && !response.idstr)) { + throw new Error('微博详情获取失败') + } + return response + }) + } +} + +export const weiboService = new WeiboService() diff --git a/src/pages/SettingsPage.scss b/src/pages/SettingsPage.scss index ac35a22..a94c532 100644 --- a/src/pages/SettingsPage.scss +++ b/src/pages/SettingsPage.scss @@ -1,4 +1,4 @@ -.settings-modal-overlay { +.settings-modal-overlay { position: fixed; top: 0; left: 0; @@ -1849,6 +1849,20 @@ animation: fadeIn 0.2s ease; } +.social-cookie-modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.52); + display: flex; + align-items: center; + justify-content: center; + z-index: 2300; + animation: fadeIn 0.2s ease; +} + // API 警告弹窗 .api-warning-modal { width: 420px; @@ -1918,6 +1932,85 @@ } } +.settings-inline-modal { + width: min(560px, calc(100vw - 40px)); + max-height: min(720px, calc(100vh - 40px)); + display: flex; + flex-direction: column; + background: var(--bg-primary); + border-radius: 16px; + overflow: hidden; + box-shadow: 0 16px 48px rgba(0, 0, 0, 0.2); + animation: slideUp 0.25s ease; + + .modal-header { + display: flex; + align-items: center; + gap: 10px; + padding: 20px 24px; + border-bottom: 1px solid var(--border-color); + + svg { + color: var(--primary); + } + + h3 { + margin: 0; + font-size: 16px; + font-weight: 600; + color: var(--text-primary); + } + } + + .modal-body { + flex: 1; + padding: 20px 24px; + overflow-y: auto; + + .warning-text { + margin: 0 0 16px; + font-size: 14px; + color: var(--text-secondary); + line-height: 1.6; + } + } + + .modal-footer { + display: flex; + justify-content: flex-end; + gap: 10px; + padding: 16px 24px; + border-top: 1px solid var(--border-color); + background: var(--bg-secondary); + } +} + +.social-cookie-textarea { + width: 100%; + min-height: 220px; + resize: vertical; + border: 1px solid var(--border-color); + border-radius: 12px; + padding: 12px 14px; + background: var(--bg-primary); + color: var(--text-primary); + font-size: 12px; + line-height: 1.6; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', monospace; + margin-top: 10px; +} + +.social-inline-error { + margin-top: 10px; + border-radius: 10px; + padding: 10px 12px; + font-size: 12px; + line-height: 1.5; + color: color-mix(in srgb, var(--danger) 72%, var(--text-primary) 28%); + background: color-mix(in srgb, var(--danger) 8%, var(--bg-secondary)); + border: 1px solid color-mix(in srgb, var(--danger) 24%, var(--border-color)); +} + @keyframes fadeIn { from { opacity: 0; @@ -3506,6 +3599,103 @@ overflow: hidden; } + &.insight-social-tab { + .anti-revoke-list-header { + grid-template-columns: minmax(0, 1fr) minmax(300px, 420px) auto; + + .insight-social-column-title { + color: var(--text-tertiary); + } + } + + .anti-revoke-row { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(300px, 420px) auto; + align-items: center; + gap: 14px; + } + + .anti-revoke-row-main { + min-width: 0; + } + + .insight-social-binding-cell { + min-width: 0; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px 10px; + align-items: center; + } + + .insight-social-binding-input-wrap { + min-width: 0; + display: flex; + align-items: center; + gap: 8px; + } + + .binding-platform-chip { + flex-shrink: 0; + border-radius: 999px; + padding: 2px 8px; + font-size: 11px; + color: var(--text-secondary); + border: 1px solid color-mix(in srgb, var(--border-color) 84%, transparent); + background: color-mix(in srgb, var(--bg-secondary) 88%, var(--bg-primary) 12%); + } + + .insight-social-binding-input { + width: 100%; + min-width: 0; + height: 30px; + border-radius: 8px; + border: 1px solid var(--border-color); + background: color-mix(in srgb, var(--bg-primary) 92%, var(--bg-secondary) 8%); + color: var(--text-primary); + font-size: 12px; + padding: 0 10px; + outline: none; + transition: border-color 0.18s ease, box-shadow 0.18s ease; + + &:focus { + border-color: color-mix(in srgb, var(--primary) 55%, var(--border-color)); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary) 16%, transparent); + } + } + + .insight-social-binding-actions { + display: inline-flex; + align-items: center; + justify-self: flex-end; + gap: 8px; + } + + .insight-social-binding-feedback { + grid-column: 1 / span 2; + min-height: 18px; + } + + .binding-feedback { + font-size: 12px; + line-height: 1.4; + color: var(--text-secondary); + + &.error { + color: color-mix(in srgb, var(--danger) 72%, var(--text-primary) 28%); + } + + &.muted { + color: var(--text-tertiary); + } + } + + .anti-revoke-row-status { + justify-self: flex-end; + align-items: flex-end; + max-width: none; + } + } + @media (max-width: 980px) { .anti-revoke-hero { flex-direction: column; @@ -3541,5 +3731,36 @@ justify-content: space-between; max-width: none; } + + &.insight-social-tab { + .anti-revoke-list-header { + grid-template-columns: minmax(0, 1fr) auto; + + .insight-social-column-title { + display: none; + } + } + + .anti-revoke-row { + display: flex; + align-items: flex-start; + flex-direction: column; + } + + .insight-social-binding-cell, + .anti-revoke-row-status { + width: 100%; + } + + .insight-social-binding-cell { + grid-template-columns: 1fr; + } + + .insight-social-binding-feedback { + grid-column: 1; + } + } } } + + diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index b62f101..f585d0c 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef } from 'react' +import { useState, useEffect, useRef } from 'react' import { useLocation } from 'react-router-dom' import { useAppStore } from '../stores/appStore' import { useChatStore } from '../stores/chatStore' @@ -48,7 +48,7 @@ const tabs: { id: Exclude; label: string ] const aiTabs: Array<{ id: Extract; label: string }> = [ - { id: 'aiCommon', label: 'AI 通用' }, + { id: 'aiCommon', label: '基础配置' }, { id: 'insight', label: 'AI 见解' }, { id: 'aiFootprint', label: 'AI 足迹' } ] @@ -284,6 +284,17 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { const [aiInsightTelegramEnabled, setAiInsightTelegramEnabled] = useState(false) const [aiInsightTelegramToken, setAiInsightTelegramToken] = useState('') const [aiInsightTelegramChatIds, setAiInsightTelegramChatIds] = useState('') + const [aiInsightAllowSocialContext, setAiInsightAllowSocialContext] = useState(false) + const [aiInsightSocialContextCount, setAiInsightSocialContextCount] = useState(3) + const [aiInsightWeiboCookie, setAiInsightWeiboCookie] = useState('') + const [aiInsightWeiboBindings, setAiInsightWeiboBindings] = useState>({}) + const [showWeiboCookieModal, setShowWeiboCookieModal] = useState(false) + const [weiboCookieDraft, setWeiboCookieDraft] = useState('') + const [weiboCookieError, setWeiboCookieError] = useState('') + const [isSavingWeiboCookie, setIsSavingWeiboCookie] = useState(false) + const [weiboBindingDrafts, setWeiboBindingDrafts] = useState>({}) + const [weiboBindingErrors, setWeiboBindingErrors] = useState>({}) + const [weiboBindingLoadingSessionId, setWeiboBindingLoadingSessionId] = useState(null) const [aiFootprintEnabled, setAiFootprintEnabled] = useState(false) const [aiFootprintSystemPrompt, setAiFootprintSystemPrompt] = useState('') const [aiInsightDebugLogEnabled, setAiInsightDebugLogEnabled] = useState(false) @@ -527,6 +538,10 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { const savedAiInsightTelegramEnabled = await configService.getAiInsightTelegramEnabled() const savedAiInsightTelegramToken = await configService.getAiInsightTelegramToken() const savedAiInsightTelegramChatIds = await configService.getAiInsightTelegramChatIds() + const savedAiInsightAllowSocialContext = await configService.getAiInsightAllowSocialContext() + const savedAiInsightSocialContextCount = await configService.getAiInsightSocialContextCount() + const savedAiInsightWeiboCookie = await configService.getAiInsightWeiboCookie() + const savedAiInsightWeiboBindings = await configService.getAiInsightWeiboBindings() const savedAiFootprintEnabled = await configService.getAiFootprintEnabled() const savedAiFootprintSystemPrompt = await configService.getAiFootprintSystemPrompt() const savedAiInsightDebugLogEnabled = await configService.getAiInsightDebugLogEnabled() @@ -546,6 +561,10 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { setAiInsightTelegramEnabled(savedAiInsightTelegramEnabled) setAiInsightTelegramToken(savedAiInsightTelegramToken) setAiInsightTelegramChatIds(savedAiInsightTelegramChatIds) + setAiInsightAllowSocialContext(savedAiInsightAllowSocialContext) + setAiInsightSocialContextCount(savedAiInsightSocialContextCount) + setAiInsightWeiboCookie(savedAiInsightWeiboCookie) + setAiInsightWeiboBindings(savedAiInsightWeiboBindings) setAiFootprintEnabled(savedAiFootprintEnabled) setAiFootprintSystemPrompt(savedAiFootprintSystemPrompt) setAiInsightDebugLogEnabled(savedAiInsightDebugLogEnabled) @@ -1684,6 +1703,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { + ) @@ -2331,6 +2351,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { + ) const resolvedWhisperModelPath = whisperModelDir || whisperModelStatus?.modelPath || '' @@ -2438,6 +2459,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { + ) @@ -2844,9 +2866,145 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { )} + ) + const withAsyncTimeout = async (task: Promise, timeoutMs: number, timeoutMessage: string): Promise => { + let timeoutHandle: ReturnType | null = null + try { + return await Promise.race([ + task, + new Promise((_, reject) => { + timeoutHandle = setTimeout(() => reject(new Error(timeoutMessage)), timeoutMs) + }) + ]) + } finally { + if (timeoutHandle) clearTimeout(timeoutHandle) + } + } + + const hasWeiboCookieConfigured = aiInsightWeiboCookie.trim().length > 0 + + const openWeiboCookieModal = () => { + setWeiboCookieDraft(aiInsightWeiboCookie) + setWeiboCookieError('') + setShowWeiboCookieModal(true) + } + + const persistWeiboCookieDraft = async (draftOverride?: string): Promise => { + const draftToSave = draftOverride ?? weiboCookieDraft + if (draftToSave === aiInsightWeiboCookie) return true + setIsSavingWeiboCookie(true) + setWeiboCookieError('') + try { + const result = await withAsyncTimeout( + window.electronAPI.social.saveWeiboCookie(draftToSave), + 10000, + '保存微博 Cookie 超时,请稍后重试' + ) + if (!result.success) { + setWeiboCookieError(result.error || '微博 Cookie 保存失败') + return false + } + const normalized = result.normalized || '' + setAiInsightWeiboCookie(normalized) + setWeiboCookieDraft(normalized) + showMessage(result.hasCookie ? '微博 Cookie 已保存' : '微博 Cookie 已清空', true) + return true + } catch (e: any) { + setWeiboCookieError(e?.message || String(e)) + return false + } finally { + setIsSavingWeiboCookie(false) + } + } + + const handleCloseWeiboCookieModal = async (discard = false) => { + if (discard) { + setShowWeiboCookieModal(false) + setWeiboCookieDraft(aiInsightWeiboCookie) + setWeiboCookieError('') + return + } + const ok = await persistWeiboCookieDraft() + if (!ok) return + setShowWeiboCookieModal(false) + setWeiboCookieError('') + } + + const getWeiboBindingDraftValue = (sessionId: string): string => { + const draft = weiboBindingDrafts[sessionId] + if (draft !== undefined) return draft + return aiInsightWeiboBindings[sessionId]?.uid || '' + } + + const updateWeiboBindingDraft = (sessionId: string, value: string) => { + setWeiboBindingDrafts((prev) => ({ + ...prev, + [sessionId]: value + })) + setWeiboBindingErrors((prev) => { + if (!prev[sessionId]) return prev + const next = { ...prev } + delete next[sessionId] + return next + }) + } + + const handleSaveWeiboBinding = async (sessionId: string, displayName: string) => { + const draftUid = getWeiboBindingDraftValue(sessionId) + setWeiboBindingLoadingSessionId(sessionId) + setWeiboBindingErrors((prev) => { + if (!prev[sessionId]) return prev + const next = { ...prev } + delete next[sessionId] + return next + }) + try { + const result = await withAsyncTimeout( + window.electronAPI.social.validateWeiboUid(draftUid), + 12000, + '微博 UID 校验超时,请稍后重试' + ) + if (!result.success || !result.uid) { + setWeiboBindingErrors((prev) => ({ ...prev, [sessionId]: result.error || '微博 UID 校验失败' })) + return + } + + const nextBindings: Record = { + ...aiInsightWeiboBindings, + [sessionId]: { + uid: result.uid, + screenName: result.screenName, + updatedAt: Date.now() + } + } + setAiInsightWeiboBindings(nextBindings) + await configService.setAiInsightWeiboBindings(nextBindings) + setWeiboBindingDrafts((prev) => ({ ...prev, [sessionId]: result.uid! })) + showMessage(`已为「${displayName}」绑定微博 UID`, true) + } catch (e: any) { + setWeiboBindingErrors((prev) => ({ ...prev, [sessionId]: e?.message || String(e) })) + } finally { + setWeiboBindingLoadingSessionId(null) + } + } + + const handleClearWeiboBinding = async (sessionId: string, silent = false) => { + const nextBindings = { ...aiInsightWeiboBindings } + delete nextBindings[sessionId] + setAiInsightWeiboBindings(nextBindings) + setWeiboBindingDrafts((prev) => ({ ...prev, [sessionId]: '' })) + setWeiboBindingErrors((prev) => { + if (!prev[sessionId]) return prev + const next = { ...prev } + delete next[sessionId] + return next + }) + await configService.setAiInsightWeiboBindings(nextBindings) + if (!silent) showMessage('已清除微博绑定', true) + } const renderInsightTab = () => (
{/* 总开关 */} @@ -2878,7 +3036,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
- 该功能依赖「AI 通用」里的模型配置。用于验证完整链路(数据库→API→弹窗)。 + 该功能依赖「基础配置」里的模型配置。用于验证完整链路(数据库→API→弹窗)。
+ +
+
+ {!hasWeiboCookieConfigured && ( + + 未配置微博 Cookie 时,也会尝试抓取微博公开内容;但可能因平台风控导致获取失败或内容较少。 + + )} +
+ + {aiInsightAllowSocialContext && ( +
+ + + 当前仅支持微博最近发帖。 +
+ 不建议超过 5,避免触发平台风控。 +
+ { + const val = Math.max(1, Math.min(5, parseInt(e.target.value, 10) || 3)) + setAiInsightSocialContextCount(val) + scheduleConfigSave('aiInsightSocialContextCount', () => configService.setAiInsightSocialContextCount(val)) + }} + style={{ width: 100 }} + /> +
+ )} + +
{/* 自定义 System Prompt */} {(() => { const DEFAULT_SYSTEM_PROMPT = `你是用户的私人关系观察助手,名叫"见解"。你的任务是主动提供有价值的观察和建议。 @@ -3050,8 +3268,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
+ {weiboBinding && ( + + )} +
+
+ {weiboBindingError ? ( + {weiboBindingError} + ) : weiboBinding?.screenName ? ( + @{weiboBinding.screenName} + ) : weiboBinding?.uid ? ( + 已绑定 UID:{weiboBinding.uid} + ) : ( + 仅支持手动填写数字 UID + )} +
+