From 70b9f75aeaf1ad16af8ff6b03a5bbe0c59c03113 Mon Sep 17 00:00:00 2001 From: liangxu Date: Sun, 12 Jul 2026 14:17:14 +0800 Subject: [PATCH] feat(yuanbao-adapter): match chat SSE by question and keep answer breaks Filter captured `/api/chat/*` event-stream responses down to the one whose request prompt matches the current question, avoiding cross-talk between concurrent conversations. Preserve blank lines when sanitizing answer candidates so paragraph breaks survive, and always trust the parsed SSE answer over the DOM scrape. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/main/adapters/yuanbao.test.ts | 73 ++++++ .../src/main/adapters/yuanbao.ts | 217 ++++++++++++------ 2 files changed, 221 insertions(+), 69 deletions(-) diff --git a/apps/desktop-client/src/main/adapters/yuanbao.test.ts b/apps/desktop-client/src/main/adapters/yuanbao.test.ts index ad79711..7769b52 100644 --- a/apps/desktop-client/src/main/adapters/yuanbao.test.ts +++ b/apps/desktop-client/src/main/adapters/yuanbao.test.ts @@ -9,11 +9,13 @@ const { extractCitationMarkerIndexes, extractQuestionText, findUnresolvedCitationIndexes, + isCurrentYuanbaoChatSSE, mergeText, normalizeCitationMarkers, normalizeSourceUrl, parseYuanbaoCaptures, resolveCitationsFromMarkers, + selectBestAnswerText, } = __yuanbaoTestUtils describe('yuanbao adapter helpers', () => { @@ -67,6 +69,77 @@ describe('yuanbao adapter helpers', () => { expect(mergeText(first, repeated)).toBe(repeated) }) + it('keeps a valid API SSE answer even when the DOM fallback is longer', () => { + const apiAnswer = + '### 核心结论\n\n混动车兼顾补能效率与低速油耗。\n\n- 适合长途出行\n- 适合没有固定充电桩的用户' + const domFallback = + '页面工具栏 联网搜索 深度思考 已完成搜索。混动车兼顾补能效率与低速油耗,适合长途出行,也适合没有固定充电桩的用户。这里还有页面推荐问题、操作按钮和其他更长的界面文本。' + + expect(selectBestAnswerText(apiAnswer, domFallback)).toBe(apiAnswer) + }) + + it('uses the page RPA answer only when API SSE has no usable answer', () => { + const domFallback = '### 页面兜底\n\n- 仅在 SSE 缺失时使用' + + expect(selectBestAnswerText(null, domFallback)).toBe(domFallback) + expect(selectBestAnswerText(' ', domFallback)).toBe(domFallback) + }) + + it('captures only the current POST chat SSE response', () => { + const question = '今天是几号?' + const requestBody = JSON.stringify({ + prompt: question, + supportFunctions: ['autoInternetSearch'], + }) + + expect( + isCurrentYuanbaoChatSSE( + 'https://yuanbao.tencent.com/api/chat/conversation-1', + 'POST', + 'text/event-stream', + requestBody, + question, + ), + ).toBe(true) + expect( + isCurrentYuanbaoChatSSE( + 'https://yuanbao.tencent.com/api/user/agent/conversation/promptSug', + 'POST', + 'application/json', + requestBody, + question, + ), + ).toBe(false) + expect( + isCurrentYuanbaoChatSSE( + 'https://yuanbao.tencent.com/api/chat/conversation-1', + 'POST', + 'text/event-stream', + JSON.stringify({ prompt: '另一个问题' }), + question, + ), + ).toBe(false) + }) + + it('builds Markdown only from top-level SSE text frames, excluding deep-search process text', () => { + const summary = parseYuanbaoCaptures([ + { + url: 'https://yuanbao.tencent.com/api/chat/conversation-1', + status: 200, + contentType: 'text/event-stream', + body: [ + 'data: {"type":"deepSearch","contents":[{"type":"text","msg":"搜索过程不应进入最终回答"}]}', + 'data: {"type":"text","msg":"### 核心结论\\n\\n"}', + 'data: {"type":"text","msg":"正文。\\n\\n- 条目 A\\n- 条目 B"}', + 'data: {"type":"meta","endConv":true,"traceId":"trace-1"}', + ].join('\n\n'), + }, + ]) + + expect(summary.answer).toBe('### 核心结论\n\n正文。\n\n- 条目 A\n- 条目 B') + expect(summary.providerRequestID).toBe('trace-1') + }) + it('resolves citation markers against the best available source pool', () => { const sources = [ { url: 'https://example.com/1', normalized_url: 'https://example.com/1' }, diff --git a/apps/desktop-client/src/main/adapters/yuanbao.ts b/apps/desktop-client/src/main/adapters/yuanbao.ts index e751d5b..1169848 100644 --- a/apps/desktop-client/src/main/adapters/yuanbao.ts +++ b/apps/desktop-client/src/main/adapters/yuanbao.ts @@ -350,21 +350,22 @@ function sanitizeAnswerCandidate(value: string | null): string | null { const repaired = repairPotentialMojibake(normalized) ?? normalized const collapsed = collapseTokenFragmentRuns(repaired) - const lines = collapsed - .split(/\r?\n+/) - .map((line) => line.trim()) - .filter(Boolean) + const lines = collapsed.split(/\r?\n/) if (lines.length <= 1) { return collapsed } - const keptLines = lines.filter((line) => !looksLikeNonAnswerNoise(line)) - if (!keptLines.length) { + const keptLines = lines.filter((line) => !line.trim() || !looksLikeNonAnswerNoise(line.trim())) + if (!keptLines.some((line) => line.trim())) { return collapsed } - const cleaned = keptLines.join('\n') - return answerQualityScore(cleaned) >= answerQualityScore(collapsed) ? cleaned : collapsed + return ( + keptLines + .join('\n') + .replace(/\n{3,}/g, '\n\n') + .trim() || collapsed + ) } function selectBestAnswerText( @@ -379,12 +380,7 @@ function selectBestAnswerText( if (!dom) { return parsed } - - if (parsed.includes(dom) && answerQualityScore(dom) >= answerQualityScore(parsed) - 24) { - return dom - } - - return answerQualityScore(dom) > answerQualityScore(parsed) ? dom : parsed + return parsed } function resolveCitationsFromMarkers( @@ -469,6 +465,16 @@ function getDirectString(record: Record, keys: string[]): strin return null } +function getDirectTextFragment(record: Record, keys: string[]): string | null { + for (const key of keys) { + const value = record[key] + if (typeof value === 'string' && value.length > 0) { + return value + } + } + return null +} + function normalizeSourceUrl(value: string | null): string | null { const input = normalizeText(value) if (!input) { @@ -844,7 +850,52 @@ function safePageURL(page: PlaywrightPage): string { } } -function attachYuanbaoResponseCapture(page: PlaywrightPage): { +function isCurrentYuanbaoChatSSE( + url: string, + method: string, + contentType: string | null, + requestBody: string | null, + questionText: string, +): boolean { + if (method.toUpperCase() !== 'POST' || !contentType || !/text\/event-stream/i.test(contentType)) { + return false + } + + try { + const parsedURL = new URL(url) + if ( + parsedURL.hostname !== 'yuanbao.tencent.com' || + !/^\/api\/chat\/[^/]+\/?$/.test(parsedURL.pathname) + ) { + return false + } + } catch { + return false + } + + if (!requestBody) { + return false + } + + try { + const payload = JSON.parse(requestBody) as unknown + if (!isRecord(payload)) { + return false + } + const expected = normalizeText(questionText) + const candidates = [payload.prompt, payload.displayPrompt] + .map((value) => normalizeText(value)) + .filter((value): value is string => value !== null) + return Boolean(expected && candidates.some((candidate) => candidate === expected)) + } catch { + return false + } +} + +function attachYuanbaoResponseCapture( + page: PlaywrightPage, + questionText: string, +): { captures: YuanbaoCaptureRecord[] flush: (timeoutMs: number) => Promise pendingCount: () => number @@ -868,17 +919,16 @@ function attachYuanbaoResponseCapture(page: PlaywrightPage): { const responseHandler = (response: PlaywrightResponse) => { const url = normalizeOptionalString(response.url()) - if (!url || !url.startsWith('https://yuanbao.tencent.com/')) { + if (!url) { return } const headers = response.headers() const contentType = normalizeOptionalString(headers['content-type']) ?? null - const shouldRead = - url.includes('/api/') || - response.status() >= 400 || - (contentType !== null && /(json|event-stream|text\/plain)/i.test(contentType)) - if (!shouldRead) { + const request = response.request() + if ( + !isCurrentYuanbaoChatSSE(url, request.method(), contentType, request.postData(), questionText) + ) { return } @@ -1011,14 +1061,19 @@ function resolveYuanbaoSourceFieldBucket(key: string): 'citation' | 'search' | n return null } -function walkYuanbaoPayload(value: unknown, summary: YuanbaoSummary, depth = 0): void { +function walkYuanbaoPayload( + value: unknown, + summary: YuanbaoSummary, + depth = 0, + allowAnswer = false, +): void { if (depth > 12 || value == null) { return } if (Array.isArray(value)) { for (const item of value) { - walkYuanbaoPayload(item, summary, depth + 1) + walkYuanbaoPayload(item, summary, depth + 1, false) } return } @@ -1045,10 +1100,12 @@ function walkYuanbaoPayload(value: unknown, summary: YuanbaoSummary, depth = 0): summary.contentTypes.add(type) switch (type) { case 'text': - summary.answer = mergeText( - summary.answer, - getDirectString(value, ['msg', 'text', 'content']), - ) + if (allowAnswer) { + summary.answer = mergeText( + summary.answer, + getDirectTextFragment(value, ['msg', 'text', 'content']), + ) + } break case 'think': summary.reasoning = mergeText( @@ -1080,7 +1137,7 @@ function walkYuanbaoPayload(value: unknown, summary: YuanbaoSummary, depth = 0): collectSourceBucket(value.thinkDeepSections, summary.citations) break case 'deepSearch': - walkYuanbaoPayload(value.contents, summary, depth + 1) + walkYuanbaoPayload(value.contents, summary, depth + 1, false) break case 'step': collectSourceBucket(value.thinkDeepSections, summary.citations) @@ -1099,36 +1156,36 @@ function walkYuanbaoPayload(value: unknown, summary: YuanbaoSummary, depth = 0): } if (Array.isArray(value.content)) { - walkYuanbaoPayload(value.content, summary, depth + 1) + walkYuanbaoPayload(value.content, summary, depth + 1, false) } if (Array.isArray(value.contents)) { - walkYuanbaoPayload(value.contents, summary, depth + 1) + walkYuanbaoPayload(value.contents, summary, depth + 1, false) } if (Array.isArray(value.speechesV2)) { - walkYuanbaoPayload(value.speechesV2, summary, depth + 1) + walkYuanbaoPayload(value.speechesV2, summary, depth + 1, false) } if (Array.isArray(value.convs)) { - walkYuanbaoPayload(value.convs, summary, depth + 1) + walkYuanbaoPayload(value.convs, summary, depth + 1, false) } if (Array.isArray(value.docs)) { - walkYuanbaoPayload(value.docs, summary, depth + 1) + walkYuanbaoPayload(value.docs, summary, depth + 1, false) } if (Array.isArray(value.citations)) { - walkYuanbaoPayload(value.citations, summary, depth + 1) + walkYuanbaoPayload(value.citations, summary, depth + 1, false) } if (Array.isArray(value.ref_docs)) { - walkYuanbaoPayload(value.ref_docs, summary, depth + 1) + walkYuanbaoPayload(value.ref_docs, summary, depth + 1, false) } if (Array.isArray(value.thinkDeepSections)) { - walkYuanbaoPayload(value.thinkDeepSections, summary, depth + 1) + walkYuanbaoPayload(value.thinkDeepSections, summary, depth + 1, false) } if (Array.isArray(value.list)) { - walkYuanbaoPayload(value.list, summary, depth + 1) + walkYuanbaoPayload(value.list, summary, depth + 1, false) } for (const nested of Object.values(value)) { if (Array.isArray(nested) || isRecord(nested)) { - walkYuanbaoPayload(nested, summary, depth + 1) + walkYuanbaoPayload(nested, summary, depth + 1, false) } } } @@ -1155,7 +1212,7 @@ function parseYuanbaoCaptures(captures: YuanbaoCaptureRecord[]): YuanbaoSummary const candidates = extractJSONCandidates(capture.body) summary.candidateCount += candidates.length for (const candidate of candidates) { - walkYuanbaoPayload(candidate, summary) + walkYuanbaoPayload(candidate, summary, 0, true) } } @@ -1551,7 +1608,7 @@ const yuanbaoQueryInPage = async (parameters: { for (const scope of scopes) { const nodes = Array.from( scope.querySelectorAll( - "button, [role='button'], label, [tabindex], [dt-button-id], [data-button-id]", + "button, [role='button'], [role='menuitem'], [role='option'], li.t-dropdown__item, label, [tabindex], [dt-button-id], [data-button-id]", ), ) for (const node of nodes) { @@ -1585,7 +1642,29 @@ const yuanbaoQueryInPage = async (parameters: { await wait(delayMs) } + const hasActiveComposerMode = (pattern: RegExp, shell: HTMLElement | null): boolean => { + const scopes: ParentNode[] = shell ? [shell, document] : [document] + const seen = new Set() + for (const scope of scopes) { + const atoms = Array.from(scope.querySelectorAll('.application-blot-ai-atom')) + for (const atom of atoms) { + if (seen.has(atom) || !isVisible(atom)) { + continue + } + seen.add(atom) + const text = normalize(atom.textContent) + if (text && pattern.test(text)) { + return true + } + } + } + return false + } + const readToggleStateByPattern = (pattern: RegExp, shell: HTMLElement | null): boolean | null => { + if (hasActiveComposerMode(pattern, shell)) { + return true + } const target = findInteractiveByText(pattern, shell) return target ? toggleState(target) : null } @@ -1611,29 +1690,23 @@ const yuanbaoQueryInPage = async (parameters: { } const ensureWebSearchEnabled = async (shell: HTMLElement | null): Promise => { - const directState = await ensureToggleEnabledByPattern(webSearchPattern, shell) - if (directState === true) { + if (hasActiveComposerMode(webSearchPattern, shell)) { return true } const toolsButton = findInteractiveByText(toolsPattern, shell) if (!toolsButton) { - return directState + return null } await clickInteractive(toolsButton, 220) const menuItem = findInteractiveByText(webSearchPattern, null) if (!menuItem) { - return directState + return false } - let menuState = toggleState(menuItem) - if (menuState !== true) { - await clickInteractive(menuItem, 220) - menuState = toggleState(menuItem) - } - - return menuState ?? true + await clickInteractive(menuItem, 300) + return hasActiveComposerMode(webSearchPattern, shell) } const setComposerText = (composer: HTMLElement, text: string) => { @@ -2265,6 +2338,9 @@ const yuanbaoQueryInPage = async (parameters: { if (enableWebSearch) { lastKnownWebSearchEnabled = await ensureWebSearchEnabled(composerShell) + if (lastKnownWebSearchEnabled !== true) { + return fail('yuanbao_web_search_enable_failed', bodyText(), composerTop) + } } setComposerText(composer, questionText) @@ -2349,7 +2425,7 @@ export const yuanbaoAdapter: MonitorAdapter = { context.reportProgress('yuanbao.page_ready') await ensurePageOnYuanbaoChat(page, context.signal) - const capture = attachYuanbaoResponseCapture(page) + const capture = attachYuanbaoResponseCapture(page, questionText) let pageResult: YuanbaoPageQueryResult try { @@ -2370,15 +2446,13 @@ export const yuanbaoAdapter: MonitorAdapter = { const parsed = parseYuanbaoCaptures(capture.captures) const pendingCaptureCount = capture.pendingCount() const domCitations = buildOrderedDomSourceItems(pageResult.domLinks) - const searchResults = dedupeSourceItems(parsed.searchResults) - let answerWithMarkers = selectBestAnswerText(parsed.answer, pageResult.domAnswer) - if ( - pageResult.inlineCitationIndexes.length > 0 && - extractCitationMarkerIndexes(answerWithMarkers).length === 0 && - extractCitationMarkerIndexes(pageResult.domAnswer).length > 0 - ) { - answerWithMarkers = pageResult.domAnswer - } + const searchResults = parsed.searchResults.length + ? dedupeSourceItems(parsed.searchResults) + : dedupeSourceItems(domCitations) + const apiAnswer = sanitizeAnswerCandidate(parsed.answer) + const domAnswer = sanitizeAnswerCandidate(pageResult.domAnswer) + const answerWithMarkers = selectBestAnswerText(apiAnswer, domAnswer) + const responseSource = apiAnswer ? 'api_sse' : domAnswer ? 'page_rpa' : null const citationMarkerIndexes = extractCitationMarkerIndexes( answerWithMarkers ?? pageResult.domAnswer, ) @@ -2387,20 +2461,22 @@ export const yuanbaoAdapter: MonitorAdapter = { : 0 const markerCitations = dedupeSourceItems( resolveCitationsFromMarkers(answerWithMarkers ?? pageResult.domAnswer, [ - domCitations, parsed.citations, parsed.searchResults, - dedupeSourceItems([...domCitations, ...parsed.citations, ...parsed.searchResults]), + domCitations, + dedupeSourceItems([...parsed.citations, ...parsed.searchResults, ...domCitations]), ]), ) const answer = normalizeCitationMarkers(answerWithMarkers) const citations = - domCitations.length >= maxCitationMarkerIndex - ? appendUniqueSourceItems(domCitations, [...parsed.citations, ...markerCitations]) - : appendUniqueSourceItems( - dedupeSourceItems([...parsed.citations, ...markerCitations]), - domCitations, - ) + parsed.citations.length >= maxCitationMarkerIndex + ? appendUniqueSourceItems(parsed.citations, [...markerCitations, ...domCitations]) + : domCitations.length >= maxCitationMarkerIndex + ? appendUniqueSourceItems(domCitations, [...parsed.citations, ...markerCitations]) + : appendUniqueSourceItems( + dedupeSourceItems([...parsed.citations, ...markerCitations]), + domCitations, + ) const inlineCitationCandidates = dedupeSourceItems( citationMarkerIndexes .map((index) => citations[index - 1] ?? null) @@ -2482,6 +2558,7 @@ export const yuanbaoAdapter: MonitorAdapter = { request_id: requestID, conversation_id: conversationID, answer, + response_source: responseSource, reasoning, citation_count: citations.length, search_result_count: searchResults.length, @@ -2494,6 +2571,7 @@ export const yuanbaoAdapter: MonitorAdapter = { model_label: pageResult.modelLabel, deep_think_enabled: pageResult.deepThinkEnabled, web_search_enabled: pageResult.webSearchEnabled, + response_source: responseSource, candidate_count: parsed.candidateCount, capture_count: capture.captures.length, capture_pending_after_flush: pendingCaptureCount, @@ -2518,6 +2596,7 @@ export const __yuanbaoTestUtils = { extractCitationMarkerIndexes, extractQuestionText, findUnresolvedCitationIndexes, + isCurrentYuanbaoChatSSE, mergeText, normalizeCitationMarkers, normalizeSourceUrl,