diff --git a/apps/desktop-client/package.json b/apps/desktop-client/package.json
index 2682ea7..068bf1a 100644
--- a/apps/desktop-client/package.json
+++ b/apps/desktop-client/package.json
@@ -1,6 +1,6 @@
{
"name": "@geo/desktop-client",
- "version": "0.1.7",
+ "version": "0.1.8",
"private": true,
"description": "省心推客户端 — 连接和管理您的媒体账号,轻松发布和监控内容表现。",
"author": {
diff --git a/apps/desktop-client/src/main/adapters/wenxin.test.ts b/apps/desktop-client/src/main/adapters/wenxin.test.ts
index 45c33c0..e3abd31 100644
--- a/apps/desktop-client/src/main/adapters/wenxin.test.ts
+++ b/apps/desktop-client/src/main/adapters/wenxin.test.ts
@@ -6,10 +6,13 @@ const {
buildSourceItem,
extractQuestionText,
htmlTableToMarkdown,
+ isWenxinStaleConversationMessage,
isObservationComplete,
+ mergeWenxinObservation,
normalizeUrl,
parseWenxinHistoryResponse,
parseWenxinSSEBody,
+ selectBestWenxinEditorCandidate,
} = __wenxinTestUtils
describe('wenxin adapter helpers', () => {
@@ -43,6 +46,270 @@ describe('wenxin adapter helpers', () => {
})
})
+ it('parses chat.baidu.com conversation stream events', () => {
+ const sseBody = [
+ 'event:message',
+ 'data:{"code":0,"data":{"session_id":"chat-session-1","message_id":"provider-msg-1","usedModel":{"modelName":"smartMode"},"content":"合肥全屋定制推荐 A、B 两家公司。","search_results":[{"url":"https://example.com/hefei-custom","title":"合肥全屋定制攻略","siteName":"示例站"}],"is_end":false}}',
+ '',
+ 'event:message',
+ 'data:{"code":0,"data":{"session_id":"chat-session-1","message_id":"provider-msg-1","tokens_all":"合肥全屋定制推荐 A、B 两家公司。\\n建议重点看板材、五金和交付口碑。","is_end":true}}',
+ ].join('\n')
+
+ const parsed = parseWenxinSSEBody(sseBody, true, null)
+
+ expect(parsed).toMatchObject({
+ sessionId: 'chat-session-1',
+ providerModel: 'smartMode',
+ providerRequestID: 'provider-msg-1',
+ answer: '合肥全屋定制推荐 A、B 两家公司。\n建议重点看板材、五金和交付口碑。',
+ isEnded: true,
+ captureDone: true,
+ })
+ expect(parsed.searchResults).toHaveLength(1)
+ expect(parsed.searchResults[0]).toMatchObject({
+ url: 'https://example.com/hefei-custom',
+ title: '合肥全屋定制攻略',
+ site_name: '示例站',
+ })
+ })
+
+ it('parses real chat.baidu.com generator value deltas', () => {
+ const sseBody = [
+ 'event:basedata',
+ 'data:{"status":0,"query":"合肥全屋定制推荐","lid":"session-real-1"}',
+ '',
+ 'event:message',
+ 'data:{"status":0,"qid":"session-real-1","sessionId":"session-real-1","data":{"message":{"msgId":"msg-real-1","metaData":{"state":"generating-resp","endTurn":false},"content":{"generator":{"component":"markdown-yiyan","data":{"value":"结合合肥本地市场口碑,"},"usedModel":{"modelName":"Search-Lightning"}}}}}}',
+ '',
+ 'event:message',
+ 'data:{"status":0,"qid":"session-real-1","sessionId":"session-real-1","data":{"message":{"msgId":"msg-real-1","metaData":{"state":"generating-resp","endTurn":true},"content":{"generator":{"component":"markdown-yiyan","isFinished":true,"data":{"value":"推荐拾光里、欧派、索菲亚、志邦等品牌。"},"usedModel":{"modelName":"Search-Lightning"}}}}}}',
+ ].join('\n')
+
+ const parsed = parseWenxinSSEBody(sseBody, true, 'AbortError: BodyStreamBuffer was aborted')
+
+ expect(parsed).toMatchObject({
+ source: 'chat_api',
+ sessionId: 'session-real-1',
+ providerModel: 'Search-Lightning',
+ providerRequestID: 'msg-real-1',
+ answer: '结合合肥本地市场口碑,推荐拾光里、欧派、索菲亚、志邦等品牌。',
+ isEnded: true,
+ captureDone: true,
+ })
+ expect(parsed.errorMessage).toBeNull()
+ })
+
+ it('does not collapse legitimate repeated characters across answer chunks', () => {
+ const sseBody = [
+ 'event:basedata',
+ 'data:{"status":0,"query":"合肥全屋定制推荐","lid":"session-repeat-char-1"}',
+ '',
+ 'event:message',
+ 'data:{"status":0,"qid":"session-repeat-char-1","sessionId":"session-repeat-char-1","data":{"message":{"msgId":"msg-repeat-char-1","metaData":{"state":"generating-resp","endTurn":false},"content":{"generator":{"component":"markdown-yiyan","data":{"value":"结合"},"usedModel":{"modelName":"Search-Lightning"}}}}}}',
+ '',
+ 'event:message',
+ 'data:{"status":0,"qid":"session-repeat-char-1","sessionId":"session-repeat-char-1","data":{"message":{"msgId":"msg-repeat-char-1","metaData":{"state":"generating-resp","endTurn":true},"content":{"generator":{"component":"markdown-yiyan","isFinished":true,"data":{"value":"合肥本地市场口碑。"},"usedModel":{"modelName":"Search-Lightning"}}}}}}',
+ ].join('\n')
+
+ expect(parseWenxinSSEBody(sseBody, true, null).answer).toBe('结合合肥本地市场口碑。')
+ })
+
+ it('preserves markdown formatting from real chat.baidu.com answer chunks', () => {
+ const sseBody = [
+ 'event:basedata',
+ 'data:{"status":0,"query":"合肥全屋定制推荐","lid":"session-format-1"}',
+ '',
+ 'event:message',
+ 'data:{"status":0,"qid":"session-format-1","sessionId":"session-format-1","data":{"message":{"msgId":"msg-format-1","metaData":{"state":"generating-resp","endTurn":false},"content":{"generator":{"component":"thinkingSteps","data":{"value":"调用工具 百度优选"},"usedModel":{"modelName":"Search-Lightning"}}}}}}',
+ '',
+ 'event:message',
+ 'data:{"status":0,"qid":"session-format-1","sessionId":"session-format-1","data":{"message":{"msgId":"msg-format-1","metaData":{"state":"generating-resp","endTurn":false},"content":{"generator":{"component":"markdown-yiyan","data":{"value":"结合合肥本地市场口碑,推荐如下:\\n\\n一、本土高性价比工厂直营品牌\\n"},"usedModel":{"modelName":"Search-Lightning"}}}}}}',
+ '',
+ 'event:message',
+ 'data:{"status":0,"qid":"session-format-1","sessionId":"session-format-1","data":{"message":{"msgId":"msg-format-1","metaData":{"state":"generating-resp","endTurn":false},"content":{"generator":{"component":"markdown-yiyan","data":{"value":"1. 拾光里全屋定制:本土工厂直营。\\n2. 宸智雅筑:源头工厂直供。\\n\\n"},"usedModel":{"modelName":"Search-Lightning"}}}}}}',
+ '',
+ 'event:message',
+ 'data:{"status":0,"qid":"session-format-1","sessionId":"session-format-1","data":{"message":{"msgId":"msg-format-1","metaData":{"state":"generating-resp","endTurn":true},"content":{"generator":{"component":"markdown-yiyan","isFinished":true,"data":{"value":"二、全国连锁品牌\\n1. 欧派\\n2. 索菲亚\\n"},"usedModel":{"modelName":"Search-Lightning"}}}}}}',
+ ].join('\n')
+
+ const parsed = parseWenxinSSEBody(sseBody, true, null)
+
+ expect(parsed.answer).toBe(
+ [
+ '结合合肥本地市场口碑,推荐如下:',
+ '',
+ '一、本土高性价比工厂直营品牌',
+ '1. 拾光里全屋定制:本土工厂直营。',
+ '2. 宸智雅筑:源头工厂直供。',
+ '',
+ '二、全国连锁品牌',
+ '1. 欧派',
+ '2. 索菲亚',
+ ].join('\n'),
+ )
+ expect(parsed.answer).not.toContain('调用工具')
+ expect(parsed.isEnded).toBe(true)
+ })
+
+ it('merges cumulative chat.baidu.com markdown chunks without duplicating text', () => {
+ const sseBody = [
+ 'event:basedata',
+ 'data:{"status":0,"query":"合肥全屋定制推荐","lid":"session-cumulative-1"}',
+ '',
+ 'event:message',
+ 'data:{"status":0,"qid":"session-cumulative-1","sessionId":"session-cumulative-1","data":{"message":{"msgId":"msg-cumulative-1","metaData":{"state":"generating-resp","endTurn":false},"content":{"generator":{"component":"markdown-yiyan","data":{"value":"结合合肥本地市场口碑,推荐如下:\\n\\n一、本土品牌\\n1.拾光里"},"usedModel":{"modelName":"Search-Lightning"}}}}}}',
+ '',
+ 'event:message',
+ 'data:{"status":0,"qid":"session-cumulative-1","sessionId":"session-cumulative-1","data":{"message":{"msgId":"msg-cumulative-1","metaData":{"state":"generating-resp","endTurn":false},"content":{"generator":{"component":"markdown-yiyan","data":{"value":"结合合肥本地市场口碑,推荐如下:\\n\\n一、本土品牌\\n1.拾光里\\n2.宸智雅筑"},"usedModel":{"modelName":"Search-Lightning"}}}}}}',
+ '',
+ 'event:message',
+ 'data:{"status":0,"qid":"session-cumulative-1","sessionId":"session-cumulative-1","data":{"message":{"msgId":"msg-cumulative-1","metaData":{"state":"generating-resp","endTurn":true},"content":{"generator":{"component":"markdown-yiyan","isFinished":true,"data":{"value":"结合合肥本地市场口碑,推荐如下:\\n\\n一、本土品牌\\n1.拾光里\\n2.宸智雅筑\\n\\n二、全国品牌\\n1.欧派"},"usedModel":{"modelName":"Search-Lightning"}}}}}}',
+ ].join('\n')
+
+ const parsed = parseWenxinSSEBody(sseBody, true, null)
+
+ expect(parsed.answer).toBe(
+ [
+ '结合合肥本地市场口碑,推荐如下:',
+ '',
+ '一、本土品牌',
+ '1. 拾光里',
+ '2. 宸智雅筑',
+ '',
+ '二、全国品牌',
+ '1. 欧派',
+ ].join('\n'),
+ )
+ })
+
+ it('does not let stale history override a valid chat.baidu.com stream answer', () => {
+ const observation = mergeWenxinObservation(
+ {
+ sessionId: 'chat-session-1',
+ userChatId: null,
+ botChatId: null,
+ source: 'chat_api',
+ providerModel: 'Search-Lightning',
+ providerRequestID: 'msg-1',
+ answer: '合肥全屋定制推荐拾光里、欧派、索菲亚。',
+ markdownChunks: [],
+ citations: [],
+ searchResults: [],
+ withdrawText: null,
+ errorMessage: null,
+ isBanned: false,
+ sessionBan: false,
+ sessionDel: false,
+ isEnded: true,
+ eventCount: 2,
+ captureDone: true,
+ captureError: null,
+ },
+ {
+ sessionId: 'chat-session-1',
+ loginRequired: false,
+ providerModel: null,
+ providerRequestID: null,
+ latestChatId: null,
+ latestChatMode: null,
+ latestChatStop: null,
+ state: null,
+ answer: null,
+ answerBlocks: [],
+ citations: [],
+ searchResults: [],
+ withdrawText: '换个话题重新开始吧,请点击新建对话',
+ errorMessage: '换个话题重新开始吧,请点击新建对话',
+ generating: false,
+ signature: null,
+ },
+ )
+
+ expect(observation.answer).toBe('合肥全屋定制推荐拾光里、欧派、索菲亚。')
+ expect(observation.withdrawText).toBeNull()
+ expect(observation.errorMessage).toBeNull()
+ expect(isWenxinStaleConversationMessage(observation.errorMessage)).toBe(false)
+ })
+
+ it('recognizes stale conversation errors that should retry in a new chat', () => {
+ expect(isWenxinStaleConversationMessage('换个话题重新开始吧,请点击新建对话')).toBe(true)
+ expect(isWenxinStaleConversationMessage('请先登录后继续')).toBe(false)
+ })
+
+ it('selects the visible chat.baidu.com composer over sidebar inputs', () => {
+ const selected = selectBestWenxinEditorCandidate([
+ {
+ id: 'history-search',
+ kind: 'input',
+ disabled: false,
+ readOnly: false,
+ hintText: 'search history 搜索历史',
+ contextText: 'sidebar 历史 对话列表 新建对话',
+ valueLength: 0,
+ rectTop: 120,
+ rectBottom: 152,
+ rectWidth: 220,
+ rectHeight: 32,
+ viewportWidth: 1440,
+ viewportHeight: 900,
+ },
+ {
+ id: 'chat-composer',
+ kind: 'textarea',
+ disabled: false,
+ readOnly: false,
+ hintText: 'chat-input textarea placeholder 可以问我任何问题',
+ contextText: 'ai-input composer 联网搜索 深度思考 发送',
+ valueLength: 0,
+ rectTop: 760,
+ rectBottom: 824,
+ rectWidth: 860,
+ rectHeight: 64,
+ viewportWidth: 1440,
+ viewportHeight: 900,
+ },
+ ])
+
+ expect(selected?.id).toBe('chat-composer')
+ })
+
+ it('accepts Lexical contenteditable composers when there is no API chat token', () => {
+ const selected = selectBestWenxinEditorCandidate([
+ {
+ id: 'prompt-suggestion',
+ kind: 'contenteditable',
+ disabled: false,
+ readOnly: false,
+ hintText: 'prompt-list recommend',
+ contextText: '推荐问题 试试问我',
+ valueLength: 0,
+ rectTop: 280,
+ rectBottom: 308,
+ rectWidth: 420,
+ rectHeight: 28,
+ viewportWidth: 1280,
+ viewportHeight: 800,
+ },
+ {
+ id: 'lexical-composer',
+ kind: 'contenteditable',
+ disabled: false,
+ readOnly: false,
+ hintText: 'role textbox data-lexical-editor editor',
+ contextText: 'chat-input composer 请输入你的问题 发送',
+ valueLength: 0,
+ rectTop: 650,
+ rectBottom: 708,
+ rectWidth: 740,
+ rectHeight: 58,
+ viewportWidth: 1280,
+ viewportHeight: 800,
+ },
+ ])
+
+ expect(selected?.id).toBe('lexical-composer')
+ })
+
it('extracts withdraw text from stream', () => {
const sseBody = [
'event:major',
@@ -257,6 +524,7 @@ describe('wenxin adapter helpers', () => {
errorMessage: 'temporary stream issue',
historyGenerating: false,
streamDone: false,
+ streamSeen: false,
signature: 'sig-1',
}),
).toBe(false)
@@ -274,8 +542,55 @@ describe('wenxin adapter helpers', () => {
errorMessage: null,
historyGenerating: false,
streamDone: false,
+ streamSeen: false,
signature: 'sig-2',
}),
).toBe(false)
})
+
+ it('does not treat an in-progress stream answer as complete', () => {
+ expect(
+ isObservationComplete({
+ sessionId: 'chat-session-1',
+ providerModel: 'Search-Lightning',
+ providerRequestID: 'provider-msg-1',
+ answer: '结合合肥本地市场口碑,推荐如下:\n\n一、本土品牌\n1. 拾光里',
+ answerBlocks: ['结合合肥本地市场口碑,推荐如下:\n\n一、本土品牌\n1. 拾光里'],
+ citations: [],
+ searchResults: [],
+ withdrawText: null,
+ errorMessage: null,
+ historyGenerating: false,
+ streamDone: false,
+ streamSeen: true,
+ signature: 'sig-in-progress',
+ }),
+ ).toBe(false)
+ })
+
+ it('does not treat source-only observations as complete without answer text', () => {
+ expect(
+ isObservationComplete({
+ sessionId: 'chat-session-1',
+ providerModel: 'smartMode',
+ providerRequestID: 'provider-msg-1',
+ answer: null,
+ answerBlocks: [],
+ citations: [],
+ searchResults: [
+ {
+ url: 'https://example.com/hefei-custom',
+ title: '合肥全屋定制攻略',
+ host: 'example.com',
+ },
+ ],
+ withdrawText: null,
+ errorMessage: null,
+ historyGenerating: false,
+ streamDone: true,
+ streamSeen: true,
+ signature: 'sig-source-only',
+ }),
+ ).toBe(false)
+ })
})
diff --git a/apps/desktop-client/src/main/adapters/wenxin.ts b/apps/desktop-client/src/main/adapters/wenxin.ts
index f1ffacd..7706ca2 100644
--- a/apps/desktop-client/src/main/adapters/wenxin.ts
+++ b/apps/desktop-client/src/main/adapters/wenxin.ts
@@ -6,19 +6,59 @@ import type { MonitorAdapter } from './base'
import { normalizeText, sessionCookieFetchJson } from './common'
const WENXIN_BOOTSTRAP_URL = 'https://yiyan.baidu.com/'
+const WENXIN_CHAT_API_BOOTSTRAP_URL = 'https://chat.baidu.com/'
+const WENXIN_CHAT_API_CONVERSATION_URL = 'https://chat.baidu.com/aichat/api/conversation'
+const WENXIN_CHAT_API_NEW_CHAT_BASE_URL =
+ 'https://chat.baidu.com/search?isShowHello=1&extParams=%7B%22out_enter_type%22%3A%22home_aiinput_askai%22%2C%22enter_type%22%3A%22sidebar_dialog%22%7D'
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_MAX_SUBMIT_ATTEMPTS = 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_STREAM_CAPTURE_BODY_LIMIT = 5_000_000
+const WENXIN_CHAT_API_SUBMIT_TIMEOUT_MS = 15_000
+const WENXIN_STREAM_URL_PATTERNS = ['/eb/chat/conversation/v2', '/aichat/api/conversation']
+const WENXIN_EDITOR_CANDIDATE_SELECTORS = [
+ 'div[role="textbox"].editable__T7WAW4uW',
+ '[role="textbox"]',
+ '[contenteditable]:not([contenteditable="false"])',
+ '[data-lexical-editor="true"]',
+ '[data-slate-editor="true"]',
+ 'textarea',
+ 'input:not([type])',
+ 'input[type="text"]',
+ 'input[type="search"]',
+] as const
+const WENXIN_EDITOR_SELECTOR = WENXIN_EDITOR_CANDIDATE_SELECTORS.join(', ')
+const WENXIN_EDITOR_CONTEXT_SELECTOR = [
+ 'form',
+ '[class*="chat-input"]',
+ '[class*="ChatInput"]',
+ '[class*="composer"]',
+ '[class*="Composer"]',
+ '[class*="editor"]',
+ '[class*="Editor"]',
+ '[class*="input"]',
+ '[class*="Input"]',
+ '[class*="textarea"]',
+ '[class*="TextArea"]',
+ '[class*="bottom"]',
+ '[class*="Bottom"]',
+ '[data-testid*="input"]',
+ '[data-testid*="composer"]',
+].join(', ')
+const WENXIN_COMPOSER_HINT_PATTERN =
+ /(chat[-_\s]?input|ai[-_\s]?input|main[-_\s]?input|composer|compose|editor|editable|textarea|text[-_\s]?area|prompt|ask|question|send[-_\s]?box|dialogue|conversation|输入|提问|问题|问一问|发送|请输入|可以问我|有什么想问|搜索或输入)/i
+const WENXIN_NON_COMPOSER_HINT_PATTERN =
+ /(history|sidebar|side[-_\s]?bar|nav|menu|account|login|filter|suggest|autocomplete|recommend|prompt[-_\s]?list|conversation[-_\s]?list|chat[-_\s]?list|new[-_\s]?chat|历史|侧边|导航|菜单|账号|登录|筛选|联想|推荐|搜索历史|搜索对话|对话列表|新建对话|设置|分享)/i
const WENXIN_LOGIN_SIGNALS = ['未登录', '请先登录', '登录', '扫码登录', '百度账号登录']
const WENXIN_RISK_CONTROL_PATTERN =
/(当前访问环境存在异常|访问环境存在异常|环境存在异常|更换浏览器再尝试提问|当前环境异常)/i
const WENXIN_LOGIN_REQUIRED_PATTERN = /(请先登录|未登录|登录后|登录态失效|重新登录)/i
+const WENXIN_STALE_CONVERSATION_PATTERN =
+ /(换个话题重新开始|点击新建对话|新建对话|重新开始吧|conversation.*(?:expired|invalid)|session.*(?:expired|invalid))/i
const WENXIN_PLACEHOLDER_PATTERN =
/^(?:正在生成中(?:\.\.\.)?|思考中(?:\.\.\.)?|内容由AI生成,仅供参考,请仔细甄别|全选)$/
const WENXIN_TABLE_HTML_PATTERN = /
/gi
@@ -88,9 +128,13 @@ type WenxinStreamSummary = {
sessionId: string | null
userChatId: string | null
botChatId: string | null
+ source: 'unknown' | 'yiyan' | 'chat_api'
providerModel: string | null
providerRequestID: string | null
answer: string | null
+ markdownChunks: string[]
+ citations: MonitoringSourceItem[]
+ searchResults: MonitoringSourceItem[]
withdrawText: string | null
errorMessage: string | null
isBanned: boolean
@@ -121,6 +165,43 @@ type WenxinHistorySummary = {
signature: string | null
}
+type WenxinSubmitResult = {
+ ok: boolean
+ mode: 'chat_api' | 'editor'
+ status: number | null
+ contentType: string | null
+ detail: string | null
+ chatTokenPresent?: boolean
+ chatTokenSource?: string | null
+}
+
+type WenxinEditorCandidateKind = 'contenteditable' | 'textarea' | 'input'
+
+type WenxinEditorCandidateScoreInput = {
+ kind: WenxinEditorCandidateKind
+ disabled: boolean
+ readOnly: boolean
+ hintText: string
+ contextText: string
+ valueLength: number
+ rectTop: number
+ rectBottom: number
+ rectWidth: number
+ rectHeight: number
+ viewportWidth: number
+ viewportHeight: number
+}
+
+type WenxinEditorCandidate = WenxinEditorCandidateScoreInput & {
+ id: string
+}
+
+type WenxinResolvedEditor = {
+ locator: ReturnType
+ kind: WenxinEditorCandidateKind
+ id: string
+}
+
type WenxinObservation = {
sessionId: string | null
providerModel: string | null
@@ -133,6 +214,7 @@ type WenxinObservation = {
errorMessage: string | null
historyGenerating: boolean
streamDone: boolean
+ streamSeen: boolean
signature: string | null
}
@@ -184,6 +266,108 @@ function normalizeBlockText(value: string | null): string | null {
)
}
+function normalizeWenxinHintText(value: string): string {
+ return value.replace(/\u00a0/g, ' ').replace(/\s+/g, ' ').trim()
+}
+
+function scoreWenxinEditorCandidate(candidate: WenxinEditorCandidateScoreInput): number | null {
+ if (
+ candidate.disabled ||
+ candidate.readOnly ||
+ candidate.rectWidth < 24 ||
+ candidate.rectHeight < 12 ||
+ candidate.viewportWidth <= 0 ||
+ candidate.viewportHeight <= 0
+ ) {
+ return null
+ }
+
+ const hintText = normalizeWenxinHintText(`${candidate.hintText} ${candidate.contextText}`)
+ const composerLike = WENXIN_COMPOSER_HINT_PATTERN.test(hintText)
+ const nonComposerLike = WENXIN_NON_COMPOSER_HINT_PATTERN.test(hintText)
+ const searchLike = /(?:^|[\s_-])(search|搜索)(?:$|[\s_-])/i.test(hintText)
+ const bottomZone = candidate.rectTop >= candidate.viewportHeight * 0.52
+ const wideEnough = candidate.rectWidth >= Math.min(360, candidate.viewportWidth * 0.32)
+
+ if (nonComposerLike && !composerLike) {
+ return null
+ }
+
+ if (candidate.kind === 'input' && !composerLike && !bottomZone) {
+ return null
+ }
+
+ if (candidate.valueLength > 1_200 && !composerLike) {
+ return null
+ }
+
+ let score =
+ candidate.rectTop * 3 +
+ Math.min(candidate.rectWidth, candidate.viewportWidth, 1_200) +
+ Math.min(candidate.rectHeight * 6, 360)
+
+ if (candidate.kind === 'contenteditable') {
+ score += 2_200
+ } else if (candidate.kind === 'textarea') {
+ score += 1_800
+ } else {
+ score += 500
+ }
+
+ if (composerLike) {
+ score += 2_400
+ }
+ if (nonComposerLike) {
+ score -= 1_800
+ }
+ if (searchLike && !composerLike) {
+ score -= 1_200
+ }
+ if (bottomZone) {
+ score += 900
+ }
+ if (wideEnough) {
+ score += 500
+ }
+ if (candidate.rectTop < candidate.viewportHeight * 0.4) {
+ score -= 1_200
+ }
+ if (candidate.rectBottom > candidate.viewportHeight + 12) {
+ score -= 800
+ }
+
+ return score > 0 ? score : null
+}
+
+function selectBestWenxinEditorCandidate(
+ candidates: T[],
+): T | null {
+ return (
+ candidates
+ .map((candidate) => ({
+ candidate,
+ score: scoreWenxinEditorCandidate(candidate),
+ }))
+ .filter((item): item is { candidate: T; score: number } => item.score !== null)
+ .sort((left, right) => right.score - left.score)[0]?.candidate ?? null
+ )
+}
+
+function normalizeWenxinEditorText(value: string): string {
+ return value.trim().replace(/\s+/g, ' ')
+}
+
+function readNestedRecord(source: unknown, path: string[]): Record | null {
+ let cursor = source
+ for (const key of path) {
+ if (!isRecord(cursor)) {
+ return null
+ }
+ cursor = cursor[key]
+ }
+ return isRecord(cursor) ? cursor : null
+}
+
function decodeUrlCandidate(value: string): string {
let decoded = value.trim()
for (let index = 0; index < 3; index += 1) {
@@ -391,6 +575,55 @@ function mergeText(current: string | null, next: string | null): string | null {
return `${left}${right}`
}
+function normalizeWenxinMarkdownAnswer(value: string): string | null {
+ const normalized = value
+ .replace(/\u00a0/g, ' ')
+ .replace(/\r/g, '')
+ .replace(/[ \t]+\n/g, '\n')
+ .replace(/(^|\n)([ \t]*)(\d+)\.(?=\S)/g, '$1$2$3. ')
+ .replace(/(^|\n)([ \t]*[-*+])(?=\S)/g, '$1$2 ')
+ .replace(/\n{3,}/g, '\n\n')
+ .trim()
+
+ return normalized || null
+}
+
+function mergeWenxinMarkdownChunk(current: string, next: string): string {
+ if (!current) {
+ return next
+ }
+ if (!next || current === next || current.endsWith(next)) {
+ return current
+ }
+ if (next.startsWith(current)) {
+ return next
+ }
+
+ return `${current}${next}`
+}
+
+function mergeWenxinMarkdownChunks(chunks: string[]): string | null {
+ const merged = chunks.reduce((current, chunk) => mergeWenxinMarkdownChunk(current, chunk), '')
+ return normalizeWenxinMarkdownAnswer(merged)
+}
+
+function appendWenxinMarkdownChunk(summary: WenxinStreamSummary, value: string | null): void {
+ if (typeof value !== 'string' || !value) {
+ return
+ }
+
+ const previous = summary.markdownChunks.at(-1)
+ if (previous === value) {
+ return
+ }
+
+ summary.markdownChunks.push(value)
+ const markdownAnswer = mergeWenxinMarkdownChunks(summary.markdownChunks)
+ if (markdownAnswer) {
+ summary.answer = markdownAnswer
+ }
+}
+
function safeParseJSON(value: string): unknown {
try {
return JSON.parse(value)
@@ -455,12 +688,13 @@ function buildSourceItem(input: unknown): MonitoringSourceItem | null {
return null
}
- let host: string | null = null
- try {
- host = new URL(url).hostname || null
- } catch {
- host = null
- }
+ const host = (() => {
+ try {
+ return new URL(url).hostname || null
+ } catch {
+ return null
+ }
+ })()
return {
url,
@@ -811,9 +1045,13 @@ function emptyWenxinStreamSummary(): WenxinStreamSummary {
sessionId: null,
userChatId: null,
botChatId: null,
+ source: 'unknown',
providerModel: null,
providerRequestID: null,
answer: null,
+ markdownChunks: [],
+ citations: [],
+ searchResults: [],
withdrawText: null,
errorMessage: null,
isBanned: false,
@@ -826,6 +1064,166 @@ function emptyWenxinStreamSummary(): WenxinStreamSummary {
}
}
+function readRecordByKeys(source: unknown, keys: string[]): Record | null {
+ if (!isRecord(source)) {
+ return null
+ }
+
+ for (const key of keys) {
+ const value = source[key]
+ if (isRecord(value)) {
+ return value
+ }
+ }
+
+ return null
+}
+
+function readFlexibleBooleanByKeys(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
+ }
+ if (typeof value === 'number' && Number.isFinite(value)) {
+ return value !== 0
+ }
+ if (typeof value === 'string') {
+ const normalized = value.trim().toLowerCase()
+ if (['true', '1', 'yes', 'done', 'finish', 'finished'].includes(normalized)) {
+ return true
+ }
+ if (['false', '0', 'no', 'pending', 'running'].includes(normalized)) {
+ return false
+ }
+ }
+ }
+
+ return null
+}
+
+function updateWenxinStreamSummaryFromPayload(
+ summary: WenxinStreamSummary,
+ payload: Record,
+): void {
+ const payloadData = readRecordByKeys(payload, ['data', 'result', 'content']) ?? payload
+ const nestedMessage = readNestedRecord(payload, ['data', 'message'])
+ const generator = readNestedRecord(payload, ['data', 'message', 'content', 'generator'])
+ const generatorData = readNestedRecord(payload, ['data', 'message', 'content', 'generator', 'data'])
+ const usedModel =
+ readRecordByKeys(generator, ['usedModel', 'modelInfo', 'model_info']) ??
+ readRecordByKeys(payloadData, ['usedModel', 'modelInfo', 'model_info']) ??
+ readRecordByKeys(payload, ['usedModel', 'modelInfo', 'model_info'])
+
+ summary.sessionId =
+ readStringByKeys(payload, ['sessionId', 'qid']) ??
+ readStringByKeys(payloadData, [
+ 'session_id',
+ 'sessionId',
+ 'conversation_id',
+ 'conversationId',
+ 'conversationID',
+ ]) ??
+ readStringByKeys(payload, ['session_id', 'sessionId', 'conversation_id', 'conversationId']) ??
+ summary.sessionId
+ summary.botChatId =
+ readStringByKeys(payloadData, ['chat_id', 'chatId', 'bot_chat_id', 'botChatId']) ??
+ summary.botChatId
+ summary.providerModel =
+ readStringByKeys(usedModel, ['modelName', 'showModelName', 'model', 'modelSign']) ??
+ readStringByKeys(payloadData, [
+ 'provider_model',
+ 'providerModel',
+ 'modelName',
+ 'modelSign',
+ 'model',
+ ]) ??
+ summary.providerModel
+ summary.providerRequestID =
+ readStringByKeys(nestedMessage, ['msgId', 'message_id', 'messageId']) ??
+ readStringByKeys(payloadData, [
+ 'message_id',
+ 'messageId',
+ 'request_id',
+ 'requestId',
+ 'logId',
+ 'log_id',
+ ]) ??
+ readStringByKeys(payload, ['message_id', 'messageId', 'request_id', 'requestId', 'logId']) ??
+ summary.providerRequestID
+ summary.withdrawText =
+ readStringByKeys(payloadData, ['withdrawText', 'withdraw_text', 'blockedText']) ??
+ summary.withdrawText
+ summary.isBanned =
+ readFlexibleBooleanByKeys(payloadData, ['isBanned', 'is_banned']) ?? summary.isBanned
+ summary.sessionBan =
+ readFlexibleBooleanByKeys(payloadData, ['sessionBan', 'session_ban']) ?? summary.sessionBan
+ summary.sessionDel =
+ readFlexibleBooleanByKeys(payloadData, ['sessionDel', 'session_del']) ?? summary.sessionDel
+
+ const code = readNumberByKeys(payload, ['code', 'errno', 'status'])
+ const dataCode = readNumberByKeys(payloadData, ['code', 'errno', 'status'])
+ const messageText =
+ readStringByKeys(payload, ['msg', 'message', 'error', 'errmsg', 'errMsg']) ??
+ readStringByKeys(payloadData, ['msg', 'error', 'errmsg', 'errMsg', 'detail'])
+ if ((code !== null && code !== 0) || (dataCode !== null && dataCode !== 0)) {
+ summary.errorMessage = messageText ?? summary.errorMessage
+ }
+
+ const tokensAll = readStringByKeys(payloadData, [
+ 'tokens_all',
+ 'tokensAll',
+ 'answer',
+ 'answerText',
+ 'markdown',
+ ])
+ const generatorComponent = readStringByKeys(generator, ['component'])
+ const generatorValue =
+ typeof generatorData?.value === 'string' ? generatorData.value : null
+ const generatorText = typeof generator?.text === 'string' ? generator.text : null
+ if (generatorComponent === 'markdown-yiyan') {
+ appendWenxinMarkdownChunk(summary, generatorValue ?? generatorText)
+ } else {
+ const delta =
+ (generatorComponent && /answer|markdown|content/i.test(generatorComponent)
+ ? normalizeBlockText(generatorValue ?? generatorText)
+ : null) ??
+ readStringByKeys(payloadData, ['content', 'text', 'delta', 'response', 'result']) ??
+ (code === 0 || code === null ? readStringByKeys(payloadData, ['message']) : null)
+ summary.answer = tokensAll
+ ? selectBestAnswer(summary.answer, tokensAll)
+ : mergeText(summary.answer, delta)
+ }
+ summary.isEnded =
+ readFlexibleBooleanByKeys(payloadData, [
+ 'is_end',
+ 'isEnd',
+ 'is_ended',
+ 'isEnded',
+ 'done',
+ 'finish',
+ 'finished',
+ ]) ??
+ readFlexibleBooleanByKeys(readNestedRecord(payload, ['data', 'message', 'metaData']), [
+ 'endTurn',
+ ]) ??
+ readFlexibleBooleanByKeys(generator, ['isFinished']) ??
+ summary.isEnded
+
+ const citationBucket: MonitoringSourceItem[] = []
+ collectSourceBucketByFieldPattern(payloadData, citationBucket, WENXIN_CITATION_FIELD_PATTERN)
+ collectSourceBucketByFieldPattern(generatorData, citationBucket, WENXIN_CITATION_FIELD_PATTERN)
+ const searchBucket: MonitoringSourceItem[] = []
+ collectSourceBucketByFieldPattern(payloadData, searchBucket, WENXIN_SEARCH_FIELD_PATTERN)
+ collectSourceBucketByFieldPattern(generatorData, searchBucket, WENXIN_SEARCH_FIELD_PATTERN)
+ summary.citations = dedupeSourceItems([...summary.citations, ...citationBucket, ...searchBucket])
+ summary.searchResults = dedupeSourceItems([...summary.searchResults, ...searchBucket])
+}
+
function parseSSERecord(record: string): { event: string; payload: unknown } | null {
const lines = record.split(/\r?\n/)
let eventName = 'message'
@@ -880,7 +1278,17 @@ function parseWenxinSSEBody(
summary.eventCount += 1
+ if (
+ parsed.event === 'basedata' ||
+ readStringByKeys(parsed.payload, ['qid', 'sessionId']) ||
+ readNestedRecord(parsed.payload, ['data', 'message'])
+ ) {
+ summary.source = 'chat_api'
+ }
+
if (parsed.event === 'major') {
+ summary.source = summary.source === 'chat_api' ? summary.source : 'yiyan'
+ updateWenxinStreamSummaryFromPayload(summary, parsed.payload)
const payloadData = isRecord(parsed.payload.data) ? parsed.payload.data : null
const createChat = isRecord(payloadData?.createChatResponseVoCommonResult)
? payloadData.createChatResponseVoCommonResult
@@ -921,6 +1329,11 @@ function parseWenxinSSEBody(
}
if (parsed.event === 'message') {
+ updateWenxinStreamSummaryFromPayload(summary, parsed.payload)
+ if (summary.source === 'chat_api') {
+ continue
+ }
+
const payloadData = isRecord(parsed.payload.data) ? parsed.payload.data : null
const chatID = readStringByKeys(payloadData, ['chat_id', 'chatId'])
if (summary.botChatId && chatID && summary.botChatId !== chatID) {
@@ -944,7 +1357,10 @@ function parseWenxinSSEBody(
if (code !== null && code !== 0 && messageText) {
summary.errorMessage = messageText
}
+ continue
}
+
+ updateWenxinStreamSummaryFromPayload(summary, parsed.payload)
}
if (!summary.errorMessage && captureError && !/AbortError/i.test(captureError)) {
@@ -1076,12 +1492,18 @@ function mergeWenxinObservation(
stream: WenxinStreamSummary,
history: WenxinHistorySummary | null,
): WenxinObservation {
- const citations = dedupeSourceItems([...(history?.citations ?? [])])
- const searchResults = dedupeSourceItems([...(history?.searchResults ?? [])])
+ const citations = dedupeSourceItems([...stream.citations, ...(history?.citations ?? [])])
+ const searchResults = dedupeSourceItems([
+ ...stream.searchResults,
+ ...(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
+ const streamHasAnswer = Boolean(normalizeBlockText(stream.answer))
+ const withdrawText = streamHasAnswer ? stream.withdrawText : history?.withdrawText ?? stream.withdrawText
+ const errorMessage = streamHasAnswer
+ ? stream.errorMessage ?? stream.captureError
+ : history?.errorMessage ?? stream.errorMessage ?? stream.captureError
return {
sessionId: history?.sessionId ?? stream.sessionId,
@@ -1095,6 +1517,7 @@ function mergeWenxinObservation(
errorMessage,
historyGenerating: history?.generating ?? false,
streamDone: stream.captureDone || stream.isEnded,
+ streamSeen: stream.eventCount > 0,
signature: JSON.stringify({
sessionId: history?.sessionId ?? stream.sessionId,
answer,
@@ -1105,6 +1528,7 @@ function mergeWenxinObservation(
errorMessage,
historyGenerating: history?.generating ?? false,
streamDone: stream.captureDone || stream.isEnded,
+ streamSeen: stream.eventCount > 0,
}),
}
}
@@ -1117,18 +1541,24 @@ function isWenxinLoginMessage(value: string | null | undefined): boolean {
return WENXIN_LOGIN_REQUIRED_PATTERN.test(value ?? '')
}
+function isWenxinStaleConversationMessage(value: string | null | undefined): boolean {
+ return WENXIN_STALE_CONVERSATION_PATTERN.test(value ?? '')
+}
+
function isObservationComplete(observation: WenxinObservation): boolean {
if (observation.historyGenerating) {
return false
}
- if (observation.answer && WENXIN_PLACEHOLDER_PATTERN.test(observation.answer)) {
+ if (!observation.answer || WENXIN_PLACEHOLDER_PATTERN.test(observation.answer)) {
return false
}
- return Boolean(
- observation.answer || observation.citations.length || observation.searchResults.length,
- )
+ if (observation.streamSeen && !observation.streamDone) {
+ return false
+ }
+
+ return true
}
async function sleep(ms: number, signal?: AbortSignal): Promise {
@@ -1158,10 +1588,17 @@ async function ensurePageOnWenxinChat(page: PlaywrightPage, signal: AbortSignal)
throw new Error('adapter_aborted')
}
- await page.goto(WENXIN_BOOTSTRAP_URL, {
- waitUntil: 'domcontentloaded',
- timeout: WENXIN_PAGE_READY_TIMEOUT_MS,
- })
+ await page
+ .goto(WENXIN_CHAT_API_BOOTSTRAP_URL, {
+ waitUntil: 'domcontentloaded',
+ timeout: WENXIN_PAGE_READY_TIMEOUT_MS,
+ })
+ .catch(async () => {
+ await page.goto(WENXIN_BOOTSTRAP_URL, {
+ waitUntil: 'domcontentloaded',
+ timeout: WENXIN_PAGE_READY_TIMEOUT_MS,
+ })
+ })
await page
.waitForLoadState('domcontentloaded', {
@@ -1175,6 +1612,140 @@ async function ensurePageOnWenxinChat(page: PlaywrightPage, signal: AbortSignal)
.catch(() => undefined)
}
+async function startFreshWenxinConversation(
+ page: PlaywrightPage,
+ signal: AbortSignal,
+): Promise {
+ if (signal.aborted) {
+ throw new Error('adapter_aborted')
+ }
+
+ const clickNewChat = async (): Promise =>
+ await page
+ .evaluate(() => {
+ const normalize = (value: unknown): string | null => {
+ if (typeof value !== 'string') {
+ return null
+ }
+ const trimmed = value.replace(/\u00a0/g, ' ').trim().replace(/\s+/g, ' ')
+ 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 isInteractive = (element: HTMLElement): boolean => {
+ const style = window.getComputedStyle(element)
+ return (
+ element.matches('button, a[href], [role="button"], [tabindex]') ||
+ style.cursor === 'pointer' ||
+ typeof element.onclick === 'function'
+ )
+ }
+ const readText = (element: HTMLElement): string | null =>
+ normalize(
+ [
+ element.getAttribute('aria-label'),
+ element.getAttribute('title'),
+ element.innerText || element.textContent || '',
+ ].join(' '),
+ )
+
+ const candidates = Array.from(
+ document.querySelectorAll('button, a[href], [role="button"], [tabindex], div, span'),
+ )
+ .filter((element): element is HTMLElement => isVisible(element))
+ .map((element) => {
+ const text = readText(element)
+ if (!text || !/(新建对话|新对话|开启新对话|重新开始)/i.test(text)) {
+ return null
+ }
+
+ const target =
+ element.closest('button, a[href], [role="button"], [tabindex]') ??
+ (isInteractive(element) ? element : null)
+ if (!(target instanceof HTMLElement) || !isVisible(target) || !isInteractive(target)) {
+ return null
+ }
+
+ const targetText = readText(target) ?? text
+ if (targetText.length > 32 && !/^(?:\+?\s*)?(?:新建对话|新对话|开启新对话)$/i.test(targetText)) {
+ return null
+ }
+
+ const className = String(target.className || '')
+ let score = 0
+ if (/^(?:\+?\s*)?(?:新建对话|新对话)$/.test(targetText)) {
+ score += 120
+ }
+ if (target.matches('button, a[href], [role="button"]')) {
+ score += 60
+ }
+ if (/(new|create|chat|conversation|sidebar)/i.test(className)) {
+ score += 30
+ }
+ if (targetText.length <= 12) {
+ score += 20
+ }
+ if (/换个话题|请点击|重新开始吧/i.test(targetText) && targetText.length > 12) {
+ score -= 120
+ }
+ return score > 0 ? { target, score } : null
+ })
+ .filter((item): item is { target: HTMLElement; score: number } => Boolean(item))
+ .sort((left, right) => right.score - left.score)
+
+ const target = candidates[0]?.target
+ if (!target) {
+ return false
+ }
+ target.scrollIntoView({ block: 'center', inline: 'center' })
+ target.click()
+ return true
+ })
+ .catch(() => false)
+
+ const clickedBeforeNavigation = await clickNewChat()
+ if (clickedBeforeNavigation) {
+ await page.waitForTimeout(1_000).catch(() => undefined)
+ await page.waitForLoadState('networkidle', { timeout: 2_000 }).catch(() => undefined)
+ }
+
+ await page
+ .goto(`${WENXIN_CHAT_API_NEW_CHAT_BASE_URL}&_geo_t=${Date.now()}`, {
+ waitUntil: 'domcontentloaded',
+ timeout: WENXIN_PAGE_READY_TIMEOUT_MS,
+ })
+ .catch(async () => {
+ await page.goto(WENXIN_CHAT_API_BOOTSTRAP_URL, {
+ waitUntil: 'domcontentloaded',
+ timeout: WENXIN_PAGE_READY_TIMEOUT_MS,
+ })
+ })
+ await page.waitForLoadState('networkidle', { timeout: 2_000 }).catch(() => undefined)
+
+ const clicked = await clickNewChat()
+
+ if (clicked) {
+ await page.waitForTimeout(800).catch(() => undefined)
+ await page.waitForLoadState('networkidle', { timeout: 2_000 }).catch(() => undefined)
+ }
+
+ return true
+}
+
async function readWenxinPageSnapshot(page: PlaywrightPage): Promise {
return await page.evaluate(
({
@@ -1925,13 +2496,194 @@ async function maybeOpenWenxinReferencePanel(page: PlaywrightPage): Promise {
- const editor = page.locator(WENXIN_EDITOR_SELECTOR).first()
- await editor.waitFor({
- state: 'visible',
- timeout: WENXIN_PAGE_READY_TIMEOUT_MS,
- })
+async function collectWenxinEditorCandidates(
+ page: PlaywrightPage,
+): Promise {
+ return await page.evaluate(
+ ({ selector, contextSelector }) => {
+ type CandidateKind = 'contenteditable' | 'textarea' | 'input'
+ type Candidate = {
+ id: string
+ kind: CandidateKind
+ disabled: boolean
+ readOnly: boolean
+ hintText: string
+ contextText: string
+ valueLength: number
+ rectTop: number
+ rectBottom: number
+ rectWidth: number
+ rectHeight: number
+ viewportWidth: number
+ viewportHeight: number
+ }
+ const normalize = (value: unknown): string => {
+ if (typeof value !== 'string') {
+ return ''
+ }
+ return value.replace(/\u00a0/g, ' ').replace(/\s+/g, ' ').trim()
+ }
+ const readClassName = (element: Element): string => {
+ const className = element.getAttribute('class')
+ return typeof className === 'string' ? className : ''
+ }
+ const readElementHint = (element: HTMLElement): string =>
+ [
+ element.getAttribute('aria-label'),
+ element.getAttribute('placeholder'),
+ element.getAttribute('title'),
+ element.getAttribute('name'),
+ element.getAttribute('id'),
+ element.getAttribute('data-testid'),
+ element.getAttribute('data-test-id'),
+ element.getAttribute('data-type'),
+ element.getAttribute('role'),
+ readClassName(element),
+ ]
+ .map(normalize)
+ .filter(Boolean)
+ .join(' ')
+ const readContextText = (element: HTMLElement): string => {
+ const shell =
+ element.closest(contextSelector) ??
+ element.parentElement?.closest(contextSelector) ??
+ element.parentElement ??
+ element
+ if (!(shell instanceof HTMLElement)) {
+ return ''
+ }
+ const shellText = normalize(shell.innerText || shell.textContent || '').slice(0, 500)
+ return [readElementHint(shell), shellText].filter(Boolean).join(' ')
+ }
+ const isVisible = (element: HTMLElement): boolean => {
+ 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 getKind = (element: HTMLElement): CandidateKind | null => {
+ if (element instanceof HTMLTextAreaElement) {
+ return 'textarea'
+ }
+ if (element instanceof HTMLInputElement) {
+ return 'input'
+ }
+ if (
+ element.isContentEditable ||
+ element.getAttribute('contenteditable') === 'true' ||
+ element.getAttribute('data-lexical-editor') === 'true' ||
+ element.getAttribute('data-slate-editor') === 'true'
+ ) {
+ return 'contenteditable'
+ }
+ return null
+ }
+ const getValueLength = (element: HTMLElement): number => {
+ if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) {
+ return element.value.length
+ }
+ return (element.innerText || element.textContent || '').length
+ }
+
+ const seen = new Set()
+ const candidates: Candidate[] = []
+ for (const node of Array.from(document.querySelectorAll(selector))) {
+ if (!(node instanceof HTMLElement) || seen.has(node) || !isVisible(node)) {
+ continue
+ }
+ seen.add(node)
+
+ const kind = getKind(node)
+ if (!kind) {
+ continue
+ }
+ const rect = node.getBoundingClientRect()
+ const id = `geo-wenxin-editor-${Date.now()}-${candidates.length}`
+ node.setAttribute('data-geo-wenxin-editor-candidate-id', id)
+ candidates.push({
+ id,
+ kind,
+ disabled:
+ (node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement) &&
+ node.disabled,
+ readOnly:
+ (node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement) &&
+ node.readOnly,
+ hintText: readElementHint(node),
+ contextText: readContextText(node),
+ valueLength: getValueLength(node),
+ rectTop: rect.top,
+ rectBottom: rect.bottom,
+ rectWidth: rect.width,
+ rectHeight: rect.height,
+ viewportWidth: window.innerWidth,
+ viewportHeight: window.innerHeight,
+ })
+ }
+
+ return candidates
+ },
+ {
+ selector: WENXIN_EDITOR_SELECTOR,
+ contextSelector: WENXIN_EDITOR_CONTEXT_SELECTOR,
+ },
+ )
+}
+
+async function resolveWenxinEditor(
+ page: PlaywrightPage,
+ timeoutMs = WENXIN_PAGE_READY_TIMEOUT_MS,
+): Promise {
+ const deadline = Date.now() + timeoutMs
+
+ do {
+ const candidates = await collectWenxinEditorCandidates(page).catch(() => [])
+ const selected = selectBestWenxinEditorCandidate(candidates)
+ if (selected) {
+ return {
+ locator: page
+ .locator(`[data-geo-wenxin-editor-candidate-id="${selected.id}"]`)
+ .first(),
+ kind: selected.kind,
+ id: selected.id,
+ }
+ }
+
+ if (Date.now() >= deadline) {
+ break
+ }
+ await page.waitForTimeout(200).catch(() => undefined)
+ } while (Date.now() < deadline)
+
+ return null
+}
+
+async function isWenxinEditorVisible(
+ page: PlaywrightPage,
+ timeoutMs = 2_500,
+): Promise {
+ return Boolean(await resolveWenxinEditor(page, timeoutMs))
+}
+
+async function fillWenxinEditorText(
+ page: PlaywrightPage,
+ questionText: string,
+ timeoutMs = WENXIN_PAGE_READY_TIMEOUT_MS,
+): Promise {
+ const resolvedEditor = await resolveWenxinEditor(page, timeoutMs)
+ if (!resolvedEditor) {
+ throw new Error('wenxin_editor_not_found')
+ }
+
+ const editor = resolvedEditor.locator
await editor.scrollIntoViewIfNeeded().catch(() => undefined)
await editor
.evaluate((node) => {
@@ -1941,25 +2693,56 @@ async function fillWenxinEditorText(page: PlaywrightPage, questionText: string):
})
.catch(() => undefined)
- let filled = false
- try {
- await editor.fill(questionText)
- filled = true
- } catch {
- filled = false
- }
+ const filled =
+ resolvedEditor.kind === 'textarea' || resolvedEditor.kind === 'input'
+ ? await editor.fill(questionText).then(
+ () => true,
+ () => false,
+ )
+ : false
- const normalizedExpected = questionText.trim().replace(/\s+/g, ' ')
- const normalizedActual = (await editor.innerText().catch(() => '')).trim().replace(/\s+/g, ' ')
+ const normalizedExpected = normalizeWenxinEditorText(questionText)
+ const normalizedActual = normalizeWenxinEditorText(
+ await editor
+ .evaluate((node) => {
+ if (node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement) {
+ return node.value
+ }
+ if (node instanceof HTMLElement) {
+ return node.innerText || node.textContent || ''
+ }
+ return ''
+ })
+ .catch(() => ''),
+ )
if (!filled || normalizedActual !== normalizedExpected) {
await page.evaluate(
- ({ selector, text }) => {
- const editorElement = document.querySelector(selector)
+ ({ editorId, text }) => {
+ const editorElement = document.querySelector(
+ `[data-geo-wenxin-editor-candidate-id="${editorId}"]`,
+ )
if (!(editorElement instanceof HTMLElement)) {
throw new Error('wenxin_editor_missing')
}
+ if (
+ editorElement instanceof HTMLInputElement ||
+ editorElement instanceof HTMLTextAreaElement
+ ) {
+ editorElement.focus()
+ editorElement.value = text
+ editorElement.dispatchEvent(
+ new InputEvent('input', {
+ bubbles: true,
+ inputType: 'insertText',
+ data: text,
+ }),
+ )
+ editorElement.dispatchEvent(new Event('change', { bubbles: true }))
+ return
+ }
+
const selection = window.getSelection()
const range = document.createRange()
range.selectNodeContents(editorElement)
@@ -1991,34 +2774,607 @@ async function fillWenxinEditorText(page: PlaywrightPage, questionText: string):
}
},
{
- selector: WENXIN_EDITOR_SELECTOR,
+ editorId: resolvedEditor.id,
text: questionText,
},
)
}
+
+ return resolvedEditor
+}
+
+async function tryClickWenxinSendButton(
+ page: PlaywrightPage,
+ resolvedEditor: WenxinResolvedEditor,
+): Promise {
+ return await page
+ .evaluate(
+ ({ editorId, contextSelector }) => {
+ const editor =
+ document.querySelector(`[data-geo-wenxin-editor-candidate-id="${editorId}"]`) ??
+ document.activeElement
+ if (!(editor instanceof HTMLElement)) {
+ return false
+ }
+
+ const normalize = (value: unknown): string => {
+ if (typeof value !== 'string') {
+ return ''
+ }
+ return value.replace(/\u00a0/g, ' ').replace(/\s+/g, ' ').trim()
+ }
+ const isVisible = (element: HTMLElement): boolean => {
+ 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 isDisabled = (element: HTMLElement): boolean => {
+ if (element instanceof HTMLButtonElement || element instanceof HTMLInputElement) {
+ return element.disabled
+ }
+ const ariaDisabled = normalize(element.getAttribute('aria-disabled')).toLowerCase()
+ const className = normalize(element.getAttribute('class')).toLowerCase()
+ return (
+ ariaDisabled === 'true' ||
+ /\b(?:disabled|disable|inactive|forbidden)\b/i.test(className)
+ )
+ }
+
+ const shell =
+ editor.closest(contextSelector) ??
+ editor.parentElement?.closest(contextSelector) ??
+ editor.parentElement ??
+ document.body
+ const scopes: ParentNode[] = [shell]
+ if (shell instanceof HTMLElement && shell.parentElement) {
+ scopes.push(shell.parentElement)
+ }
+ scopes.push(document)
+
+ const seen = new Set()
+ const candidates: Array<{ element: HTMLElement; score: number }> = []
+ const editorRect = editor.getBoundingClientRect()
+ for (const scope of scopes) {
+ for (const element of Array.from(
+ scope.querySelectorAll('button, [role="button"], [aria-label], [title]'),
+ )) {
+ if (!(element instanceof HTMLElement) || seen.has(element) || !isVisible(element)) {
+ continue
+ }
+ seen.add(element)
+ if (isDisabled(element)) {
+ continue
+ }
+
+ const rect = element.getBoundingClientRect()
+ const text = normalize(
+ [
+ element.innerText || element.textContent || '',
+ element.getAttribute('aria-label'),
+ element.getAttribute('title'),
+ element.getAttribute('class'),
+ element.getAttribute('data-testid'),
+ ].join(' '),
+ )
+ const isSendLike =
+ /(发送|提交|send|submit|arrow-up|paper-plane|plane|btn-send|send-button|input-send)/i.test(
+ text,
+ )
+ const nearEditor =
+ Math.abs(rect.top - editorRect.top) <= 180 ||
+ Math.abs(rect.bottom - editorRect.bottom) <= 180
+ const toEditorRight = rect.left >= editorRect.right - 140
+ const compactIconButton = rect.width <= 96 && rect.height <= 96
+ if (!isSendLike && !(nearEditor && toEditorRight && compactIconButton)) {
+ continue
+ }
+ let score = 0
+ if (isSendLike) {
+ score += 2_000
+ }
+ if (nearEditor) {
+ score += 1_000
+ }
+ if (toEditorRight) {
+ score += 300
+ }
+ if (rect.top < window.innerHeight * 0.45) {
+ score -= 900
+ }
+ if (/新建对话|历史|登录|设置|分享|取消/i.test(text)) {
+ score -= 1_500
+ }
+ if (score > 0) {
+ candidates.push({ element, score })
+ }
+ }
+ }
+
+ candidates.sort((left, right) => right.score - left.score)
+ const selected = candidates[0]?.element
+ if (!selected) {
+ return false
+ }
+ selected.click()
+ return true
+ },
+ {
+ editorId: resolvedEditor.id,
+ contextSelector: WENXIN_EDITOR_CONTEXT_SELECTOR,
+ },
+ )
+ .catch(() => false)
}
async function submitWenxinQuestion(
page: PlaywrightPage,
questionText: string,
signal: AbortSignal,
+ editorTimeoutMs = WENXIN_PAGE_READY_TIMEOUT_MS,
): Promise {
if (signal.aborted) {
throw new Error('adapter_aborted')
}
- await fillWenxinEditorText(page, questionText)
+ const resolvedEditor = await fillWenxinEditorText(page, questionText, editorTimeoutMs)
await sleep(150, signal)
- const editor = page.locator(WENXIN_EDITOR_SELECTOR).first()
- await editor.press('Enter').catch(async () => {
+ if (await tryClickWenxinSendButton(page, resolvedEditor)) {
+ return
+ }
+
+ await resolvedEditor.locator.press('Enter').catch(async () => {
await page.keyboard.press('Enter').catch(() => undefined)
})
}
+function buildWenxinChatApiPayload(questionText: string): Record {
+ return {
+ message: {
+ inputMethod: 'chat_search',
+ isRebuild: false,
+ content: {
+ query: '',
+ agentInfo: {
+ agent_id: [''],
+ params: JSON.stringify({ agt_rk: 1, agt_sess_cnt: 1 }),
+ },
+ agentInfoList: [],
+ qtype: 0,
+ },
+ searchInfo: {
+ srcid: '',
+ order: '',
+ tplname: '',
+ dqaKey: '',
+ re_rank: '1',
+ ori_lid: '',
+ sa: 'bkb',
+ enter_type: 'chat_url',
+ chatParams: {
+ setype: 'csaitab',
+ chat_samples: 'WISE_NEW_CSAITAB',
+ chat_token: '',
+ scene: '',
+ },
+ isPrivateChat: false,
+ usedModel: {
+ modelName: 'smartMode',
+ modelFunction: {
+ deepSearch: '1',
+ thinkMode: '0',
+ },
+ showModelName: 'smartMode',
+ },
+ landingPageSwitch: '',
+ landingPage: 'aitab',
+ ecomFrom: '',
+ hasLocPermission: '',
+ isInnovate: 2,
+ applid: '',
+ a_lid: '',
+ showMindMap: false,
+ deepDecisionInfo: {
+ isDeepDecision: 0,
+ },
+ },
+ from: '',
+ source: 'pc_csaitab',
+ query: [
+ {
+ type: 'TEXT',
+ data: {
+ text: {
+ query: questionText,
+ extData: '{}',
+ text_type: '',
+ },
+ },
+ },
+ ],
+ anti_ext: {
+ inputT: null,
+ ck1: 16817,
+ ck9: 1404,
+ ck10: 800,
+ },
+ },
+ sa: 'bkb',
+ setype: 'csaitab',
+ rank: 1,
+ }
+}
+
+async function submitWenxinQuestionViaChatApi(
+ page: PlaywrightPage,
+ questionText: string,
+ signal: AbortSignal,
+): Promise {
+ if (signal.aborted) {
+ throw new Error('adapter_aborted')
+ }
+
+ return await page.evaluate(
+ async ({ url, payload, timeoutMs, 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
+ }
+ const state: CaptureState = globalWindow.__geoWenxinCapture ?? {
+ sequence: 0,
+ entries: [],
+ }
+ globalWindow.__geoWenxinCapture = state
+
+ const isRecord = (value: unknown): value is Record =>
+ typeof value === 'object' && value !== null && !Array.isArray(value)
+ const normalize = (value: unknown): string | null =>
+ typeof value === 'string' && value.trim() ? value.trim() : null
+ const findChatTokenInText = (value: unknown): string | null => {
+ const text = normalize(value)
+ if (!text) {
+ return null
+ }
+ const keyMatch = text.match(
+ /["']?chat_token["']?\s*[:=]\s*["']([^"']{24,})["']/i,
+ )
+ if (keyMatch?.[1]) {
+ return keyMatch[1]
+ }
+ const tokenMatch = text.match(/[A-Za-z0-9+/=]{48,}-\d+-\d+/)
+ return tokenMatch?.[0] ?? null
+ }
+ const readNestedString = (value: unknown, path: string[]): string | null => {
+ let cursor = value
+ for (const key of path) {
+ if (!isRecord(cursor)) {
+ return null
+ }
+ cursor = cursor[key]
+ }
+ return normalize(cursor)
+ }
+ const findChatTokenInObject = (
+ value: unknown,
+ seen = new Set(),
+ depth = 0,
+ ): string | null => {
+ if (depth > 8 || value == null || seen.has(value)) {
+ return null
+ }
+ if (typeof value === 'string') {
+ return findChatTokenInText(value)
+ }
+ if (!isRecord(value) && !Array.isArray(value)) {
+ return null
+ }
+ seen.add(value)
+ if (Array.isArray(value)) {
+ for (const item of value) {
+ const token = findChatTokenInObject(item, seen, depth + 1)
+ if (token) {
+ return token
+ }
+ }
+ return null
+ }
+ for (const [key, item] of Object.entries(value)) {
+ if (/chat_?token/i.test(key)) {
+ const token = findChatTokenInText(item)
+ if (token) {
+ return token
+ }
+ }
+ if (Array.isArray(item) || isRecord(item) || typeof item === 'string') {
+ const token = findChatTokenInObject(item, seen, depth + 1)
+ if (token) {
+ return token
+ }
+ }
+ }
+ return null
+ }
+ const findChatTokenInStorage = (): string | null => {
+ for (const storage of [window.localStorage, window.sessionStorage]) {
+ for (let index = 0; index < storage.length; index += 1) {
+ const key = storage.key(index)
+ if (!key) {
+ continue
+ }
+ const token = findChatTokenInText(key) ?? findChatTokenInText(storage.getItem(key))
+ if (token) {
+ return token
+ }
+ }
+ }
+ return null
+ }
+ const stateCandidates = [
+ (window as typeof window & { __NEXT_DATA__?: unknown }).__NEXT_DATA__,
+ (window as typeof window & { __INITIAL_STATE__?: unknown }).__INITIAL_STATE__,
+ (
+ window as typeof window & {
+ __NEXT_REDUX_STORE__?: { getState?: () => unknown }
+ }
+ ).__NEXT_REDUX_STORE__?.getState?.(),
+ ]
+ const chatTokenCandidates = [
+ {
+ token: readNestedString(payload, [
+ 'message',
+ 'searchInfo',
+ 'chatParams',
+ 'chat_token',
+ ]),
+ source: 'payload',
+ },
+ {
+ token:
+ new URL(window.location.href).searchParams.get('chat_token') ??
+ new URL(window.location.href).searchParams.get('chatToken'),
+ source: 'url',
+ },
+ {
+ token: findChatTokenInStorage(),
+ source: 'storage',
+ },
+ {
+ token: stateCandidates.map((item) => findChatTokenInObject(item)).find(Boolean) ?? null,
+ source: 'state',
+ },
+ ]
+ const chatTokenCandidate = chatTokenCandidates.find((candidate) => Boolean(candidate.token))
+ const chatToken = chatTokenCandidate?.token ?? null
+ const chatTokenSource = chatTokenCandidate?.source ?? null
+
+ if (!chatToken) {
+ return {
+ ok: false,
+ mode: 'chat_api' as const,
+ status: null,
+ contentType: null,
+ detail: 'wenxin_chat_token_missing',
+ chatTokenPresent: false,
+ chatTokenSource: null,
+ }
+ }
+
+ if (chatToken) {
+ const chatParams = isRecord(
+ (payload.message as Record | undefined)?.searchInfo,
+ )
+ ? ((payload.message as Record).searchInfo as Record)
+ .chatParams
+ : null
+ if (isRecord(chatParams)) {
+ chatParams.chat_token = chatToken
+ }
+ }
+
+ const body = JSON.stringify(payload)
+ const entry: CaptureEntry = {
+ id: `wenxin-chat-api-${(state.sequence += 1)}`,
+ url,
+ method: 'POST',
+ status: null,
+ contentType: null,
+ requestBody: body,
+ body: '',
+ done: false,
+ error: null,
+ startedAt: Date.now(),
+ updatedAt: Date.now(),
+ }
+ state.entries.push(entry)
+
+ const finalizeEntry = () => {
+ if (entry.body.length > bodyLimit) {
+ entry.body = entry.body.slice(0, bodyLimit)
+ }
+ entry.done = true
+ entry.updatedAt = Date.now()
+ }
+
+ const readResponseBody = async (response: Response) => {
+ const decoder = new TextDecoder()
+ try {
+ if (response.body) {
+ const reader = response.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 response.text()
+ }
+ } catch (error) {
+ entry.error =
+ error instanceof Error ? `${error.name}: ${error.message}` : String(error)
+ } finally {
+ finalizeEntry()
+ }
+ }
+
+ const controller = new AbortController()
+ const timeout = window.setTimeout(() => controller.abort(), timeoutMs)
+ try {
+ const response = await fetch(url, {
+ method: 'POST',
+ credentials: 'include',
+ headers: {
+ accept: 'text/event-stream, application/json, text/plain, */*',
+ 'content-type': 'application/json',
+ 'x-requested-with': 'XMLHttpRequest',
+ },
+ body,
+ signal: controller.signal,
+ })
+ entry.status = Number.isFinite(response.status) ? response.status : null
+ entry.contentType = response.headers.get('content-type')
+
+ window.clearTimeout(timeout)
+
+ if (response.ok) {
+ void readResponseBody(response)
+ } else {
+ await readResponseBody(response)
+ entry.error = entry.body.slice(0, 240) || response.statusText || `HTTP ${response.status}`
+ }
+
+ return {
+ ok: response.ok,
+ mode: 'chat_api' as const,
+ status: entry.status,
+ contentType: entry.contentType,
+ detail: response.ok ? null : entry.body.slice(0, 240) || response.statusText,
+ chatTokenPresent: Boolean(chatToken),
+ chatTokenSource,
+ }
+ } catch (error) {
+ entry.error = error instanceof Error ? `${error.name}: ${error.message}` : String(error)
+ return {
+ ok: false,
+ mode: 'chat_api' as const,
+ status: entry.status,
+ contentType: entry.contentType,
+ detail: entry.error,
+ chatTokenPresent: Boolean(chatToken),
+ chatTokenSource,
+ }
+ } finally {
+ window.clearTimeout(timeout)
+ if (!entry.done && entry.error) {
+ finalizeEntry()
+ }
+ }
+ },
+ {
+ url: WENXIN_CHAT_API_CONVERSATION_URL,
+ payload: buildWenxinChatApiPayload(questionText),
+ timeoutMs: WENXIN_CHAT_API_SUBMIT_TIMEOUT_MS,
+ bodyLimit: WENXIN_STREAM_CAPTURE_BODY_LIMIT,
+ },
+ )
+}
+
+async function submitWenxinQuestionWithFallback(
+ page: PlaywrightPage,
+ questionText: string,
+ signal: AbortSignal,
+ editorOnly = false,
+): Promise {
+ if (editorOnly) {
+ const editorTimeoutMs = 10_000
+ if (!(await isWenxinEditorVisible(page, editorTimeoutMs))) {
+ return {
+ ok: false,
+ mode: 'editor',
+ status: null,
+ contentType: null,
+ detail: 'wenxin_editor_not_found_after_new_chat',
+ }
+ }
+ await resetWenxinFetchCapture(page).catch(() => undefined)
+ await submitWenxinQuestion(page, questionText, signal, editorTimeoutMs)
+ return {
+ ok: true,
+ mode: 'editor',
+ status: null,
+ contentType: null,
+ detail: null,
+ }
+ }
+
+ const chatApiResult = await submitWenxinQuestionViaChatApi(page, questionText, signal).catch(
+ (error): WenxinSubmitResult => ({
+ ok: false,
+ mode: 'chat_api',
+ status: null,
+ contentType: null,
+ detail: error instanceof Error ? error.message : String(error),
+ }),
+ )
+
+ if (chatApiResult.ok) {
+ return chatApiResult
+ }
+
+ const editorFallbackTimeoutMs =
+ chatApiResult.detail === 'wenxin_chat_token_missing' ? 10_000 : 2_500
+ if (!(await isWenxinEditorVisible(page, editorFallbackTimeoutMs))) {
+ return chatApiResult
+ }
+
+ await resetWenxinFetchCapture(page).catch(() => undefined)
+ await submitWenxinQuestion(page, questionText, signal, editorFallbackTimeoutMs)
+ return {
+ ok: true,
+ mode: 'editor',
+ status: null,
+ contentType: null,
+ detail: chatApiResult.detail,
+ }
+}
+
async function installWenxinFetchCapture(page: PlaywrightPage): Promise {
await page.evaluate(
- ({ captureLimit, bodyLimit }) => {
+ ({ captureLimit, bodyLimit, patterns }) => {
type CaptureEntry = {
id: string
url: string
@@ -2076,7 +3432,7 @@ async function installWenxinFetchCapture(page: PlaywrightPage): Promise {
const requestBody = typeof init?.body === 'string' ? init.body : null
const response = await originalFetch(...args)
- if (!url.includes('/eb/chat/conversation/v2')) {
+ if (!patterns.some((pattern) => url.includes(pattern))) {
return response
}
@@ -2135,6 +3491,7 @@ async function installWenxinFetchCapture(page: PlaywrightPage): Promise {
{
captureLimit: WENXIN_STREAM_CAPTURE_LIMIT,
bodyLimit: WENXIN_STREAM_CAPTURE_BODY_LIMIT,
+ patterns: WENXIN_STREAM_URL_PATTERNS,
},
)
}
@@ -2228,7 +3585,7 @@ async function waitForWenxinAnswer(
let lastSignature: string | null = null
let latestStream = emptyWenxinStreamSummary()
let latestHistory: WenxinHistorySummary | null = null
- let latestObservation = mergeWenxinObservation(latestStream, latestHistory)
+ let latestObservation: WenxinObservation
let seenCapture = false
let resolvedSessionId: string | null = null
@@ -2259,7 +3616,7 @@ async function waitForWenxinAnswer(
}
}
- if (resolvedSessionId) {
+ if (resolvedSessionId && latestStream.source !== 'chat_api') {
latestHistory = await fetchWenxinHistory(session, resolvedSessionId)
}
@@ -2278,6 +3635,25 @@ async function waitForWenxinAnswer(
}
}
+ if (
+ isWenxinStaleConversationMessage(latestObservation.withdrawText) ||
+ isWenxinStaleConversationMessage(latestObservation.errorMessage)
+ ) {
+ return {
+ ok: false,
+ error: 'wenxin_stale_conversation',
+ detail:
+ latestObservation.withdrawText ??
+ latestObservation.errorMessage ??
+ 'stale_conversation',
+ stream: latestStream,
+ history: latestHistory,
+ observation: latestObservation,
+ stablePollCount,
+ elapsedMs: Date.now() - startedAt,
+ }
+ }
+
if (
isWenxinRiskControlMessage(latestObservation.withdrawText) ||
isWenxinRiskControlMessage(latestObservation.errorMessage)
@@ -2405,32 +3781,77 @@ export const wenxinAdapter: MonitorAdapter = {
await ensurePageOnWenxinChat(page, context.signal)
await installWenxinFetchCapture(page)
await resetWenxinFetchCapture(page)
+ await startFreshWenxinConversation(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,
- },
- ),
+
+ let submitResult: WenxinSubmitResult | null = null
+ let waitResult: WenxinWaitResult | null = null
+ let retryReason: string | null = null
+ let attempts = 0
+
+ while (attempts < WENXIN_MAX_SUBMIT_ATTEMPTS) {
+ attempts += 1
+ context.reportProgress(attempts === 1 ? 'wenxin.query' : 'wenxin.query_retry')
+ submitResult = await submitWenxinQuestionWithFallback(
+ page,
+ questionText,
+ context.signal,
+ attempts > 1,
+ )
+ if (!submitResult.ok) {
+ const captures = await readWenxinFetchCapture(page).catch(() => [])
+ return {
+ status: 'failed',
+ summary: `文心一言请求提交失败:${submitResult.detail ?? 'chat_api_failed'}`,
+ error: buildAdapterError(
+ 'wenxin_submit_failed',
+ submitResult.detail ?? 'chat_api_failed',
+ {
+ page_url: initialSnapshot.url,
+ submit_mode: submitResult.mode,
+ submit_status: submitResult.status,
+ submit_content_type: submitResult.contentType,
+ chat_token_present: submitResult.chatTokenPresent ?? null,
+ chat_token_source: submitResult.chatTokenSource ?? null,
+ stream_capture_count: captures.length,
+ stream_captures: buildWenxinCaptureDebug(captures),
+ },
+ ),
+ }
}
+
+ context.reportProgress(attempts === 1 ? 'wenxin.wait_answer' : 'wenxin.wait_answer_retry')
+ waitResult = await waitForWenxinAnswer(
+ page,
+ context.session,
+ questionText,
+ context.signal,
+ )
+
+ if (
+ waitResult.ok ||
+ waitResult.error !== 'wenxin_stale_conversation' ||
+ attempts >= WENXIN_MAX_SUBMIT_ATTEMPTS
+ ) {
+ break
+ }
+
+ retryReason = waitResult.detail
+ await startFreshWenxinConversation(page, context.signal)
+ await installWenxinFetchCapture(page).catch(() => undefined)
+ await resetWenxinFetchCapture(page).catch(() => undefined)
}
- 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,
- )
+ if (!submitResult || !waitResult) {
+ return {
+ status: 'failed',
+ summary: '文心一言请求失败:未能完成提交。',
+ error: buildAdapterError('wenxin_submit_failed', 'submit_result_missing'),
+ }
+ }
const captures = await readWenxinFetchCapture(page)
await maybeOpenWenxinReferencePanel(page).catch(() => undefined)
const finalSnapshot = await readWenxinPageSnapshot(page).catch(() => initialSnapshot)
@@ -2474,6 +3895,12 @@ export const wenxinAdapter: MonitorAdapter = {
summary: '文心一言超时未拿到完整答案,已回写 unknown 等待后续补采。',
error: buildAdapterError(waitResult.error, waitResult.detail ?? waitResult.error, {
page_url: finalSnapshot.url,
+ submit_mode: submitResult.mode,
+ submit_status: submitResult.status,
+ chat_token_present: submitResult.chatTokenPresent ?? null,
+ chat_token_source: submitResult.chatTokenSource ?? null,
+ attempt_count: attempts,
+ stale_retry_reason: retryReason,
session_id: requestID,
stream_done: waitResult.stream.captureDone || waitResult.stream.isEnded,
history_generating: waitResult.history?.generating ?? null,
@@ -2486,16 +3913,34 @@ export const wenxinAdapter: MonitorAdapter = {
summary: `文心一言请求失败:${waitResult.detail ?? waitResult.error}`,
error: buildAdapterError(waitResult.error, waitResult.detail ?? waitResult.error, {
page_url: finalSnapshot.url,
+ submit_mode: submitResult.mode,
+ submit_status: submitResult.status,
+ chat_token_present: submitResult.chatTokenPresent ?? null,
+ chat_token_source: submitResult.chatTokenSource ?? null,
+ attempt_count: attempts,
+ stale_retry_reason: retryReason,
session_id: requestID,
}),
}
}
- if (!answer && !citations.length && !searchResults.length) {
+ if (!answer) {
return {
status: 'unknown',
- summary: '文心一言返回为空,已回写 unknown 等待后续对账。',
- error: buildAdapterError('wenxin_empty_response', 'wenxin returned no answer or sources'),
+ summary: '文心一言未返回正文答案,已回写 unknown 等待后续对账。',
+ error: buildAdapterError('wenxin_empty_response', 'wenxin returned no answer text', {
+ page_url: finalSnapshot.url,
+ submit_mode: submitResult.mode,
+ submit_status: submitResult.status,
+ chat_token_present: submitResult.chatTokenPresent ?? null,
+ chat_token_source: submitResult.chatTokenSource ?? null,
+ attempt_count: attempts,
+ stale_retry_reason: retryReason,
+ citation_count: citations.length,
+ search_result_count: searchResults.length,
+ stream_capture_count: captures.length,
+ stream_captures: buildWenxinCaptureDebug(captures),
+ }),
}
}
@@ -2525,6 +3970,9 @@ export const wenxinAdapter: MonitorAdapter = {
login_reason: finalSnapshot.loginReason,
model_label: finalSnapshot.modelLabel,
user_name: finalSnapshot.userName,
+ submit_result: toJsonValue(submitResult),
+ attempt_count: attempts,
+ stale_retry_reason: retryReason,
session_id: requestID,
answer: answer ?? null,
answer_block_count: observation.answerBlocks.length,
@@ -2556,8 +4004,11 @@ export const __wenxinTestUtils = {
buildSourceItem,
extractQuestionText,
htmlTableToMarkdown,
+ isWenxinStaleConversationMessage,
isObservationComplete,
+ mergeWenxinObservation,
normalizeUrl,
parseWenxinHistoryResponse,
parseWenxinSSEBody,
+ selectBestWenxinEditorCandidate,
}