From ca40657c5a59580fec3877a3e665a7df80fa8895 Mon Sep 17 00:00:00 2001 From: liangxu Date: Thu, 23 Apr 2026 15:26:24 +0800 Subject: [PATCH] feat(desktop): add Wenxin monitoring adapter and surface platform risk control MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces the Wenxin (文心一言) monitoring adapter and registers it in the runtime controller and adapter index. Adds a new risk_control failure classification with custom classifiers for Doubao and Wenxin that recognize rate-limit, 频控 and 访问环境异常 signals, and propagates this state through account-health, runtime activity alerts, and the renderer views so users see "触发风控" instead of a generic challenge message on the Home, Accounts, and AI Platforms screens. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../desktop-client/src/main/account-health.ts | 4 +- .../desktop-client/src/main/adapters/index.ts | 1 + .../src/main/adapters/wenxin.test.ts | 197 ++ .../src/main/adapters/wenxin.ts | 2160 +++++++++++++++++ apps/desktop-client/src/main/auth-types.ts | 3 +- .../src/main/platform-auth-adapters.test.ts | 24 + .../src/main/platform-auth-adapters.ts | 64 +- .../src/main/runtime-controller.ts | 66 +- apps/desktop-client/src/renderer/types.ts | 1 + .../src/renderer/views/AccountsView.vue | 2 +- .../src/renderer/views/AiPlatformsView.vue | 31 +- .../src/renderer/views/HomeView.vue | 20 +- 12 files changed, 2552 insertions(+), 21 deletions(-) create mode 100644 apps/desktop-client/src/main/adapters/wenxin.test.ts create mode 100644 apps/desktop-client/src/main/adapters/wenxin.ts create mode 100644 apps/desktop-client/src/main/platform-auth-adapters.test.ts diff --git a/apps/desktop-client/src/main/account-health.ts b/apps/desktop-client/src/main/account-health.ts index fd98551..fda777f 100644 --- a/apps/desktop-client/src/main/account-health.ts +++ b/apps/desktop-client/src/main/account-health.ts @@ -602,10 +602,10 @@ export async function reportAccountFailure( const previousSignature = snapshotSignature(record); const now = Date.now(); - if (classification === "challenge") { + if (classification === "challenge" || classification === "risk_control") { record.authState = "challenge_required"; record.probeState = "idle"; - record.authReason = "captcha_gate"; + record.authReason = classification === "risk_control" ? "risk_control" : "captcha_gate"; record.lastAuthFailureAt = now; record.lastProbeAt = now; record.nextProbeAt = nextRegularProbeAt(now); diff --git a/apps/desktop-client/src/main/adapters/index.ts b/apps/desktop-client/src/main/adapters/index.ts index b8bce7f..7ecfc1f 100644 --- a/apps/desktop-client/src/main/adapters/index.ts +++ b/apps/desktop-client/src/main/adapters/index.ts @@ -3,5 +3,6 @@ export * from "./doubao"; export * from "./kimi"; export * from "./qwen"; export * from "./toutiao"; +export * from "./wenxin"; export * from "./yuanbao"; export * from "./zhihu"; diff --git a/apps/desktop-client/src/main/adapters/wenxin.test.ts b/apps/desktop-client/src/main/adapters/wenxin.test.ts new file mode 100644 index 0000000..9232001 --- /dev/null +++ b/apps/desktop-client/src/main/adapters/wenxin.test.ts @@ -0,0 +1,197 @@ +import { describe, expect, it } from "vitest"; + +import { __wenxinTestUtils } from "./wenxin"; + +const { + htmlTableToMarkdown, + isObservationComplete, + parseWenxinHistoryResponse, + parseWenxinSSEBody, +} = __wenxinTestUtils; + +describe("wenxin adapter helpers", () => { + it("parses conversation stream major and message events", () => { + const sseBody = [ + "event:major", + "data:{\"logId\":\"major-log\",\"data\":{\"createChatResponseVoCommonResult\":{\"data\":{\"chat\":{\"id\":\"user-1\",\"message\":[{\"content\":\"问题\"}]},\"botChat\":{\"id\":\"bot-1\",\"message\":[{\"content\":\"正在生成中...\"}],\"messageId\":\"msg-1\",\"modelSign\":\"EB45T\"}}},\"createSessionResponseVoCommonResult\":{\"data\":{\"sessionId\":\"session-1\",\"model\":\"EB45T\"}}}}", + "", + "event:message", + "data:{\"code\":0,\"data\":{\"chat_id\":\"bot-1\",\"content\":\"第一段\",\"is_end\":false}}", + "", + "event:message", + "data:{\"code\":0,\"data\":{\"chat_id\":\"bot-1\",\"tokens_all\":\"第一段\\n第二段\",\"is_end\":true}}", + ].join("\n"); + + expect(parseWenxinSSEBody(sseBody, true, null)).toMatchObject({ + sessionId: "session-1", + botChatId: "bot-1", + providerModel: "EB45T", + providerRequestID: "msg-1", + answer: "第一段\n第二段", + isEnded: true, + captureDone: true, + }); + }); + + it("extracts withdraw text from stream", () => { + const sseBody = [ + "event:major", + "data:{\"data\":{\"createChatResponseVoCommonResult\":{\"data\":{\"isWithdraw\":true,\"withdrawText\":\"当前访问环境存在异常,请更换浏览器再尝试提问\",\"sessionDel\":true}}}}", + "", + "ERR:AbortError: BodyStreamBuffer was aborted", + ].join("\n"); + + expect(parseWenxinSSEBody(sseBody, true, "AbortError: BodyStreamBuffer was aborted")).toMatchObject({ + withdrawText: "当前访问环境存在异常,请更换浏览器再尝试提问", + sessionDel: true, + }); + }); + + it("converts html tables to markdown", () => { + const markdown = htmlTableToMarkdown("
品牌均价
A999
"); + + expect(markdown).toBe( + [ + "| 品牌 | 均价 |", + "| --- | --- |", + "| A | 999 |", + ].join("\n"), + ); + }); + + it("extracts answer text, citations, search results, and tables from history payload", () => { + const history = parseWenxinHistoryResponse({ + code: 0, + msg: "success", + data: { + state: 1, + chats: [ + { + id: "user-1", + role: "user", + message: [ + { + contentType: "text", + content: "问题", + }, + ], + }, + { + id: "bot-1", + role: "robot", + stop: 1, + mode: "history", + modelSign: "EB45T", + messageId: "msg-1", + citations: [ + { + url: "https://example.com/article", + title: "示例文章", + siteName: "示例站点", + }, + ], + searchCitations: { + total: 1, + list: [ + { + url: "https://search.example.com/result", + title: "搜索结果", + site: "搜索站点", + }, + ], + }, + message: [ + { + contentType: "text", + content: "推荐如下:", + }, + { + contentType: "table", + content: "
品牌价格
A1000
", + }, + ], + }, + ], + }, + }, "session-1"); + + expect(history.answer).toContain("推荐如下:"); + expect(history.answer).toContain("| 品牌 | 价格 |"); + expect(history.citations).toHaveLength(2); + expect(history.searchResults).toHaveLength(1); + expect(history.providerModel).toBe("EB45T"); + expect(history.providerRequestID).toBe("msg-1"); + expect(history.generating).toBe(false); + }); + + it("falls back to nested web list sources when search citations are not on the latest chat root", () => { + const history = parseWenxinHistoryResponse({ + code: 0, + msg: "success", + data: { + state: 1, + webListData: { + list: [ + { + url: "https://fallback.example.com/article", + title: "回退网页", + name: "回退站点", + }, + ], + }, + chats: [ + { + id: "bot-1", + role: "robot", + stop: 1, + mode: "history", + modelSign: "EB45T", + messageId: "msg-2", + message: [ + { + contentType: "text", + content: "这是答案", + }, + ], + }, + ], + }, + }, "session-2"); + + expect(history.searchResults).toHaveLength(1); + expect(history.searchResults[0]?.url).toBe("https://fallback.example.com/article"); + expect(history.citations[0]?.url).toBe("https://fallback.example.com/article"); + }); + + it("does not treat error-only observations as complete", () => { + expect(isObservationComplete({ + sessionId: "session-1", + providerModel: "EB45T", + providerRequestID: "msg-1", + answer: null, + answerBlocks: [], + citations: [], + searchResults: [], + withdrawText: null, + errorMessage: "temporary stream issue", + historyGenerating: false, + streamDone: false, + signature: "sig-1", + })).toBe(false); + + expect(isObservationComplete({ + sessionId: "session-1", + providerModel: "EB45T", + providerRequestID: "msg-1", + answer: null, + answerBlocks: [], + citations: [], + searchResults: [], + withdrawText: "内容暂不可用", + errorMessage: null, + historyGenerating: false, + streamDone: false, + signature: "sig-2", + })).toBe(false); + }); +}); diff --git a/apps/desktop-client/src/main/adapters/wenxin.ts b/apps/desktop-client/src/main/adapters/wenxin.ts new file mode 100644 index 0000000..5d28fe9 --- /dev/null +++ b/apps/desktop-client/src/main/adapters/wenxin.ts @@ -0,0 +1,2160 @@ +import type { JsonValue, MonitoringSourceItem } from "@geo/shared-types"; +import type { Session } from "electron/main"; +import type { Page as PlaywrightPage } from "playwright-core"; + +import { + normalizeText, + sessionCookieFetchJson, +} from "./common"; +import type { MonitorAdapter } from "./base"; + +const WENXIN_BOOTSTRAP_URL = "https://yiyan.baidu.com/"; +const WENXIN_HISTORY_URL = "https://yiyan.baidu.com/eb/chat/history"; +const WENXIN_PAGE_READY_TIMEOUT_MS = 20_000; +const WENXIN_QUERY_TIMEOUT_MS = 180_000; +const WENXIN_QUERY_POLL_INTERVAL_MS = 1_250; +const WENXIN_STABLE_POLLS_REQUIRED = 3; +const WENXIN_STREAM_CAPTURE_LIMIT = 8; +const WENXIN_STREAM_CAPTURE_BODY_LIMIT = 800_000; +const WENXIN_EDITOR_SELECTOR = "div[role=\"textbox\"].editable__T7WAW4uW, div[role=\"textbox\"], [contenteditable=\"true\"]"; +const WENXIN_LOGIN_SIGNALS = [ + "未登录", + "请先登录", + "登录", + "扫码登录", + "百度账号登录", +]; +const WENXIN_RISK_CONTROL_PATTERN = /(当前访问环境存在异常|访问环境存在异常|环境存在异常|更换浏览器再尝试提问|当前环境异常)/i; +const WENXIN_LOGIN_REQUIRED_PATTERN = /(请先登录|未登录|登录后|登录态失效|重新登录)/i; +const WENXIN_PLACEHOLDER_PATTERN = /^(?:正在生成中(?:\.\.\.)?|思考中(?:\.\.\.)?|内容由AI生成,仅供参考,请仔细甄别|全选)$/; +const WENXIN_TABLE_HTML_PATTERN = //gi; +const WENXIN_REFERENCE_TRIGGER_PATTERN = /(参考\s*\d+\s*(?:个网页|条网页信息源)|found\s*\d+\s*web pages)/i; +const WENXIN_CITATION_FIELD_PATTERN = /^(?:citations?|citationInfo|pluginCitations?|pluginCitation|reference(?:s|List|_list|Cards|_cards|Docs|_docs)?|source(?:s|List|_list|Cards|_cards)?)$/i; +const WENXIN_SEARCH_FIELD_PATTERN = /^(?:searchCitations?|search_results?|searchResultList|search_result_list|webListData|webList|browseReferences?|browse_results?|grounding(?:_results?)?)$/i; + +type WenxinPageSourceLink = { + url: string; + title: string | null; + text: string | null; + siteName: string | null; +}; + +type WenxinPageSnapshot = { + url: string; + title: string | null; + loginRequired: boolean; + loginReason: string | null; + isLogin: boolean | null; + modelLabel: string | null; + userName: string | null; + editorText: string | null; + bodySample: string | null; + referenceTriggerText: string | null; + referenceLinks: WenxinPageSourceLink[]; +}; + +type WenxinCapturedStreamRecord = { + id: string; + url: string; + method: string; + status: number | null; + contentType: string | null; + requestBody: string | null; + body: string; + done: boolean; + error: string | null; + startedAt: number; + updatedAt: number; +}; + +type WenxinStreamSummary = { + sessionId: string | null; + userChatId: string | null; + botChatId: string | null; + providerModel: string | null; + providerRequestID: string | null; + answer: string | null; + withdrawText: string | null; + errorMessage: string | null; + isBanned: boolean; + sessionBan: boolean; + sessionDel: boolean; + isEnded: boolean; + eventCount: number; + captureDone: boolean; + captureError: string | null; +}; + +type WenxinHistorySummary = { + sessionId: string | null; + loginRequired: boolean; + providerModel: string | null; + providerRequestID: string | null; + latestChatId: string | null; + latestChatMode: string | null; + latestChatStop: number | boolean | null; + state: number | string | null; + answer: string | null; + answerBlocks: string[]; + citations: MonitoringSourceItem[]; + searchResults: MonitoringSourceItem[]; + withdrawText: string | null; + errorMessage: string | null; + generating: boolean; + signature: string | null; +}; + +type WenxinObservation = { + sessionId: string | null; + providerModel: string | null; + providerRequestID: string | null; + answer: string | null; + answerBlocks: string[]; + citations: MonitoringSourceItem[]; + searchResults: MonitoringSourceItem[]; + withdrawText: string | null; + errorMessage: string | null; + historyGenerating: boolean; + streamDone: boolean; + signature: string | null; +}; + +type WenxinWaitResult = + | { + ok: true; + stream: WenxinStreamSummary; + history: WenxinHistorySummary | null; + observation: WenxinObservation; + stablePollCount: number; + elapsedMs: number; + } + | { + ok: false; + error: string; + detail: string | null; + stream: WenxinStreamSummary; + history: WenxinHistorySummary | null; + observation: WenxinObservation; + stablePollCount: number; + elapsedMs: number; + }; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function normalizeOptionalString(value: unknown): string | null { + if (typeof value !== "string") { + return null; + } + + const trimmed = value.trim(); + return trimmed ? trimmed : null; +} + +function normalizeBlockText(value: string | null): string | null { + const normalized = normalizeOptionalString(value); + if (!normalized) { + return null; + } + + return normalized + .replace(/\u00a0/g, " ") + .replace(/\r/g, "") + .replace(/\n{3,}/g, "\n\n") + .trim() || null; +} + +function normalizeUrl(value: unknown): string | null { + const input = normalizeText(value); + if (!input) { + return null; + } + + try { + const url = new URL(input); + url.hash = ""; + return url.toString(); + } catch { + return null; + } +} + +function isLikelyWenxinSourceUrl(url: string | null): boolean { + if (!url) { + return false; + } + + try { + const parsed = new URL(url); + const host = parsed.hostname.toLowerCase(); + if (!/^https?:$/i.test(parsed.protocol)) { + return false; + } + if ( + host === "yiyan.baidu.com" + || host.endsWith(".yiyan.baidu.com") + || host === "eb-static.cdn.bcebos.com" + || host === "ppui-static-wap.cdn.bcebos.com" + ) { + return false; + } + return true; + } catch { + return false; + } +} + +function readStringByKeys(source: unknown, keys: string[]): string | null { + if (!isRecord(source)) { + return null; + } + + for (const key of keys) { + const value = normalizeOptionalString(source[key]); + if (value) { + return value; + } + } + + return null; +} + +function readBooleanByKeys(source: unknown, keys: string[]): boolean | null { + if (!isRecord(source)) { + return null; + } + + for (const key of keys) { + const value = source[key]; + if (typeof value === "boolean") { + return value; + } + } + + return null; +} + +function readNumberByKeys(source: unknown, keys: string[]): number | null { + if (!isRecord(source)) { + return null; + } + + for (const key of keys) { + const value = source[key]; + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + } + + return null; +} + +function normalizeStopFlag(value: unknown): number | boolean | null { + if (typeof value === "boolean") { + return value; + } + + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + + if (typeof value === "string") { + const trimmed = value.trim().toLowerCase(); + if (!trimmed) { + return null; + } + if (trimmed === "true") { + return true; + } + if (trimmed === "false") { + return false; + } + + const numericValue = Number(trimmed); + if (Number.isFinite(numericValue)) { + return numericValue; + } + } + + return null; +} + +function mergeText(current: string | null, next: string | null): string | null { + const left = normalizeBlockText(current); + const right = normalizeBlockText(next); + if (!left) { + return right; + } + if (!right) { + return left; + } + + if (right.startsWith(left)) { + return right; + } + if (left.startsWith(right)) { + return left; + } + if (left.endsWith(right)) { + return left; + } + if (right.endsWith(left)) { + return right; + } + + return `${left}${right}`; +} + +function safeParseJSON(value: string): unknown { + try { + return JSON.parse(value); + } catch { + return null; + } +} + +function dedupeSourceItems(items: MonitoringSourceItem[]): MonitoringSourceItem[] { + const keyed = new Map(); + for (const item of items) { + const key = normalizeUrl(item.normalized_url ?? item.url); + if (!key) { + continue; + } + + const existing = keyed.get(key); + if (!existing) { + keyed.set(key, { + ...item, + normalized_url: key, + }); + continue; + } + + keyed.set(key, { + ...existing, + title: existing.title ?? item.title ?? null, + site_name: existing.site_name ?? item.site_name ?? null, + host: existing.host ?? item.host ?? null, + }); + } + + return Array.from(keyed.values()); +} + +function buildSourceItem(input: unknown): MonitoringSourceItem | null { + if (!isRecord(input)) { + return null; + } + + const url = normalizeUrl( + input.url + ?? input.href + ?? input.link + ?? input.originUrl + ?? input.origin_url + ?? input.targetUrl + ?? input.target_url + ?? input.sourceUrl + ?? input.source_url, + ); + if (!url || !isLikelyWenxinSourceUrl(url)) { + return null; + } + + let host: string | null = null; + try { + host = new URL(url).hostname || null; + } catch { + host = null; + } + + return { + url, + title: normalizeText( + input.title + ?? input.name + ?? input.text + ?? input.label + ?? input.desc, + ), + site_name: normalizeText( + input.site_name + ?? input.siteName + ?? input.site + ?? input.domain + ?? input.source, + ), + normalized_url: url, + host, + }; +} + +function collectSourceBucketByFieldPattern( + input: unknown, + bucket: MonitoringSourceItem[], + pattern: RegExp, + depth = 0, +): void { + if (depth > 10 || input == null) { + return; + } + + if (Array.isArray(input)) { + for (const item of input) { + collectSourceBucketByFieldPattern(item, bucket, pattern, depth + 1); + } + return; + } + + if (!isRecord(input)) { + return; + } + + for (const [key, value] of Object.entries(input)) { + if (pattern.test(key)) { + collectSourceBucket(value, bucket, depth + 1); + } + + if (Array.isArray(value) || isRecord(value)) { + collectSourceBucketByFieldPattern(value, bucket, pattern, depth + 1); + } + } +} + +function collectSourceBucket(input: unknown, bucket: MonitoringSourceItem[], depth = 0): void { + if (depth > 10 || input == null) { + return; + } + + if (Array.isArray(input)) { + for (const item of input) { + collectSourceBucket(item, bucket, depth + 1); + } + return; + } + + const sourceItem = buildSourceItem(input); + if (sourceItem) { + bucket.push(sourceItem); + } + + if (!isRecord(input)) { + return; + } + + for (const value of Object.values(input)) { + if (Array.isArray(value) || isRecord(value)) { + collectSourceBucket(value, bucket, depth + 1); + } + } +} + +function decodeHtmlEntities(value: string): string { + const namedEntityMap: Record = { + amp: "&", + lt: "<", + gt: ">", + quot: "\"", + apos: "'", + nbsp: " ", + mdash: "-", + ndash: "-", + }; + + return value.replace(/&(#x?[0-9a-f]+|[a-z]+);/gi, (matched, entity: string) => { + const lower = entity.toLowerCase(); + if (lower in namedEntityMap) { + return namedEntityMap[lower]; + } + + if (lower.startsWith("#x")) { + const codePoint = Number.parseInt(lower.slice(2), 16); + return Number.isFinite(codePoint) ? String.fromCodePoint(codePoint) : matched; + } + + if (lower.startsWith("#")) { + const codePoint = Number.parseInt(lower.slice(1), 10); + return Number.isFinite(codePoint) ? String.fromCodePoint(codePoint) : matched; + } + + return matched; + }); +} + +function stripHtml(value: string): string { + return decodeHtmlEntities( + value + .replace(//gi, "\n") + .replace(/<\/(?:p|div|section|article|li|ul|ol|h[1-6])>/gi, "\n") + .replace(/]*>/gi, "- ") + .replace(/<[^>]+>/g, " "), + ) + .replace(/[ \t]+\n/g, "\n") + .replace(/\n[ \t]+/g, "\n") + .replace(/[ \t]{2,}/g, " "); +} + +function htmlTableToMarkdown(tableHtml: string): string | null { + const rowMatches = [...tableHtml.matchAll(/]*>([\s\S]*?)<\/tr>/gi)]; + if (!rowMatches.length) { + return null; + } + + const rows = rowMatches + .map((match) => [...match[1].matchAll(/]*>([\s\S]*?)<\/t[hd]>/gi)]) + .map((cells) => + cells + .map((cell) => normalizeBlockText(stripHtml(cell[1] ?? "")) ?? "") + .map((cell) => cell.replace(/\|/g, "\\|").replace(/\n+/g, "
")), + ) + .filter((cells) => cells.some((cell) => cell.length > 0)); + + if (!rows.length) { + return null; + } + + const header = rows[0]; + const body = rows.slice(1); + const separator = header.map(() => "---"); + const lines = [ + `| ${header.join(" | ")} |`, + `| ${separator.join(" | ")} |`, + ...body.map((row) => `| ${row.join(" | ")} |`), + ]; + return normalizeBlockText(lines.join("\n")); +} + +function textSegmentsFromHtml(value: string): string[] { + const result: string[] = []; + const push = (segment: string | null) => { + const normalized = normalizeBlockText(segment); + if (normalized) { + result.push(normalized); + } + }; + + const tables = [...value.matchAll(WENXIN_TABLE_HTML_PATTERN)]; + for (const match of tables) { + push(htmlTableToMarkdown(match[0])); + } + + const withoutTables = value.replace(WENXIN_TABLE_HTML_PATTERN, "\n"); + push(stripHtml(withoutTables)); + return result; +} + +function extractTextSegments(value: unknown, result: string[], seen: Set, depth = 0): void { + if (depth > 6 || value == null) { + return; + } + + const push = (segment: string | null) => { + const normalized = normalizeBlockText(segment); + if (!normalized || WENXIN_PLACEHOLDER_PATTERN.test(normalized)) { + return; + } + + const key = normalized.replace(/\s+/g, " "); + if (seen.has(key)) { + return; + } + seen.add(key); + result.push(normalized); + }; + + if (typeof value === "string") { + if (/<[a-z][\s\S]*>/i.test(value)) { + for (const segment of textSegmentsFromHtml(value)) { + push(segment); + } + return; + } + push(value); + return; + } + + if (Array.isArray(value)) { + for (const item of value) { + extractTextSegments(item, result, seen, depth + 1); + } + return; + } + + if (!isRecord(value)) { + return; + } + + for (const key of ["content", "text", "tips", "quoteText", "markdown", "html"]) { + if (key in value) { + extractTextSegments(value[key], result, seen, depth + 1); + } + } + + for (const key of ["htmlMetaInfo", "metaData", "resInfo"]) { + if (key in value) { + extractTextSegments(value[key], result, seen, depth + 1); + } + } +} + +function extractWenxinMessageBlocks(messages: unknown): string[] { + const result: string[] = []; + const seen = new Set(); + + if (Array.isArray(messages)) { + for (const message of messages) { + extractTextSegments(message, result, seen); + } + return result; + } + + extractTextSegments(messages, result, seen); + return result; +} + +function answerQualityScore(value: string | null): number { + const normalized = normalizeBlockText(value); + if (!normalized) { + return Number.NEGATIVE_INFINITY; + } + + let score = normalized.length; + if (/\| .+ \|/.test(normalized)) { + score += 120; + } + if (/(?:^|\n)(?:[-*+]\s+|\d+\.\s+|#{1,6}\s+)/m.test(normalized)) { + score += 40; + } + if (WENXIN_PLACEHOLDER_PATTERN.test(normalized)) { + score -= 500; + } + return score; +} + +function selectBestAnswer(primary: string | null, secondary: string | null): string | null { + const first = normalizeBlockText(primary); + const second = normalizeBlockText(secondary); + if (!first) { + return second; + } + if (!second) { + return first; + } + + return answerQualityScore(second) >= answerQualityScore(first) ? second : first; +} + +function extractQuestionText(payload: Record): string { + const candidates = [ + payload.question_text, + payload.questionText, + payload.query, + payload.question, + payload.prompt, + payload.content, + payload.title, + ]; + + for (const candidate of candidates) { + const text = normalizeOptionalString(candidate); + if (text) { + return text; + } + } + + throw new Error("wenxin_question_text_missing"); +} + +function toJsonValue(value: unknown, depth = 0): JsonValue { + if (value == null || depth > 12) { + return null; + } + + if (typeof value === "string" || typeof value === "boolean") { + return value; + } + + if (typeof value === "number") { + return Number.isFinite(value) ? value : null; + } + + if (Array.isArray(value)) { + return value.map((item) => toJsonValue(item, depth + 1)); + } + + if (!isRecord(value)) { + return null; + } + + const result: Record = {}; + for (const [key, item] of Object.entries(value)) { + result[key] = toJsonValue(item, depth + 1); + } + return result; +} + +function toJsonSources(items: MonitoringSourceItem[]): JsonValue[] { + return items.map((item) => ({ + url: item.url, + title: item.title ?? null, + site_name: item.site_name ?? null, + site_key: item.site_key ?? null, + normalized_url: item.normalized_url ?? null, + host: item.host ?? null, + registrable_domain: item.registrable_domain ?? null, + subdomain: item.subdomain ?? null, + suffix: item.suffix ?? null, + article_id: item.article_id ?? null, + publish_record_id: item.publish_record_id ?? null, + resolution_status: item.resolution_status ?? null, + resolution_confidence: item.resolution_confidence ?? null, + })); +} + +function buildAdapterError( + code: string, + message: string, + extras: Record = {}, +): Record { + return { + code, + message, + ...extras, + }; +} + +function emptyWenxinStreamSummary(): WenxinStreamSummary { + return { + sessionId: null, + userChatId: null, + botChatId: null, + providerModel: null, + providerRequestID: null, + answer: null, + withdrawText: null, + errorMessage: null, + isBanned: false, + sessionBan: false, + sessionDel: false, + isEnded: false, + eventCount: 0, + captureDone: false, + captureError: null, + }; +} + +function parseSSERecord(record: string): { event: string; payload: unknown } | null { + const lines = record.split(/\r?\n/); + let eventName = "message"; + const dataLines: string[] = []; + + for (const line of lines) { + if (!line || line.startsWith(":") || line.startsWith("ERR:")) { + continue; + } + if (line.startsWith("event:")) { + eventName = line.slice(6).trim() || eventName; + continue; + } + if (line.startsWith("data:")) { + dataLines.push(line.slice(5).trimStart()); + } + } + + if (!dataLines.length) { + return null; + } + + return { + event: eventName, + payload: safeParseJSON(dataLines.join("\n")), + }; +} + +function parseWenxinSSEBody(body: string, captureDone = false, captureError: string | null = null): WenxinStreamSummary { + const summary = emptyWenxinStreamSummary(); + summary.captureDone = captureDone; + summary.captureError = captureError; + + if (!body) { + return summary; + } + + const records = body + .split(/\r?\n\r?\n+/) + .map((record) => record.trim()) + .filter(Boolean); + + for (const record of records) { + const parsed = parseSSERecord(record); + if (!parsed || !isRecord(parsed.payload)) { + continue; + } + + summary.eventCount += 1; + + if (parsed.event === "major") { + const payloadData = isRecord(parsed.payload.data) ? parsed.payload.data : null; + const createChat = isRecord(payloadData?.createChatResponseVoCommonResult) + ? payloadData.createChatResponseVoCommonResult + : null; + const createChatData = isRecord(createChat?.data) ? createChat.data : null; + const createSession = isRecord(payloadData?.createSessionResponseVoCommonResult) + ? payloadData.createSessionResponseVoCommonResult + : null; + const createSessionData = isRecord(createSession?.data) ? createSession.data : null; + const botChat = isRecord(createChatData?.botChat) ? createChatData.botChat : null; + const userChat = isRecord(createChatData?.chat) ? createChatData.chat : null; + + summary.sessionId = readStringByKeys(createSessionData, ["sessionId"]) ?? summary.sessionId; + summary.userChatId = readStringByKeys(userChat, ["id"]) ?? summary.userChatId; + summary.botChatId = readStringByKeys(botChat, ["id"]) ?? summary.botChatId; + summary.providerModel = + readStringByKeys(createSessionData, ["model"]) + ?? readStringByKeys(botChat, ["modelSign", "model"]) + ?? summary.providerModel; + summary.providerRequestID = + readStringByKeys(botChat, ["messageId"]) + ?? readStringByKeys(createChat, ["logId"]) + ?? readStringByKeys(parsed.payload, ["logId"]) + ?? summary.providerRequestID; + summary.withdrawText = + readStringByKeys(createChatData, ["withdrawText"]) + ?? summary.withdrawText; + summary.errorMessage = + readStringByKeys(createChatData, ["errMessage"]) + ?? readStringByKeys(createChat, ["msg"]) + ?? summary.errorMessage; + summary.isBanned = readBooleanByKeys(createChatData, ["isBanned"]) ?? summary.isBanned; + summary.sessionBan = readBooleanByKeys(createChatData, ["sessionBan"]) ?? summary.sessionBan; + summary.sessionDel = readBooleanByKeys(createChatData, ["sessionDel"]) ?? summary.sessionDel; + + const initialAnswer = extractWenxinMessageBlocks(botChat?.message).join("\n\n"); + summary.answer = selectBestAnswer(summary.answer, initialAnswer); + continue; + } + + if (parsed.event === "message") { + const payloadData = isRecord(parsed.payload.data) ? parsed.payload.data : null; + const chatID = readStringByKeys(payloadData, ["chat_id", "chatId"]); + if (summary.botChatId && chatID && summary.botChatId !== chatID) { + continue; + } + if (!summary.botChatId && chatID) { + summary.botChatId = chatID; + } + + const tokensAll = readStringByKeys(payloadData, ["tokens_all"]); + const delta = readStringByKeys(payloadData, ["content", "text"]); + summary.answer = tokensAll ? selectBestAnswer(summary.answer, tokensAll) : mergeText(summary.answer, delta); + summary.isEnded = readBooleanByKeys(payloadData, ["is_end"]) ?? summary.isEnded; + + const messageText = + readStringByKeys(parsed.payload, ["msg"]) + ?? readStringByKeys(payloadData, ["msg", "message", "detail"]); + const code = readNumberByKeys(parsed.payload, ["code"]); + if (code !== null && code !== 0 && messageText) { + summary.errorMessage = messageText; + } + } + } + + if (!summary.errorMessage && captureError && !/AbortError/i.test(captureError)) { + summary.errorMessage = captureError; + } + + return summary; +} + +function findLatestRobotChat(chats: unknown[]): Record | null { + for (let index = chats.length - 1; index >= 0; index -= 1) { + const item = chats[index]; + if (!isRecord(item)) { + continue; + } + + const role = readStringByKeys(item, ["role"]); + if (role !== "robot") { + continue; + } + + const parent = readStringByKeys(item, ["parent"]); + if (parent === "0" && index < chats.length - 1) { + continue; + } + + return item; + } + + return null; +} + +function parseWenxinHistoryResponse(payload: unknown, sessionId: string | null = null): WenxinHistorySummary { + const code = readNumberByKeys(payload, ["code"]); + const message = readStringByKeys(payload, ["msg"]); + if (code === 1001 || WENXIN_LOGIN_REQUIRED_PATTERN.test(message ?? "")) { + return { + sessionId, + loginRequired: true, + providerModel: null, + providerRequestID: null, + latestChatId: null, + latestChatMode: null, + latestChatStop: null, + state: null, + answer: null, + answerBlocks: [], + citations: [], + searchResults: [], + withdrawText: null, + errorMessage: message ?? "请先登录", + generating: false, + signature: null, + }; + } + + const data = isRecord(payload) && isRecord(payload.data) ? payload.data : null; + const chats = Array.isArray(data?.chats) ? data.chats : []; + const latestChat = findLatestRobotChat(chats); + const answerBlocks = extractWenxinMessageBlocks(latestChat?.message); + const answer = normalizeBlockText(answerBlocks.join("\n\n")); + + const citationBucket: MonitoringSourceItem[] = []; + collectSourceBucketByFieldPattern(latestChat, citationBucket, WENXIN_CITATION_FIELD_PATTERN); + if (!citationBucket.length) { + collectSourceBucketByFieldPattern(data, citationBucket, WENXIN_CITATION_FIELD_PATTERN); + } + + const searchBucket: MonitoringSourceItem[] = []; + collectSourceBucketByFieldPattern(latestChat, searchBucket, WENXIN_SEARCH_FIELD_PATTERN); + if (!searchBucket.length) { + collectSourceBucketByFieldPattern(data, searchBucket, WENXIN_SEARCH_FIELD_PATTERN); + } + + const citations = dedupeSourceItems([ + ...citationBucket, + ...searchBucket, + ]); + const searchResults = dedupeSourceItems(searchBucket); + const withdrawText = + readStringByKeys(latestChat, ["withdrawText"]) + ?? readStringByKeys(data, ["withdrawText"]); + const errorMessage = + readStringByKeys(latestChat, ["errMessage"]) + ?? (code !== null && code !== 0 ? message : null); + const latestChatStop = normalizeStopFlag(latestChat?.stop); + const latestChatMode = readStringByKeys(latestChat, ["mode"]); + const state = data?.state ?? null; + const generating = + latestChatStop === 0 + || (answer !== null && WENXIN_PLACEHOLDER_PATTERN.test(answer)) + || (!answer && !withdrawText && !errorMessage && !citations.length && !searchResults.length && Boolean(latestChat)); + + return { + sessionId, + loginRequired: false, + providerModel: readStringByKeys(latestChat, ["modelSign", "model"]), + providerRequestID: readStringByKeys(latestChat, ["messageId"]) ?? readStringByKeys(payload, ["logId"]), + latestChatId: readStringByKeys(latestChat, ["id"]), + latestChatMode, + latestChatStop, + state: typeof state === "number" || typeof state === "string" ? state : null, + answer, + answerBlocks, + citations, + searchResults, + withdrawText, + errorMessage, + generating, + signature: JSON.stringify({ + latestChatId: readStringByKeys(latestChat, ["id"]), + latestChatMode, + latestChatStop, + state: typeof state === "number" || typeof state === "string" ? state : null, + answer, + citationCount: citations.length, + searchCount: searchResults.length, + withdrawText, + errorMessage, + }), + }; +} + +function mergeWenxinObservation( + stream: WenxinStreamSummary, + history: WenxinHistorySummary | null, +): WenxinObservation { + const citations = dedupeSourceItems([ + ...(history?.citations ?? []), + ]); + const searchResults = dedupeSourceItems([ + ...(history?.searchResults ?? []), + ]); + const answer = selectBestAnswer(stream.answer, history?.answer ?? null); + const answerBlocks = history?.answerBlocks.length + ? history.answerBlocks + : answer + ? [answer] + : []; + const withdrawText = history?.withdrawText ?? stream.withdrawText; + const errorMessage = history?.errorMessage ?? stream.errorMessage ?? stream.captureError; + + return { + sessionId: history?.sessionId ?? stream.sessionId, + providerModel: history?.providerModel ?? stream.providerModel, + providerRequestID: history?.providerRequestID ?? stream.providerRequestID, + answer, + answerBlocks, + citations, + searchResults, + withdrawText, + errorMessage, + historyGenerating: history?.generating ?? false, + streamDone: stream.captureDone || stream.isEnded, + signature: JSON.stringify({ + sessionId: history?.sessionId ?? stream.sessionId, + answer, + answerBlockCount: answerBlocks.length, + citationCount: citations.length, + searchCount: searchResults.length, + withdrawText, + errorMessage, + historyGenerating: history?.generating ?? false, + streamDone: stream.captureDone || stream.isEnded, + }), + }; +} + +function isWenxinRiskControlMessage(value: string | null | undefined): boolean { + return WENXIN_RISK_CONTROL_PATTERN.test(value ?? ""); +} + +function isWenxinLoginMessage(value: string | null | undefined): boolean { + return WENXIN_LOGIN_REQUIRED_PATTERN.test(value ?? ""); +} + +function isObservationComplete(observation: WenxinObservation): boolean { + if (observation.historyGenerating) { + return false; + } + + if (observation.answer && WENXIN_PLACEHOLDER_PATTERN.test(observation.answer)) { + return false; + } + + return Boolean( + observation.answer + || observation.citations.length + || observation.searchResults.length, + ); +} + +function safePageURL(page: PlaywrightPage): string { + try { + return page.url(); + } catch { + return ""; + } +} + +async function sleep(ms: number, signal?: AbortSignal): Promise { + await new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(new Error("adapter_aborted")); + return; + } + + const timer = setTimeout(() => { + signal?.removeEventListener("abort", onAbort); + resolve(); + }, ms); + + const onAbort = () => { + clearTimeout(timer); + signal?.removeEventListener("abort", onAbort); + reject(new Error("adapter_aborted")); + }; + + signal?.addEventListener("abort", onAbort, { once: true }); + }); +} + +async function ensurePageOnWenxinChat(page: PlaywrightPage, signal: AbortSignal): Promise { + if (signal.aborted) { + throw new Error("adapter_aborted"); + } + + await page.goto(WENXIN_BOOTSTRAP_URL, { + waitUntil: "domcontentloaded", + timeout: WENXIN_PAGE_READY_TIMEOUT_MS, + }); + + await page.waitForLoadState("domcontentloaded", { + timeout: WENXIN_PAGE_READY_TIMEOUT_MS, + }).catch(() => undefined); + await page.waitForLoadState("networkidle", { + timeout: 3_000, + }).catch(() => undefined); +} + +async function readWenxinPageSnapshot(page: PlaywrightPage): Promise { + return await page.evaluate( + ({ editorSelector, loginSignals, triggerPatternSource, citationFieldPatternSource, searchFieldPatternSource }) => { + const isObjectRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + + const normalize = (value: unknown): string | null => { + if (typeof value !== "string") { + return null; + } + const trimmed = value + .replace(/\u00a0/g, " ") + .replace(/\r/g, "") + .replace(/\n{3,}/g, "\n\n") + .trim(); + return trimmed || null; + }; + + const normalizeHref = (value: unknown): string | null => { + if (typeof value !== "string") { + return null; + } + + const trimmed = value.trim(); + if (!trimmed || /^(?:javascript|data|blob|mailto|tel):/i.test(trimmed)) { + return null; + } + + try { + const url = new URL(trimmed, window.location.href); + url.hash = ""; + return url.toString(); + } catch { + return null; + } + }; + + const isLikelySourceUrl = (value: string | null): value is string => { + if (!value) { + return false; + } + + try { + const url = new URL(value); + const host = url.hostname.toLowerCase(); + if (!/^https?:$/i.test(url.protocol)) { + return false; + } + if ( + host === window.location.hostname.toLowerCase() + || host.endsWith(".yiyan.baidu.com") + || host === "eb-static.cdn.bcebos.com" + || host === "ppui-static-wap.cdn.bcebos.com" + ) { + return false; + } + return true; + } catch { + return false; + } + }; + + const buildSourceLink = (input: unknown) => { + if (!isObjectRecord(input)) { + return null; + } + + const url = normalizeHref( + input.url + ?? input.href + ?? input.link + ?? input.originUrl + ?? input.origin_url + ?? input.targetUrl + ?? input.target_url + ?? input.sourceUrl + ?? input.source_url, + ); + if (!isLikelySourceUrl(url)) { + return null; + } + + return { + url, + title: normalize(input.title ?? input.name ?? input.text ?? input.label ?? input.desc), + text: normalize(input.text ?? input.title ?? input.name ?? input.label), + siteName: normalize(input.siteName ?? input.site_name ?? input.site ?? input.source ?? input.domain), + }; + }; + + const dedupeLinks = ( + items: Array<{ + url: string; + title: string | null; + text: string | null; + siteName: string | null; + }>, + ) => { + const seen = new Set(); + const result: typeof items = []; + for (const item of items) { + if (!item?.url || seen.has(item.url)) { + continue; + } + seen.add(item.url); + result.push(item); + } + return result; + }; + + const collectSourceLinks = ( + input: unknown, + result: Array<{ + url: string; + title: string | null; + text: string | null; + siteName: string | null; + }>, + depth = 0, + ) => { + if (depth > 10 || input == null) { + return; + } + + if (Array.isArray(input)) { + for (const item of input) { + collectSourceLinks(item, result, depth + 1); + } + return; + } + + const sourceLink = buildSourceLink(input); + if (sourceLink) { + result.push(sourceLink); + } + + if (!isObjectRecord(input)) { + return; + } + + for (const value of Object.values(input)) { + if (Array.isArray(value) || isObjectRecord(value)) { + collectSourceLinks(value, result, depth + 1); + } + } + }; + + const collectSourceLinksByFieldPattern = ( + input: unknown, + pattern: RegExp, + result: Array<{ + url: string; + title: string | null; + text: string | null; + siteName: string | null; + }>, + depth = 0, + ) => { + if (depth > 10 || input == null) { + return; + } + + if (Array.isArray(input)) { + for (const item of input) { + collectSourceLinksByFieldPattern(item, pattern, result, depth + 1); + } + return; + } + + if (!isObjectRecord(input)) { + return; + } + + for (const [key, value] of Object.entries(input)) { + if (pattern.test(key)) { + collectSourceLinks(value, result, depth + 1); + } + + if (Array.isArray(value) || isObjectRecord(value)) { + collectSourceLinksByFieldPattern(value, pattern, result, depth + 1); + } + } + }; + + const isVisible = (element: Element | null): element is HTMLElement => { + if (!(element instanceof HTMLElement)) { + return false; + } + + const style = window.getComputedStyle(element); + if ( + style.display === "none" + || style.visibility === "hidden" + || style.opacity === "0" + || element.hidden + ) { + return false; + } + + const rect = element.getBoundingClientRect(); + return rect.width >= 8 && rect.height >= 8; + }; + + const extractAnchorLink = (element: Element) => { + if (!(element instanceof HTMLAnchorElement) || !isVisible(element)) { + return null; + } + + const url = normalizeHref(element.getAttribute("href") ?? element.href); + if (!isLikelySourceUrl(url)) { + return null; + } + + const title = normalize( + element.getAttribute("title") + ?? element.querySelector("[class*=\"title\"]")?.textContent + ?? element.textContent, + ); + const siteName = normalize( + element.getAttribute("data-site-name") + ?? element.dataset.siteName + ?? element.querySelector("[class*=\"site\"]")?.textContent + ?? element.getAttribute("aria-label"), + ); + + return { + url, + title: title ?? siteName, + text: title ?? siteName, + siteName, + }; + }; + + const collectVisiblePanelLinks = (triggerText: string | null) => { + const panelSelectors = [ + "[class*=\"drawer\"]", + "[class*=\"panel\"]", + "[class*=\"popover\"]", + "[class*=\"webList\"]", + "[class*=\"reference\"]", + "[class*=\"material\"]", + "[class*=\"cite\"]", + "aside", + "section", + "article", + ]; + + const roots = Array.from(document.querySelectorAll(panelSelectors.join(","))) + .filter((element): element is HTMLElement => isVisible(element)) + .filter((element) => { + const text = normalize(element.innerText) ?? ""; + const externalAnchorCount = Array.from(element.querySelectorAll("a[href]")) + .filter((node) => extractAnchorLink(node) !== null) + .length; + return externalAnchorCount > 0 + && ( + /参考|网页|信息源|引用|web pages|references/i.test(text) + || Boolean(triggerText && text.includes(triggerText)) + ); + }); + + const links = roots.length + ? roots.flatMap((root) => + Array.from(root.querySelectorAll("a[href]")) + .map((node) => extractAnchorLink(node)) + .filter((node): node is NonNullable => Boolean(node))) + : []; + + return dedupeLinks(links); + }; + + const store = ( + window as typeof window & { + __NEXT_REDUX_STORE__?: { + getState?: () => Record; + }; + } + ).__NEXT_REDUX_STORE__; + const state = store?.getState?.() ?? {}; + const userInfo = (state.updateReducer as Record | undefined)?.userInfo; + const selectedModel = (state.model as Record | undefined)?.selectedModel; + const triggerPattern = new RegExp(triggerPatternSource, "i"); + const citationFieldPattern = new RegExp(citationFieldPatternSource, "i"); + const searchFieldPattern = new RegExp(searchFieldPatternSource, "i"); + + const bodyText = normalize(document.body?.innerText) ?? ""; + const loginReason = loginSignals.find((signal) => bodyText.includes(signal)) ?? null; + const reqDone = isObjectRecord(userInfo) && typeof userInfo.reqDone === "boolean" ? userInfo.reqDone : null; + const storeIsLogin = isObjectRecord(userInfo) && typeof userInfo.isLogin === "boolean" ? userInfo.isLogin : null; + const isLogin = storeIsLogin ?? !loginReason; + + const modelCandidates: Array = []; + if (typeof selectedModel === "string") { + modelCandidates.push(selectedModel); + } else if (isObjectRecord(selectedModel)) { + modelCandidates.push( + selectedModel.label, + selectedModel.name, + selectedModel.model, + selectedModel.displayName, + selectedModel.assistantName, + selectedModel.assistantKey, + ); + } + for (const selector of [ + "[class*=\"model\"][class*=\"current\"]", + "[class*=\"currentModel\"]", + "[class*=\"modelLabel\"]", + ]) { + modelCandidates.push(document.querySelector(selector)?.textContent ?? null); + } + + const modelLabel = modelCandidates + .map((candidate) => normalize(candidate)) + .find((candidate): candidate is string => Boolean(candidate)) + ?? null; + + const editor = document.querySelector(editorSelector); + const editorText = normalize( + editor instanceof HTMLElement ? editor.innerText || editor.textContent || "" : "", + ); + const referenceTriggerText = Array.from(document.querySelectorAll("button, [role=\"button\"], a, div, span, p")) + .filter((element): element is HTMLElement => isVisible(element)) + .map((element) => normalize(element.innerText || element.textContent || "")) + .find((text): text is string => Boolean(text && triggerPattern.test(text))) + ?? null; + + const storeReferenceLinks: Array<{ + url: string; + title: string | null; + text: string | null; + siteName: string | null; + }> = []; + collectSourceLinksByFieldPattern(state, citationFieldPattern, storeReferenceLinks); + collectSourceLinksByFieldPattern(state, searchFieldPattern, storeReferenceLinks); + const domReferenceLinks = collectVisiblePanelLinks(referenceTriggerText); + const referenceLinks = dedupeLinks([ + ...storeReferenceLinks, + ...domReferenceLinks, + ]); + + return { + url: window.location.href, + title: normalize(document.title), + loginRequired: reqDone === true ? !isLogin : Boolean(loginReason && !isLogin), + loginReason, + isLogin, + modelLabel, + userName: isObjectRecord(userInfo) + ? normalize( + (typeof userInfo.uname === "string" ? userInfo.uname : null) + ?? (typeof userInfo.fuzzyName === "string" ? userInfo.fuzzyName : null), + ) + : null, + editorText, + bodySample: normalize(bodyText.slice(0, 320)), + referenceTriggerText, + referenceLinks, + }; + }, + { + editorSelector: WENXIN_EDITOR_SELECTOR, + loginSignals: WENXIN_LOGIN_SIGNALS, + triggerPatternSource: WENXIN_REFERENCE_TRIGGER_PATTERN.source, + citationFieldPatternSource: WENXIN_CITATION_FIELD_PATTERN.source, + searchFieldPatternSource: WENXIN_SEARCH_FIELD_PATTERN.source, + }, + ); +} + +async function maybeOpenWenxinReferencePanel(page: PlaywrightPage): Promise { + const opened = await page.evaluate(({ triggerPatternSource }) => { + const normalize = (value: unknown): string | null => { + if (typeof value !== "string") { + return null; + } + const trimmed = value + .replace(/\u00a0/g, " ") + .replace(/\r/g, "") + .replace(/\n{3,}/g, "\n\n") + .trim(); + return trimmed || null; + }; + + const isVisible = (element: Element | null): element is HTMLElement => { + if (!(element instanceof HTMLElement)) { + return false; + } + const style = window.getComputedStyle(element); + if ( + style.display === "none" + || style.visibility === "hidden" + || style.opacity === "0" + || element.hidden + ) { + return false; + } + const rect = element.getBoundingClientRect(); + return rect.width >= 8 && rect.height >= 8; + }; + + const triggerPattern = new RegExp(triggerPatternSource, "i"); + const trigger = Array.from(document.querySelectorAll("button, [role=\"button\"], a, div, span, p")) + .filter((element): element is HTMLElement => isVisible(element)) + .find((element) => { + const text = normalize(element.innerText || element.textContent || ""); + return Boolean(text && triggerPattern.test(text)); + }); + + if (!trigger) { + return false; + } + + trigger.click(); + return true; + }, { + triggerPatternSource: WENXIN_REFERENCE_TRIGGER_PATTERN.source, + }).catch(() => false); + + if (opened) { + await page.waitForTimeout(350).catch(() => undefined); + } +} + +async function fillWenxinEditorText(page: PlaywrightPage, questionText: string): Promise { + const editor = page.locator(WENXIN_EDITOR_SELECTOR).first(); + await editor.waitFor({ + state: "visible", + timeout: WENXIN_PAGE_READY_TIMEOUT_MS, + }); + + await editor.scrollIntoViewIfNeeded().catch(() => undefined); + await editor.evaluate((node) => { + if (node instanceof HTMLElement) { + node.focus(); + } + }).catch(() => undefined); + + let filled = false; + try { + await editor.fill(questionText); + filled = true; + } catch { + filled = false; + } + + const normalizedExpected = questionText.trim().replace(/\s+/g, " "); + const normalizedActual = (await editor.innerText().catch(() => "")) + .trim() + .replace(/\s+/g, " "); + + if (!filled || normalizedActual !== normalizedExpected) { + await page.evaluate( + ({ selector, text }) => { + const editorElement = document.querySelector(selector); + if (!(editorElement instanceof HTMLElement)) { + throw new Error("wenxin_editor_missing"); + } + + const selection = window.getSelection(); + const range = document.createRange(); + range.selectNodeContents(editorElement); + selection?.removeAllRanges(); + selection?.addRange(range); + + editorElement.focus(); + try { + document.execCommand("delete"); + document.execCommand("insertText", false, text); + } catch { + // Ignore and fall back to direct DOM mutation below. + } + + const current = (editorElement.innerText || editorElement.textContent || "") + .trim() + .replace(/\s+/g, " "); + if (current !== text.trim().replace(/\s+/g, " ")) { + editorElement.innerHTML = ""; + editorElement.textContent = text; + editorElement.dispatchEvent(new InputEvent("input", { + bubbles: true, + inputType: "insertText", + data: text, + })); + editorElement.dispatchEvent(new Event("change", { bubbles: true })); + } + }, + { + selector: WENXIN_EDITOR_SELECTOR, + text: questionText, + }, + ); + } +} + +async function submitWenxinQuestion( + page: PlaywrightPage, + questionText: string, + signal: AbortSignal, +): Promise { + if (signal.aborted) { + throw new Error("adapter_aborted"); + } + + await fillWenxinEditorText(page, questionText); + await sleep(150, signal); + + const editor = page.locator(WENXIN_EDITOR_SELECTOR).first(); + await editor.press("Enter").catch(async () => { + await page.keyboard.press("Enter").catch(() => undefined); + }); +} + +async function installWenxinFetchCapture(page: PlaywrightPage): Promise { + await page.evaluate( + ({ captureLimit, bodyLimit }) => { + type CaptureEntry = { + id: string; + url: string; + method: string; + status: number | null; + contentType: string | null; + requestBody: string | null; + body: string; + done: boolean; + error: string | null; + startedAt: number; + updatedAt: number; + }; + + type CaptureState = { + installed: boolean; + sequence: number; + originalFetch: typeof window.fetch; + entries: CaptureEntry[]; + }; + + const globalWindow = window as typeof window & { + __geoWenxinCapture?: CaptureState; + }; + + if (globalWindow.__geoWenxinCapture?.installed) { + return; + } + + const originalFetch = window.fetch.bind(window); + const decoder = new TextDecoder(); + const state: CaptureState = { + installed: true, + sequence: 0, + originalFetch, + entries: [], + }; + globalWindow.__geoWenxinCapture = state; + + window.fetch = (async (...args: [RequestInfo | URL, RequestInit?]) => { + const request = args[0]; + const init = args[1]; + const rawUrl = typeof request === "string" + ? request + : request instanceof URL + ? request.toString() + : request && typeof request.url === "string" + ? request.url + : ""; + const url = rawUrl ? new URL(rawUrl, window.location.href).toString() : ""; + const method = ( + init?.method + ?? (request instanceof Request ? request.method : "GET") + ).toUpperCase(); + const requestBody = typeof init?.body === "string" ? init.body : null; + + const response = await originalFetch(...args); + if (!url.includes("/eb/chat/conversation/v2")) { + return response; + } + + const entry: CaptureEntry = { + id: `wenxin-stream-${state.sequence += 1}`, + url, + method, + status: Number.isFinite(response.status) ? response.status : null, + contentType: response.headers.get("content-type"), + requestBody, + body: "", + done: false, + error: null, + startedAt: Date.now(), + updatedAt: Date.now(), + }; + state.entries.push(entry); + if (state.entries.length > captureLimit) { + state.entries.splice(0, state.entries.length - captureLimit); + } + + const clone = response.clone(); + void Promise.resolve().then(async () => { + try { + if (clone.body) { + const reader = clone.body.getReader(); + while (true) { + const { value, done } = await reader.read(); + if (done) { + break; + } + entry.body += decoder.decode(value, { stream: true }); + if (entry.body.length > bodyLimit) { + entry.body = entry.body.slice(0, bodyLimit); + } + entry.updatedAt = Date.now(); + } + entry.body += decoder.decode(); + } else { + entry.body = await clone.text(); + } + } catch (error) { + entry.error = error instanceof Error ? `${error.name}: ${error.message}` : String(error); + } finally { + if (entry.body.length > bodyLimit) { + entry.body = entry.body.slice(0, bodyLimit); + } + entry.done = true; + entry.updatedAt = Date.now(); + } + }); + + return response; + }) as typeof window.fetch; + }, + { + captureLimit: WENXIN_STREAM_CAPTURE_LIMIT, + bodyLimit: WENXIN_STREAM_CAPTURE_BODY_LIMIT, + }, + ); +} + +async function resetWenxinFetchCapture(page: PlaywrightPage): Promise { + await page.evaluate(() => { + const globalWindow = window as typeof window & { + __geoWenxinCapture?: { + entries: WenxinCapturedStreamRecord[]; + }; + }; + if (globalWindow.__geoWenxinCapture) { + globalWindow.__geoWenxinCapture.entries = []; + } + }); +} + +async function readWenxinFetchCapture(page: PlaywrightPage): Promise { + return await page.evaluate(() => { + const globalWindow = window as typeof window & { + __geoWenxinCapture?: { + entries: WenxinCapturedStreamRecord[]; + }; + }; + return (globalWindow.__geoWenxinCapture?.entries ?? []).map((entry) => ({ + id: entry.id, + url: entry.url, + method: entry.method, + status: entry.status, + contentType: entry.contentType, + requestBody: entry.requestBody, + body: entry.body, + done: entry.done, + error: entry.error, + startedAt: entry.startedAt, + updatedAt: entry.updatedAt, + })); + }); +} + +function selectWenxinCapture( + entries: WenxinCapturedStreamRecord[], + questionText: string, +): WenxinCapturedStreamRecord | null { + const normalizedQuestion = questionText.trim(); + for (let index = entries.length - 1; index >= 0; index -= 1) { + const entry = entries[index]; + if (entry.requestBody?.includes(normalizedQuestion)) { + return entry; + } + } + return entries.at(-1) ?? null; +} + +async function fetchWenxinHistory( + session: Session, + sessionId: string, +): Promise { + const payload = await sessionCookieFetchJson>( + session, + WENXIN_HISTORY_URL, + { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ + sessionId, + pageSize: 2000, + timestamp: Date.now(), + deviceType: "pc", + }), + }, + ).catch(() => null); + + if (!payload) { + return null; + } + + return parseWenxinHistoryResponse(payload, sessionId); +} + +async function waitForWenxinAnswer( + page: PlaywrightPage, + session: Session, + questionText: string, + signal: AbortSignal, +): Promise { + const startedAt = Date.now(); + let stablePollCount = 0; + let lastSignature: string | null = null; + let latestStream = emptyWenxinStreamSummary(); + let latestHistory: WenxinHistorySummary | null = null; + let latestObservation = mergeWenxinObservation(latestStream, latestHistory); + let seenCapture = false; + let resolvedSessionId: string | null = null; + + while (Date.now() - startedAt <= WENXIN_QUERY_TIMEOUT_MS) { + if (signal.aborted) { + throw new Error("adapter_aborted"); + } + + const captures = await readWenxinFetchCapture(page); + const capture = selectWenxinCapture(captures, questionText); + if (capture) { + seenCapture = true; + latestStream = parseWenxinSSEBody(capture.body, capture.done, capture.error); + resolvedSessionId = latestStream.sessionId ?? resolvedSessionId; + } + + if (!seenCapture && Date.now() - startedAt >= 12_000) { + latestObservation = mergeWenxinObservation(latestStream, latestHistory); + return { + ok: false, + error: "wenxin_submit_failed", + detail: "no Wenxin conversation stream observed after submission", + stream: latestStream, + history: latestHistory, + observation: latestObservation, + stablePollCount, + elapsedMs: Date.now() - startedAt, + }; + } + + if (resolvedSessionId) { + latestHistory = await fetchWenxinHistory(session, resolvedSessionId); + } + + latestObservation = mergeWenxinObservation(latestStream, latestHistory); + + if (latestHistory?.loginRequired || isWenxinLoginMessage(latestObservation.errorMessage)) { + return { + ok: false, + error: "wenxin_login_required", + detail: latestObservation.errorMessage ?? latestHistory?.errorMessage ?? "login_required", + stream: latestStream, + history: latestHistory, + observation: latestObservation, + stablePollCount, + elapsedMs: Date.now() - startedAt, + }; + } + + if (isWenxinRiskControlMessage(latestObservation.withdrawText) || isWenxinRiskControlMessage(latestObservation.errorMessage)) { + return { + ok: false, + error: "wenxin_risk_control", + detail: latestObservation.withdrawText ?? latestObservation.errorMessage ?? "risk_control", + stream: latestStream, + history: latestHistory, + observation: latestObservation, + stablePollCount, + elapsedMs: Date.now() - startedAt, + }; + } + + if (latestObservation.withdrawText && !latestObservation.answer && !latestObservation.citations.length && !latestObservation.searchResults.length && latestObservation.streamDone) { + return { + ok: false, + error: "wenxin_withdrawn", + detail: latestObservation.withdrawText, + stream: latestStream, + history: latestHistory, + observation: latestObservation, + stablePollCount, + elapsedMs: Date.now() - startedAt, + }; + } + + if (latestObservation.errorMessage && !latestObservation.answer && !latestObservation.citations.length && !latestObservation.searchResults.length && latestObservation.streamDone) { + return { + ok: false, + error: "wenxin_stream_error", + detail: latestObservation.errorMessage, + stream: latestStream, + history: latestHistory, + observation: latestObservation, + stablePollCount, + elapsedMs: Date.now() - startedAt, + }; + } + + if (isObservationComplete(latestObservation)) { + if (latestObservation.signature === lastSignature) { + stablePollCount += 1; + } else { + lastSignature = latestObservation.signature; + stablePollCount = 1; + } + + if (stablePollCount >= WENXIN_STABLE_POLLS_REQUIRED) { + return { + ok: true, + stream: latestStream, + history: latestHistory, + observation: latestObservation, + stablePollCount, + elapsedMs: Date.now() - startedAt, + }; + } + } else { + stablePollCount = 0; + lastSignature = null; + } + + await sleep(WENXIN_QUERY_POLL_INTERVAL_MS, signal); + } + + latestObservation = mergeWenxinObservation(latestStream, latestHistory); + return { + ok: false, + error: "wenxin_query_timeout", + detail: "timed out while waiting for Wenxin answer", + stream: latestStream, + history: latestHistory, + observation: latestObservation, + stablePollCount, + elapsedMs: Date.now() - startedAt, + }; +} + +function buildWenxinCaptureDebug(entries: WenxinCapturedStreamRecord[]): JsonValue[] { + return entries.slice(-3).map((entry) => ({ + id: entry.id, + url: entry.url, + method: entry.method, + status: entry.status, + content_type: entry.contentType, + request_body_preview: entry.requestBody?.slice(0, 240) ?? null, + body_length: entry.body.length, + body_preview: entry.body.slice(0, 360) || null, + done: entry.done, + error: entry.error ?? null, + })); +} + +export const wenxinAdapter: MonitorAdapter = { + provider: "wenxin", + executionMode: "playwright", + async query(context, payload) { + if (!context.playwright?.page) { + return { + status: "failed", + summary: "文心一言监测缺少 Playwright 页面上下文。", + error: buildAdapterError("wenxin_playwright_required", "wenxin_playwright_required"), + }; + } + + const questionText = extractQuestionText(payload); + const page = context.playwright.page; + + context.reportProgress("wenxin.page_ready"); + await ensurePageOnWenxinChat(page, context.signal); + await installWenxinFetchCapture(page); + await resetWenxinFetchCapture(page); + + const initialSnapshot = await readWenxinPageSnapshot(page); + if (initialSnapshot.loginRequired) { + return { + status: "failed", + summary: "文心一言账号未登录或登录态已失效,请先在 desktop client 中重新授权。", + error: buildAdapterError( + "wenxin_login_required", + initialSnapshot.loginReason ?? "login_required", + { + page_url: initialSnapshot.url, + }, + ), + }; + } + + context.reportProgress("wenxin.query"); + await submitWenxinQuestion(page, questionText, context.signal); + + context.reportProgress("wenxin.wait_answer"); + const waitResult = await waitForWenxinAnswer(page, context.session, questionText, context.signal); + const captures = await readWenxinFetchCapture(page); + await maybeOpenWenxinReferencePanel(page).catch(() => undefined); + const finalSnapshot = await readWenxinPageSnapshot(page).catch(() => initialSnapshot); + const observation = waitResult.observation; + const answer = normalizeBlockText(observation.answer); + const pageReferenceLinks = dedupeSourceItems( + finalSnapshot.referenceLinks + .map((item) => buildSourceItem(item)) + .filter((item): item is MonitoringSourceItem => Boolean(item)), + ); + const citations = dedupeSourceItems([ + ...observation.citations, + ...pageReferenceLinks, + ]); + const searchResults = dedupeSourceItems([ + ...observation.searchResults, + ...pageReferenceLinks, + ]); + const allSources = dedupeSourceItems([ + ...citations, + ...searchResults, + ]); + const providerModel = observation.providerModel ?? finalSnapshot.modelLabel ?? "EB45T"; + const requestID = observation.sessionId; + + if (!waitResult.ok) { + if (waitResult.error === "wenxin_login_required") { + return { + status: "failed", + summary: "文心一言账号未登录或登录态已失效,请先在 desktop client 中重新授权。", + error: buildAdapterError( + waitResult.error, + waitResult.detail ?? "login_required", + { + page_url: finalSnapshot.url, + }, + ), + }; + } + + if (waitResult.error === "wenxin_risk_control") { + return { + status: "failed", + summary: `文心一言请求失败:${waitResult.detail ?? "当前访问环境存在异常,请更换浏览器再尝试提问"}`, + error: buildAdapterError( + waitResult.error, + waitResult.detail ?? "risk_control", + { + page_url: finalSnapshot.url, + }, + ), + }; + } + + if (waitResult.error === "wenxin_query_timeout") { + return { + status: "unknown", + summary: "文心一言超时未拿到完整答案,已回写 unknown 等待后续补采。", + error: buildAdapterError( + waitResult.error, + waitResult.detail ?? waitResult.error, + { + page_url: finalSnapshot.url, + session_id: requestID, + stream_done: waitResult.stream.captureDone || waitResult.stream.isEnded, + history_generating: waitResult.history?.generating ?? null, + }, + ), + }; + } + + return { + status: "failed", + summary: `文心一言请求失败:${waitResult.detail ?? waitResult.error}`, + error: buildAdapterError( + waitResult.error, + waitResult.detail ?? waitResult.error, + { + page_url: finalSnapshot.url, + session_id: requestID, + }, + ), + }; + } + + if (!answer && !citations.length && !searchResults.length) { + return { + status: "unknown", + summary: "文心一言返回为空,已回写 unknown 等待后续对账。", + error: buildAdapterError("wenxin_empty_response", "wenxin returned no answer or sources"), + }; + } + + context.reportProgress("wenxin.parse_result"); + return { + status: "succeeded", + summary: "文心一言监控任务执行成功。", + payload: { + platform: "wenxin", + provider_model: providerModel, + provider_request_id: observation.providerRequestID, + request_id: requestID, + conversation_id: requestID, + answer, + citation_count: citations.length, + search_result_count: searchResults.length, + source_count: allSources.length, + citations: toJsonSources(citations), + references: toJsonSources(allSources), + search_results: toJsonSources(searchResults), + sources: toJsonSources(allSources), + raw_response_json: { + platform: "wenxin", + page_url: finalSnapshot.url, + page_title: finalSnapshot.title, + is_login: finalSnapshot.isLogin, + login_reason: finalSnapshot.loginReason, + model_label: finalSnapshot.modelLabel, + user_name: finalSnapshot.userName, + session_id: requestID, + answer: answer ?? null, + answer_block_count: observation.answerBlocks.length, + answer_blocks: observation.answerBlocks, + reference_trigger_text: finalSnapshot.referenceTriggerText, + page_reference_count: pageReferenceLinks.length, + page_references: toJsonSources(pageReferenceLinks), + citation_count: citations.length, + citations: toJsonSources(citations), + reference_count: allSources.length, + references: toJsonSources(allSources), + search_result_count: searchResults.length, + search_results: toJsonSources(searchResults), + source_count: allSources.length, + sources: toJsonSources(allSources), + stream_summary: toJsonValue(waitResult.stream), + history_summary: toJsonValue(waitResult.history), + stable_poll_count: waitResult.stablePollCount, + elapsed_ms: waitResult.elapsedMs, + stream_capture_count: captures.length, + stream_captures: buildWenxinCaptureDebug(captures), + }, + }, + }; + }, +}; + +export const __wenxinTestUtils = { + htmlTableToMarkdown, + isObservationComplete, + parseWenxinHistoryResponse, + parseWenxinSSEBody, +}; diff --git a/apps/desktop-client/src/main/auth-types.ts b/apps/desktop-client/src/main/auth-types.ts index 43c55b5..aa6df0e 100644 --- a/apps/desktop-client/src/main/auth-types.ts +++ b/apps/desktop-client/src/main/auth-types.ts @@ -17,6 +17,7 @@ export type AccountAuthReason = | "login_redirect" | "http_401" | "captcha_gate" + | "risk_control" | "missing_partition" | "silent_refresh_failed" | "manual_unbind" @@ -56,6 +57,6 @@ export interface AuthProbeResult { export type PlatformFailureClassification = | "auth_failure" | "challenge" + | "risk_control" | "network" | "ok"; - diff --git a/apps/desktop-client/src/main/platform-auth-adapters.test.ts b/apps/desktop-client/src/main/platform-auth-adapters.test.ts new file mode 100644 index 0000000..09761b8 --- /dev/null +++ b/apps/desktop-client/src/main/platform-auth-adapters.test.ts @@ -0,0 +1,24 @@ +import { beforeAll, describe, expect, it, vi } from "vitest"; + +vi.mock("./account-binder", () => ({ + probePublishAccountSession: vi.fn(), + silentRefreshPublishAccountSession: vi.fn(), +})); + +let getPlatformAdapter: typeof import("./platform-auth-adapters").getPlatformAdapter; + +beforeAll(async () => { + ({ getPlatformAdapter } = await import("./platform-auth-adapters")); +}); + +describe("platform auth adapters", () => { + it("classifies Wenxin environment abnormal messages as risk control", () => { + const adapter = getPlatformAdapter("wenxin"); + + expect( + adapter.classifyFailure({ + message: "当前访问环境存在异常,请更换浏览器再尝试提问", + }), + ).toBe("risk_control"); + }); +}); diff --git a/apps/desktop-client/src/main/platform-auth-adapters.ts b/apps/desktop-client/src/main/platform-auth-adapters.ts index 3eeba43..391a597 100644 --- a/apps/desktop-client/src/main/platform-auth-adapters.ts +++ b/apps/desktop-client/src/main/platform-auth-adapters.ts @@ -27,8 +27,10 @@ function normalizeFailureText(input: PlatformFailureInput): string { input.summary, input.message, typeof input.error?.code === "string" ? input.error.code : null, + typeof input.error?.reason_code === "string" ? input.error.reason_code : null, typeof input.error?.message === "string" ? input.error.message : null, typeof input.error?.detail === "string" ? input.error.detail : null, + typeof input.error?.page_error === "string" ? input.error.page_error : null, typeof input.error?.verify_scene === "string" ? input.error.verify_scene : null, ]; @@ -67,6 +69,38 @@ function classifyFailure(input: PlatformFailureInput): PlatformFailureClassifica return "ok"; } +function classifyDoubaoFailure(input: PlatformFailureInput): PlatformFailureClassification { + const text = normalizeFailureText(input); + if (!text) { + return "ok"; + } + + if ( + /(rate limited|rate limit|too many requests|request limit|frequency limit|请求过于频繁|频率过快|访问过于频繁|限流|风控|服务繁忙|请稍后|稍后再试)/i + .test(text) + ) { + return "risk_control"; + } + + return classifyFailure(input); +} + +function classifyWenxinFailure(input: PlatformFailureInput): PlatformFailureClassification { + const text = normalizeFailureText(input); + if (!text) { + return "ok"; + } + + if ( + /(当前访问环境存在异常|访问环境存在异常|环境存在异常|更换浏览器再尝试提问|当前环境异常|访问受限|环境异常)/i + .test(text) + ) { + return "risk_control"; + } + + return classifyFailure(input); +} + const genericAdapter: PlatformAdapter = { async probe(account, partition) { return await probePublishAccountSession(account, partition); @@ -77,6 +111,26 @@ const genericAdapter: PlatformAdapter = { classifyFailure, }; +const doubaoAdapter: PlatformAdapter = { + async probe(account, partition) { + return await probePublishAccountSession(account, partition); + }, + async silentRefresh(account, partition) { + return await silentRefreshPublishAccountSession(account, partition); + }, + classifyFailure: classifyDoubaoFailure, +}; + +const wenxinAdapter: PlatformAdapter = { + async probe(account, partition) { + return await probePublishAccountSession(account, partition); + }, + async silentRefresh(account, partition) { + return await silentRefreshPublishAccountSession(account, partition); + }, + classifyFailure: classifyWenxinFailure, +}; + const adapterRegistry = new Map( [ ...aiPlatformCatalog.map((platform) => platform.id), @@ -93,10 +147,16 @@ const adapterRegistry = new Map( "weixin_gzh", "zol", "dongchedi", - ].map((platform) => [platform, genericAdapter]), + ].map((platform) => [ + platform, + platform === "doubao" + ? doubaoAdapter + : platform === "wenxin" + ? wenxinAdapter + : genericAdapter, + ]), ); export function getPlatformAdapter(platformId: string): PlatformAdapter { return adapterRegistry.get(platformId) ?? genericAdapter; } - diff --git a/apps/desktop-client/src/main/runtime-controller.ts b/apps/desktop-client/src/main/runtime-controller.ts index 6182a26..73693d9 100644 --- a/apps/desktop-client/src/main/runtime-controller.ts +++ b/apps/desktop-client/src/main/runtime-controller.ts @@ -21,6 +21,7 @@ import { kimiAdapter, qwenAdapter, toutiaoAdapter, + wenxinAdapter, yuanbaoAdapter, zhihuAdapter, type AdapterExecutionResult, @@ -34,6 +35,7 @@ import { import { ensureAccountReady, forgetTrackedAccountHealth, + getAccountHealthSnapshot, getProjectedAccountHealth, probeTrackedAccount, reportAccountFailure, @@ -127,7 +129,7 @@ const pullIntervalMs = 60_000; const leaseExtendIntervalMs = 60_000; const maxActivityItems = 60; const monitorBusinessTimeZone = "Asia/Shanghai"; -const authRequiredMonitorPlatforms = new Set(["yuanbao", "kimi", "deepseek", "doubao"]); +const authRequiredMonitorPlatforms = new Set(["yuanbao", "kimi", "deepseek", "doubao", "wenxin"]); interface RuntimeTaskRecord { id: string; @@ -279,6 +281,53 @@ function isDefinitiveAuthState(authState: string): boolean { return ["active", "expired", "challenge_required", "revoked"].includes(authState); } +function runtimePlatformLabel(platform: string): string { + return getAIPlatformCatalogItem(platform)?.label ?? platform; +} + +function accountActionRequiredSummary( + platform: string, + authReason: string | null | undefined, +): string { + const label = runtimePlatformLabel(platform); + if (authReason === "risk_control") { + return `${label} 账号疑似触发风控,请打开平台处理验证或等待限制解除后再继续执行。`; + } + return `${label} 账号需要完成人机验证后才能继续执行。`; +} + +function maybeRecordAccountAccessAlert( + task: RuntimeTaskRecord, + accountIdentity: PublishAccountIdentity | null, + previous: ReturnType, + next: ReturnType, +): void { + if (!accountIdentity || !next) { + return; + } + + if (previous?.authState === next.authState && previous?.authReason === next.authReason) { + return; + } + + if (next.authState === "challenge_required" && next.authReason === "risk_control") { + recordActivity( + "warn", + `${runtimePlatformLabel(accountIdentity.platform)} 触发风控`, + `${accountIdentity.displayName || task.accountName || "当前账号"} 已被平台限制,请打开平台处理验证或等待限制解除后再刷新状态。`, + ); + return; + } + + if (next.authState === "challenge_required") { + recordActivity( + "warn", + `${runtimePlatformLabel(accountIdentity.platform)} 需要人工验证`, + `${accountIdentity.displayName || task.accountName || "当前账号"} 需要打开平台完成人工验证后再继续执行任务。`, + ); + } +} + function isoFromTimestamp(value: number | null): string | null { return value ? new Date(value).toISOString() : null; } @@ -2209,10 +2258,10 @@ function buildAccountBlockedResult( if (readiness.authState === "challenge_required") { return { status: "failed", - summary: `${task.accountName} 需要完成人机验证后才能继续执行。`, + summary: accountActionRequiredSummary(task.platform, readiness.authReason), error: { - code: "desktop_account_challenge_required", - message: "desktop account challenge required", + code: readiness.authReason === "risk_control" ? "desktop_account_risk_control" : "desktop_account_challenge_required", + message: readiness.authReason === "risk_control" ? "desktop account risk control triggered" : "desktop account challenge required", }, }; } @@ -2247,7 +2296,8 @@ async function maybeReportTaskAuthFailure( return; } - await reportAccountFailure(accountIdentity, { + const previousSnapshot = getAccountHealthSnapshot(accountIdentity.id); + const nextSnapshot = await reportAccountFailure(accountIdentity, { summary: result.summary, error: result.error ?? null, }).catch((error) => { @@ -2257,7 +2307,10 @@ async function maybeReportTaskAuthFailure( platform: accountIdentity.platform, message: error instanceof Error ? error.message : String(error), }); + return null; }); + + maybeRecordAccountAccessAlert(task, accountIdentity, previousSnapshot, nextSnapshot); } function resolvePlaywrightTargetURL( @@ -2640,6 +2693,9 @@ function selectMonitorAdapter(platform: string): MonitorAdapter | null { if (platform === qwenAdapter.provider) { return qwenAdapter; } + if (platform === wenxinAdapter.provider) { + return wenxinAdapter; + } return null; } diff --git a/apps/desktop-client/src/renderer/types.ts b/apps/desktop-client/src/renderer/types.ts index 28d6df6..f21c70a 100644 --- a/apps/desktop-client/src/renderer/types.ts +++ b/apps/desktop-client/src/renderer/types.ts @@ -27,6 +27,7 @@ export interface RuntimeAccount { | "login_redirect" | "http_401" | "captcha_gate" + | "risk_control" | "missing_partition" | "silent_refresh_failed" | "manual_unbind" diff --git a/apps/desktop-client/src/renderer/views/AccountsView.vue b/apps/desktop-client/src/renderer/views/AccountsView.vue index 691fa39..79f7938 100644 --- a/apps/desktop-client/src/renderer/views/AccountsView.vue +++ b/apps/desktop-client/src/renderer/views/AccountsView.vue @@ -90,7 +90,7 @@ function authStateLabel(account: AccountRow): string { case "expired": return "授权过期"; case "attention": - return "需人工验证"; + return account.authReason === "risk_control" ? "触发风控" : "需人工验证"; default: return account.probeState === "network_error" ? "最近校验失败" : "待重新校验"; } diff --git a/apps/desktop-client/src/renderer/views/AiPlatformsView.vue b/apps/desktop-client/src/renderer/views/AiPlatformsView.vue index 31e0b6f..30fae0a 100644 --- a/apps/desktop-client/src/renderer/views/AiPlatformsView.vue +++ b/apps/desktop-client/src/renderer/views/AiPlatformsView.vue @@ -56,7 +56,7 @@ function authLabel(account: AccountRow | null) { case "active": return "授权正常"; case "challenge_required": - return "需人工验证"; + return account.authReason === "risk_control" ? "触发风控" : "需人工验证"; case "expired": return "授权过期"; case "expiring_soon": @@ -88,6 +88,22 @@ function authColor(account: AccountRow | null) { } } +function accountActionAlert(account: AccountRow | null): string | null { + if (!account || account.authState !== "challenge_required") { + return null; + } + + if (account.authReason === "risk_control" && account.platform === "doubao") { + return "豆包账号疑似触发风控,监控已暂停。请点击“打开平台”,在前台完成验证或等待限制解除后,再刷新状态。"; + } + + if (account.authReason === "risk_control") { + return "当前账号疑似触发平台限制,相关任务已暂停。请先打开平台处理验证后,再刷新状态。"; + } + + return "当前账号需要人工验证,相关任务会暂停执行。请先打开平台完成验证后,再刷新状态。"; +} + function verificationLabel(account: AccountRow): string { if (account.lastVerifiedAt) { return formatVerifiedAtLabel(account.lastVerifiedAt); @@ -328,6 +344,19 @@ async function unbindPlatform(account: AccountRow) { +
+ + + + +
+
snapshot.value?.tasks ?? []); const accounts = computed(() => snapshot.value?.accounts ?? []); const activity = computed(() => snapshot.value?.activity ?? []); -const expiredAccounts = computed(() => - accounts.value.filter((item) => ["expired", "revoked"].includes(item.authState)), +const blockedAccounts = computed(() => + accounts.value.filter((item) => ["expired", "revoked", "challenge_required"].includes(item.authState)), ); const subsystemCards = computed(() => { @@ -159,23 +161,23 @@ const subsystemCards = computed(() => {

账号健康看板

-

这里只展示已经确认过期、需要重新授权的账号。

+

这里只展示已经确认过期、被风控或需要人工处理的账号。

-
-
+
+
{{ account.displayName }} {{ translatePlatform(account.platform) }} · {{ formatUid(account.platformUid) }}
- + {{ formatRelativeTime(account.lastVerifiedAt ?? account.lastSyncAt) }}
-

当前没有已过期账号。

+

当前没有需要人工处理的阻断账号。