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("
");
+
+ 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: "",
+ },
+ ],
+ },
+ ],
+ },
+ }, "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) {
+
+
+
+ {{ accountActionAlert(platform.account) }}
+
+
+
+
+
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) }}
-
当前没有已过期账号。
+
当前没有需要人工处理的阻断账号。