feat(desktop): add Wenxin monitoring adapter and surface platform risk control

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) <noreply@anthropic.com>
This commit is contained in:
2026-04-23 15:26:24 +08:00
parent bd4ee8feef
commit ca40657c5a
12 changed files with 2552 additions and 21 deletions
@@ -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);
@@ -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";
@@ -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("<table><tr><th>品牌</th><th>均价</th></tr><tr><td>A</td><td>999</td></tr></table>");
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: "<table><tr><th>品牌</th><th>价格</th></tr><tr><td>A</td><td>1000</td></tr></table>",
},
],
},
],
},
}, "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);
});
});
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -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";
@@ -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");
});
});
@@ -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<string, PlatformAdapter>(
[
...aiPlatformCatalog.map((platform) => platform.id),
@@ -93,10 +147,16 @@ const adapterRegistry = new Map<string, PlatformAdapter>(
"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;
}
@@ -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<typeof getAccountHealthSnapshot>,
next: ReturnType<typeof getAccountHealthSnapshot>,
): 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;
}