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, }