diff --git a/apps/desktop-client/src/main/account-binder.ts b/apps/desktop-client/src/main/account-binder.ts index 7fa0df3..1afc95e 100644 --- a/apps/desktop-client/src/main/account-binder.ts +++ b/apps/desktop-client/src/main/account-binder.ts @@ -24,6 +24,7 @@ import { sessionFetchJson, sessionFetchText, } from './adapters/common' +import { readDeepseekAccountIdentity } from './adapters/deepseek/account-identity' import { buildDoubaoSessionCredentialEvidence, DOUBAO_AUTH_PROBE_URL, @@ -44,6 +45,8 @@ import { QWEN_AUTH_PROBE_URL, readQwenAuthPageState, } from './adapters/qwen/auth-rules' +import { readWenxinAccountIdentity } from './adapters/wenxin/account-identity' +import { readYuanbaoAccountIdentity } from './adapters/yuanbao/account-identity' import type { AccountHealthProfile, AuthProbeResult } from './auth-types' import { attachWindowDiagnostics, @@ -1454,7 +1457,28 @@ async function detectDeepSeekPlatform( label: string, context: DetectContext, ): Promise { - return await detectCredentialBackedAIPlatform(platformId, label, context) + const detected = await detectCredentialBackedAIPlatform(platformId, label, context) + if (!detected) { + return null + } + + const placeholderName = `${label} 会话` + const needsName = !detected.displayName || detected.displayName === placeholderName + const needsAvatar = !detected.avatarUrl + if (!needsName && !needsAvatar) { + return detected + } + + const identity = await readDeepseekAccountIdentity(context.webContents).catch(() => null) + if (!identity || (!identity.displayName && !identity.avatarUrl)) { + return detected + } + + return sanitizeDetectedAccount({ + platformUid: detected.platformUid, + displayName: needsName && identity.displayName ? identity.displayName : detected.displayName, + avatarUrl: needsAvatar && identity.avatarUrl ? identity.avatarUrl : detected.avatarUrl, + }) } async function detectYuanbaoPlatform( @@ -1462,7 +1486,28 @@ async function detectYuanbaoPlatform( label: string, context: DetectContext, ): Promise { - return await detectCredentialBackedAIPlatform(platformId, label, context) + const detected = await detectCredentialBackedAIPlatform(platformId, label, context) + if (!detected) { + return null + } + + const placeholderName = `${label} 会话` + const needsName = !detected.displayName || detected.displayName === placeholderName + const needsAvatar = !detected.avatarUrl + if (!needsName && !needsAvatar) { + return detected + } + + const identity = await readYuanbaoAccountIdentity(context.webContents).catch(() => null) + if (!identity || (!identity.displayName && !identity.avatarUrl)) { + return detected + } + + return sanitizeDetectedAccount({ + platformUid: detected.platformUid, + displayName: needsName && identity.displayName ? identity.displayName : detected.displayName, + avatarUrl: needsAvatar && identity.avatarUrl ? identity.avatarUrl : detected.avatarUrl, + }) } async function detectWenxinPlatform( @@ -1470,7 +1515,28 @@ async function detectWenxinPlatform( label: string, context: DetectContext, ): Promise { - return await detectCredentialBackedAIPlatform(platformId, label, context) + const detected = await detectCredentialBackedAIPlatform(platformId, label, context) + if (!detected) { + return null + } + + const placeholderName = `${label} 会话` + const needsName = !detected.displayName || detected.displayName === placeholderName + const needsAvatar = !detected.avatarUrl + if (!needsName && !needsAvatar) { + return detected + } + + const identity = await readWenxinAccountIdentity(context.webContents).catch(() => null) + if (!identity || (!identity.displayName && !identity.avatarUrl)) { + return detected + } + + return sanitizeDetectedAccount({ + platformUid: detected.platformUid, + displayName: needsName && identity.displayName ? identity.displayName : detected.displayName, + avatarUrl: needsAvatar && identity.avatarUrl ? identity.avatarUrl : detected.avatarUrl, + }) } async function detectQwenFromSession( diff --git a/apps/desktop-client/src/main/adapters/deepseek/account-identity.ts b/apps/desktop-client/src/main/adapters/deepseek/account-identity.ts new file mode 100644 index 0000000..8fcb98d --- /dev/null +++ b/apps/desktop-client/src/main/adapters/deepseek/account-identity.ts @@ -0,0 +1,88 @@ +import type { WebContents } from 'electron/main' + +export interface DeepseekAccountIdentityHints { + displayName: string | null + avatarUrl: string | null +} + +const POLL_TIMEOUT_MS = 6_000 +const POLL_INTERVAL_MS = 250 + +function normalizeText(value: unknown): string | null { + if (typeof value !== 'string') return null + const trimmed = value.replace(/ /g, ' ').trim().replace(/\s+/g, ' ') + return trimmed || null +} + +// DeepSeek (chat.deepseek.com) 没有 Redux/Pinia/window state,登录态只有 localStorage +// 里的 `userToken`(JSON 包了 value 字段)。账号信息要走 /api/v0/users/current, +// 用 Authorization: Bearer 调。Cookie 不带 token,因此只能在页面上下文里 +// 执行 fetch(自带 token),返回 biz_data.id_profile.{name, picture}。 +// +// probe 通常在隐藏窗口、刚加载完就跑,React app 可能还没把 userToken 写入 localStorage, +// 所以要 poll 一下直到 token 出现,再调 API。 +export async function readDeepseekAccountIdentity( + webContents: WebContents | null | undefined, +): Promise { + if (!webContents || webContents.isDestroyed()) { + return { displayName: null, avatarUrl: null } + } + + const result = await webContents + .executeJavaScript( + `(async () => { + try { + const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + const readToken = () => { + const raw = window.localStorage.getItem('userToken'); + if (!raw) return null; + try { + const parsed = JSON.parse(raw); + const value = parsed?.value; + return typeof value === 'string' && value ? value : null; + } catch { + return typeof raw === 'string' && raw ? raw : null; + } + }; + + const deadline = Date.now() + ${POLL_TIMEOUT_MS}; + let token = readToken(); + while (!token && Date.now() < deadline) { + await sleep(${POLL_INTERVAL_MS}); + token = readToken(); + } + if (!token) return null; + + const response = await fetch('/api/v0/users/current', { + method: 'GET', + credentials: 'include', + headers: { Authorization: 'Bearer ' + token }, + }); + if (!response.ok) return null; + const payload = await response.json().catch(() => null); + const profile = payload?.data?.biz_data?.id_profile ?? null; + const bizData = payload?.data?.biz_data ?? null; + const name = profile?.name ?? bizData?.email ?? bizData?.mobile_number ?? null; + const picture = profile?.picture ?? null; + return { + displayName: typeof name === 'string' ? name : null, + avatarUrl: typeof picture === 'string' ? picture : null, + }; + } catch { + return null; + } + })();`, + true, + ) + .catch(() => null) + + if (!result || typeof result !== 'object') { + return { displayName: null, avatarUrl: null } + } + + const typed = result as { displayName?: unknown; avatarUrl?: unknown } + return { + displayName: normalizeText(typed.displayName), + avatarUrl: normalizeText(typed.avatarUrl), + } +} diff --git a/apps/desktop-client/src/main/adapters/wenxin/account-identity.ts b/apps/desktop-client/src/main/adapters/wenxin/account-identity.ts new file mode 100644 index 0000000..56dcf68 --- /dev/null +++ b/apps/desktop-client/src/main/adapters/wenxin/account-identity.ts @@ -0,0 +1,150 @@ +import type { WebContents } from 'electron/main' + +export interface WenxinAccountIdentityHints { + displayName: string | null + avatarUrl: string | null +} + +function normalizeText(value: unknown): string | null { + if (typeof value !== 'string') return null + const trimmed = value.replace(/ /g, ' ').trim().replace(/\s+/g, ' ') + return trimmed || null +} + +// 文心一言 (yiyan.baidu.com) 账号信息优先走 GET /eb/user/info,cookie 自带 BDUSS 即可, +// 返回 content.uname (真实账号) + content.fuzzyName (脱敏) + content.portrait (头像), +// 对所有账号通用、不依赖 Redux hydrate 时序。 +// +// API 失败时兜底: +// 1. 读 __NEXT_REDUX_STORE__.updateReducer.userInfo.{uname, fuzzyName, portrait} +// 2. 抓侧栏用户信息块的 DOM +export async function readWenxinAccountIdentity( + webContents: WebContents | null | undefined, +): Promise { + if (!webContents || webContents.isDestroyed()) { + return { displayName: null, avatarUrl: null } + } + + const result = await webContents + .executeJavaScript( + `(async () => { + try { + const normalize = (value) => { + if (typeof value !== 'string') return null; + const trimmed = value.replace(/\\u00a0/g, ' ').trim(); + return trimmed || null; + }; + const isRecord = (value) => + typeof value === 'object' && value !== null && !Array.isArray(value); + const isVisible = (node) => { + if (!(node instanceof HTMLElement)) return false; + const style = window.getComputedStyle(node); + if ( + style.display === 'none' || + style.visibility === 'hidden' || + style.opacity === '0' || + node.hidden + ) { + return false; + } + const rect = node.getBoundingClientRect(); + return rect.width >= 4 && rect.height >= 4; + }; + + let displayName = null; + let avatarUrl = null; + + try { + const response = await fetch('/eb/user/info', { + method: 'GET', + credentials: 'include', + }); + if (response.ok) { + const payload = await response.json().catch(() => null); + const content = payload && payload.ret === 0 ? payload.content : null; + if (isRecord(content) && content.isLogin === true) { + displayName = + normalize(content.uname) ?? normalize(content.fuzzyName); + avatarUrl = normalize(content.portrait); + } + } + } catch { + // fall through to fallbacks + } + + if (!displayName || !avatarUrl) { + const store = window.__NEXT_REDUX_STORE__; + const state = typeof store?.getState === 'function' ? store.getState() : null; + const userInfo = isRecord(state?.updateReducer) ? state.updateReducer.userInfo : null; + if (isRecord(userInfo)) { + displayName = + displayName ?? + normalize(userInfo.uname) ?? + normalize(userInfo.fuzzyName) ?? + normalize(userInfo.userName) ?? + normalize(userInfo.nickName); + avatarUrl = + avatarUrl ?? + normalize(userInfo.portrait) ?? + normalize(userInfo.avatar) ?? + normalize(userInfo.avatarUrl) ?? + normalize(userInfo.headPic) ?? + normalize(userInfo.headUrl); + } + } + + if (!displayName) { + const sidebarSelectors = [ + '[class*="userInfo"] [class*="name"]', + '[class*="user-info"] [class*="name"]', + '[class*="userName"]', + '[class*="user-name"]', + '[class*="nickName"]', + 'aside [class*="user"] [class*="name"]', + ]; + for (const selector of sidebarSelectors) { + const node = document.querySelector(selector); + if (!isVisible(node)) continue; + const text = normalize(node.textContent || ''); + if (!text || text.length < 2 || text.length > 48) continue; + displayName = text; + break; + } + } + + if (!avatarUrl) { + const avatarSelectors = [ + '[class*="userInfo"] img', + '[class*="avatar"] img', + 'aside img[alt]', + ]; + for (const selector of avatarSelectors) { + const node = document.querySelector(selector); + if (!isVisible(node)) continue; + const src = normalize(node && 'src' in node ? node.src : null); + if (!src) continue; + if (/(logo|icon|favicon)/i.test(src)) continue; + avatarUrl = src; + break; + } + } + + return { displayName, avatarUrl }; + } catch { + return null; + } + })();`, + true, + ) + .catch(() => null) + + if (!result || typeof result !== 'object') { + return { displayName: null, avatarUrl: null } + } + + const typed = result as { displayName?: unknown; avatarUrl?: unknown } + return { + displayName: normalizeText(typed.displayName), + avatarUrl: normalizeText(typed.avatarUrl), + } +} diff --git a/apps/desktop-client/src/main/adapters/yuanbao/account-identity.ts b/apps/desktop-client/src/main/adapters/yuanbao/account-identity.ts new file mode 100644 index 0000000..21a4745 --- /dev/null +++ b/apps/desktop-client/src/main/adapters/yuanbao/account-identity.ts @@ -0,0 +1,97 @@ +import type { WebContents } from 'electron/main' + +export interface YuanbaoAccountIdentityHints { + displayName: string | null + avatarUrl: string | null +} + +const POLL_TIMEOUT_MS = 6_000 +const POLL_INTERVAL_MS = 250 + +function normalizeText(value: unknown): string | null { + if (typeof value !== 'string') return null + const trimmed = value.replace(/ /g, ' ').trim().replace(/\s+/g, ' ') + return trimmed || null +} + +// 腾讯元宝 (yuanbao.tencent.com) 没暴露用户信息 API,初始化也不会动态拉取。 +// 但元宝用稳定的 BEM 命名(不是 CSS module hash),从侧栏用户块读: +// - 用户名: .yb-nav__user .nick-info-name (

) +// - 头像: .yb-nav__user .yb-common-nav__ft__avatar img +// probe 通常在隐藏窗口里、刚 navigation 就跑,React 还没把侧栏渲染出来, +// 所以这里要 poll 一下 DOM 直到 .yb-nav__user 渲染出且 .nick-info-name 有文本。 +export async function readYuanbaoAccountIdentity( + webContents: WebContents | null | undefined, +): Promise { + if (!webContents || webContents.isDestroyed()) { + return { displayName: null, avatarUrl: null } + } + + const result = await webContents + .executeJavaScript( + `(async () => { + try { + const normalize = (value) => { + if (typeof value !== 'string') return null; + const trimmed = value.replace(/\\u00a0/g, ' ').trim(); + return trimmed || null; + }; + const isVisible = (node) => { + if (!(node instanceof HTMLElement)) return false; + const style = window.getComputedStyle(node); + if ( + style.display === 'none' || + style.visibility === 'hidden' || + style.opacity === '0' || + node.hidden + ) { + return false; + } + const rect = node.getBoundingClientRect(); + return rect.width >= 4 && rect.height >= 4; + }; + const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + + const deadline = Date.now() + ${POLL_TIMEOUT_MS}; + let displayName = null; + let avatarUrl = null; + + while (Date.now() < deadline) { + const root = document.querySelector('.yb-nav__user'); + if (root) { + const nameNode = root.querySelector('.nick-info-name'); + if (isVisible(nameNode)) { + const text = normalize(nameNode.textContent || ''); + if (text) displayName = text; + } + const avatarImg = root.querySelector('.yb-common-nav__ft__avatar img'); + if (avatarImg && isVisible(avatarImg)) { + const src = normalize(avatarImg.src); + if (src && !/(logo|icon|favicon)/i.test(src)) { + avatarUrl = src; + } + } + } + if (displayName && avatarUrl) break; + await sleep(${POLL_INTERVAL_MS}); + } + + return { displayName, avatarUrl }; + } catch { + return null; + } + })();`, + true, + ) + .catch(() => null) + + if (!result || typeof result !== 'object') { + return { displayName: null, avatarUrl: null } + } + + const typed = result as { displayName?: unknown; avatarUrl?: unknown } + return { + displayName: normalizeText(typed.displayName), + avatarUrl: normalizeText(typed.avatarUrl), + } +}