Files
geo/apps/desktop-client/src/main/adapters/yuanbao.ts
T
root 70b9f75aea 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) <noreply@anthropic.com>
2026-07-12 14:17:14 +08:00

2607 lines
76 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { JsonValue, MonitoringSourceItem } from '@geo/shared-types'
import type { Page as PlaywrightPage, Response as PlaywrightResponse } from 'playwright-core'
import type { MonitorAdapter } from './base'
import { normalizeText } from './common'
const YUANBAO_BOOTSTRAP_URL = 'https://yuanbao.tencent.com/'
const YUANBAO_PAGE_READY_TIMEOUT_MS = 20_000
const YUANBAO_QUERY_TIMEOUT_MS = 120_000
const YUANBAO_CAPTURE_LIMIT = 96
const YUANBAO_CAPTURE_BODY_LIMIT = 1_200_000
const YUANBAO_CAPTURE_FLUSH_TIMEOUT_MS = 20_000
// Match both formats so we tolerate either side of the migration without breaking:
// - "[citation:N]" — emitted by the legacy yuanbao SSE protocol and what older
// captures / fixtures still contain.
// - "[N]" — emitted by the new model (DOM places <div data-idx-list="N,M,..."/>
// placeholders inline; we now lower them to bare "[N]" markers, which is also
// the format DeepSeek answers carry through to the SaaS side).
const YUANBAO_CITATION_MARKER_PATTERN = /\[(?:citation:\s*)?(\d+)\]/gi
const YUANBAO_LEGACY_CITATION_MARKER_PATTERN = /\[citation:\s*(\d+)\]/gi
const WINDOWS_1252_REVERSE_MAP = new Map<number, number>([
[0x20ac, 0x80],
[0x201a, 0x82],
[0x0192, 0x83],
[0x201e, 0x84],
[0x2026, 0x85],
[0x2020, 0x86],
[0x2021, 0x87],
[0x02c6, 0x88],
[0x2030, 0x89],
[0x0160, 0x8a],
[0x2039, 0x8b],
[0x0152, 0x8c],
[0x017d, 0x8e],
[0x2018, 0x91],
[0x2019, 0x92],
[0x201c, 0x93],
[0x201d, 0x94],
[0x2022, 0x95],
[0x2013, 0x96],
[0x2014, 0x97],
[0x02dc, 0x98],
[0x2122, 0x99],
[0x0161, 0x9a],
[0x203a, 0x9b],
[0x0153, 0x9c],
[0x017e, 0x9e],
[0x0178, 0x9f],
])
type YuanbaoCaptureRecord = {
url: string
status: number
contentType: string | null
body: string
}
type YuanbaoDomLink = {
url: string
title: string | null
text: string | null
siteName: string | null
sourceIndex: number | null
}
type YuanbaoPageQuerySuccessResult = {
ok: true
url: string
title: string | null
modelLabel: string | null
deepThinkEnabled: boolean | null
webSearchEnabled: boolean | null
domAnswer: string | null
domLinks: YuanbaoDomLink[]
inlineCitationIndexes: number[]
}
type YuanbaoPageQueryFailureResult = {
ok: false
error: string
detail: string | null
url: string | null
title: string | null
modelLabel: string | null
deepThinkEnabled: boolean | null
webSearchEnabled: boolean | null
domAnswer: string | null
domLinks: YuanbaoDomLink[]
inlineCitationIndexes: number[]
}
type YuanbaoPageQueryResult = YuanbaoPageQuerySuccessResult | YuanbaoPageQueryFailureResult
type YuanbaoSummary = {
answer: string | null
reasoning: string | null
providerModel: string | null
providerRequestID: string | null
requestID: string | null
conversationID: string | null
citations: MonitoringSourceItem[]
searchResults: MonitoringSourceItem[]
candidateCount: number
contentTypes: Set<string>
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function normalizeOptionalString(value: unknown): string | null {
if (typeof value !== 'string') {
return null
}
const trimmed = value.trim()
return trimmed ? trimmed : null
}
function countHanCharacters(value: string): number {
const matched = value.match(/[\u3400-\u9fff]/g)
return matched?.length ?? 0
}
function countSuspiciousMojibakeCharacters(value: string): number {
const matched = value.match(/[\u00a0-\u017f\u02c0-\u02ff\u2000-\u20ff]/g)
return matched?.length ?? 0
}
function looksLikeMojibake(value: string): boolean {
const normalized = value.trim()
if (!normalized) {
return false
}
if (
normalized.includes('Ã') ||
normalized.includes('â€') ||
normalized.includes('ï¼') ||
normalized.includes('ã€')
) {
return true
}
const hanCount = countHanCharacters(normalized)
const suspiciousCount = countSuspiciousMojibakeCharacters(normalized)
if (hanCount > 0) {
return suspiciousCount >= 8 && suspiciousCount > hanCount * 3
}
return suspiciousCount >= 4
}
function repairPotentialMojibake(value: string | null): string | null {
const normalized = normalizeOptionalString(value)
if (!normalized || !looksLikeMojibake(normalized)) {
return value
}
try {
const bytes = Uint8Array.from(
Array.from(normalized, (char) => {
const codePoint = char.codePointAt(0) ?? 0
const mapped = WINDOWS_1252_REVERSE_MAP.get(codePoint)
if (mapped !== undefined) {
return mapped
}
if (codePoint <= 0xff) {
return codePoint
}
throw new Error('non_windows_1252_codepoint')
}),
)
const repaired = new TextDecoder('utf-8').decode(bytes)
const originalHanCount = countHanCharacters(normalized)
const repairedHanCount = countHanCharacters(repaired)
const originalSuspiciousCount = countSuspiciousMojibakeCharacters(normalized)
const repairedSuspiciousCount = countSuspiciousMojibakeCharacters(repaired)
if (
repairedHanCount > originalHanCount ||
(repairedHanCount > 0 && repairedSuspiciousCount * 2 < originalSuspiciousCount)
) {
return repaired
}
} catch {
return value
}
return value
}
function decodeResponseBody(buffer: Buffer): string {
const decoded = new TextDecoder('utf-8').decode(buffer)
return repairPotentialMojibake(decoded) ?? decoded
}
function extractCitationMarkerIndexes(value: string | null): number[] {
const normalized = normalizeOptionalString(value)
if (!normalized) {
return []
}
const indexes: number[] = []
const seen = new Set<number>()
for (const matched of normalized.matchAll(YUANBAO_CITATION_MARKER_PATTERN)) {
const index = Number.parseInt(matched[1] ?? '', 10)
if (!Number.isFinite(index) || index < 1 || seen.has(index)) {
continue
}
seen.add(index)
indexes.push(index)
}
return indexes
}
// Normalise legacy "[citation:N]" markers (still emitted by some SSE frames)
// to "[N]" so the SaaS front-end can render the same superscript chip as
// DeepSeek answers — without that, mixed sources show two unrelated formats.
function normalizeCitationMarkers(value: string | null): string | null {
if (value == null) {
return value
}
return value.replace(YUANBAO_LEGACY_CITATION_MARKER_PATTERN, '[$1]')
}
function looksLikeNonAnswerNoise(value: string): boolean {
const trimmed = value.trim()
if (!trimmed) {
return true
}
const hanCount = countHanCharacters(trimmed)
const suspiciousCount = countSuspiciousMojibakeCharacters(trimmed)
if (hanCount === 0) {
if (suspiciousCount >= 4) {
return true
}
const codePointLength = [...trimmed].length
return suspiciousCount >= 2 && suspiciousCount * 2 >= codePointLength
}
return suspiciousCount >= 8 && suspiciousCount > hanCount * 3
}
function answerQualityScore(value: string | null): number {
const normalized = normalizeOptionalString(value)
if (!normalized) {
return Number.NEGATIVE_INFINITY
}
const hanCount = countHanCharacters(normalized)
const suspiciousCount = countSuspiciousMojibakeCharacters(normalized)
return (
hanCount * 8 -
suspiciousCount * 6 +
Math.min(normalized.length, 400) +
extractCitationMarkerIndexes(normalized).length * 12
)
}
const STREAMING_FRAGMENT_MAX_LENGTH = 6
const STREAMING_FRAGMENT_RUN_THRESHOLD = 10
const STREAMING_FALLBACK_MIN_CONTENT_CHARS = 120
const STREAMING_MARKDOWN_STRUCTURE_PATTERN =
/^(?:#{1,6}\s|[*\-+•]\s|\d+[.、]\s?|>\s?|\|\s?|```|---|===|\*\*)/
function isLikelyStreamingFragmentLine(line: string): boolean {
const trimmed = line.trim()
if (!trimmed) {
return false
}
if ([...trimmed].length > STREAMING_FRAGMENT_MAX_LENGTH) {
return false
}
if (STREAMING_MARKDOWN_STRUCTURE_PATTERN.test(trimmed)) {
return false
}
if (/^[\-=_*]{2,}$/.test(trimmed)) {
return false
}
return true
}
function collapseTokenFragmentRuns(value: string): string {
if (!value.includes('\n')) {
return value
}
const rawLines = value.split('\n')
type Segment = { type: 'content' | 'fragments'; lines: string[] }
const segments: Segment[] = []
let fragmentBuffer: string[] = []
const pushContent = (line: string) => {
const last = segments[segments.length - 1]
if (last && last.type === 'content') {
last.lines.push(line)
} else {
segments.push({ type: 'content', lines: [line] })
}
}
const flushFragments = () => {
if (!fragmentBuffer.length) {
return
}
if (fragmentBuffer.length >= STREAMING_FRAGMENT_RUN_THRESHOLD) {
segments.push({ type: 'fragments', lines: fragmentBuffer })
} else {
for (const buffered of fragmentBuffer) {
pushContent(buffered)
}
}
fragmentBuffer = []
}
for (const line of rawLines) {
if (isLikelyStreamingFragmentLine(line)) {
fragmentBuffer.push(line)
} else {
flushFragments()
pushContent(line)
}
}
flushFragments()
const contentCharCount = segments
.filter((segment) => segment.type === 'content')
.reduce(
(total, segment) => total + segment.lines.reduce((acc, line) => acc + line.trim().length, 0),
0,
)
const keepFragmentsAsFallback = contentCharCount < STREAMING_FALLBACK_MIN_CONTENT_CHARS
const output: string[] = []
for (const segment of segments) {
if (segment.type === 'content') {
output.push(...segment.lines)
} else if (keepFragmentsAsFallback) {
output.push(segment.lines.map((line) => line.trim()).join(''))
}
}
return output.join('\n')
}
function sanitizeAnswerCandidate(value: string | null): string | null {
const normalized = normalizeOptionalString(value)
if (!normalized) {
return null
}
const repaired = repairPotentialMojibake(normalized) ?? normalized
const collapsed = collapseTokenFragmentRuns(repaired)
const lines = collapsed.split(/\r?\n/)
if (lines.length <= 1) {
return collapsed
}
const keptLines = lines.filter((line) => !line.trim() || !looksLikeNonAnswerNoise(line.trim()))
if (!keptLines.some((line) => line.trim())) {
return collapsed
}
return (
keptLines
.join('\n')
.replace(/\n{3,}/g, '\n\n')
.trim() || collapsed
)
}
function selectBestAnswerText(
parsedAnswer: string | null,
domAnswer: string | null,
): string | null {
const parsed = sanitizeAnswerCandidate(parsedAnswer)
const dom = sanitizeAnswerCandidate(domAnswer)
if (!parsed) {
return dom
}
if (!dom) {
return parsed
}
return parsed
}
function resolveCitationsFromMarkers(
answerText: string | null,
pools: MonitoringSourceItem[][],
): MonitoringSourceItem[] {
const indexes = extractCitationMarkerIndexes(answerText)
if (!indexes.length) {
return []
}
const maxIndex = Math.max(...indexes)
const candidates = pools.filter((pool) => pool.length > 0)
const resolvedPool = candidates.find((pool) => pool.length >= maxIndex) ?? candidates[0] ?? []
return indexes
.map((index) => resolvedPool[index - 1] ?? null)
.filter((item): item is MonitoringSourceItem => item !== null)
}
function sharedBoundaryLength(left: string, right: string): number {
const maxLength = Math.min(left.length, right.length)
for (let length = maxLength; length >= 8; length -= 1) {
if (left.slice(left.length - length) === right.slice(0, length)) {
return length
}
}
return 0
}
function sharedPrefixLength(left: string, right: string): number {
const maxLength = Math.min(left.length, right.length)
let length = 0
while (length < maxLength && left[length] === right[length]) {
length += 1
}
return length
}
function mergeText(current: string | null, nextFragment: string | null): string | null {
if (!nextFragment) {
return current
}
if (!current) {
return nextFragment
}
if (current === nextFragment || current.includes(nextFragment)) {
return current
}
if (nextFragment.startsWith(current)) {
return nextFragment
}
const forwardOverlap = sharedBoundaryLength(current, nextFragment)
if (forwardOverlap > 0) {
return `${current}${nextFragment.slice(forwardOverlap)}`
}
const reverseOverlap = sharedBoundaryLength(nextFragment, current)
if (reverseOverlap > 0) {
return `${nextFragment}${current.slice(reverseOverlap)}`
}
const commonPrefixLength = sharedPrefixLength(current, nextFragment)
const duplicatePrefixThreshold = Math.max(
16,
Math.min(80, Math.floor(Math.min(current.length, nextFragment.length) * 0.45)),
)
if (commonPrefixLength >= duplicatePrefixThreshold) {
return answerQualityScore(nextFragment) > answerQualityScore(current) ? nextFragment : current
}
return `${current}${nextFragment}`
}
function getDirectString(record: Record<string, unknown>, keys: string[]): string | null {
for (const key of keys) {
const value = normalizeOptionalString(record[key])
if (value) {
return value
}
}
return null
}
function getDirectTextFragment(record: Record<string, unknown>, 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) {
return null
}
try {
const url = new URL(input)
for (const key of [
'url',
'u',
'target',
'target_url',
'targetUrl',
'redirect',
'redirect_url',
'redirectUrl',
'jump_url',
'jumpUrl',
'link',
]) {
const nested = normalizeText(url.searchParams.get(key))
if (!nested || nested === input || !/^https?:\/\//i.test(nested)) {
continue
}
const unwrapped = normalizeSourceUrl(nested)
if (unwrapped) {
return unwrapped
}
}
url.hash = ''
return url.toString()
} catch {
return input
}
}
function isYuanbaoInternalUrl(value: string): boolean {
try {
const host = new URL(value).hostname.toLowerCase()
return host === 'yuanbao.tencent.com' || host.endsWith('.yuanbao.tencent.com')
} catch {
return false
}
}
function buildSourceItem(input: unknown): MonitoringSourceItem | null {
if (typeof input === 'string') {
const url = normalizeSourceUrl(input)
return url ? { url, normalized_url: url } : null
}
if (!isRecord(input)) {
return null
}
const url = normalizeSourceUrl(
getDirectString(input, [
'url',
'link',
'href',
'uri',
'source_url',
'sourceUrl',
'page_url',
'pageUrl',
'jump_url',
'jumpUrl',
'target_url',
'targetUrl',
'target',
'doc_url',
'docUrl',
'document_url',
'documentUrl',
'article_url',
'articleUrl',
'web_url',
'webUrl',
'webpage_url',
'webpageUrl',
'url_pc',
'urlPc',
'pc_url',
'pcUrl',
'mobile_url',
'mobileUrl',
'link_url',
'linkUrl',
'ori_url',
'oriUrl',
'original_url',
'originalUrl',
]),
)
if (!url || isYuanbaoInternalUrl(url)) {
return null
}
let host: string | null = null
try {
host = new URL(url).hostname || null
} catch {
host = null
}
return {
url,
title: repairPotentialMojibake(
getDirectString(input, ['title', 'name', 'page_title', 'display_title', 'text']),
),
site_name: repairPotentialMojibake(
getDirectString(input, [
'site_name',
'siteName',
'source',
'platform_name',
'site',
'domain',
]),
),
site_key: getDirectString(input, ['site_key', 'siteKey', 'domain_key']),
normalized_url: url,
host,
resolution_status: repairPotentialMojibake(
getDirectString(input, ['resolution_status', 'resolutionStatus']),
),
resolution_confidence: repairPotentialMojibake(
getDirectString(input, ['resolution_confidence', 'resolutionConfidence']),
),
}
}
function dedupeSourceItems(items: MonitoringSourceItem[]): MonitoringSourceItem[] {
const keyed = new Map<string, MonitoringSourceItem>()
for (const item of items) {
const key = normalizeSourceUrl(item.normalized_url ?? item.url)
if (!key) {
continue
}
const existing = keyed.get(key)
if (!existing) {
keyed.set(key, {
...item,
normalized_url: key,
})
continue
}
keyed.set(key, {
...existing,
title: existing.title ?? item.title ?? null,
site_name: existing.site_name ?? item.site_name ?? null,
site_key: existing.site_key ?? item.site_key ?? null,
host: existing.host ?? item.host ?? null,
resolution_status: existing.resolution_status ?? item.resolution_status ?? null,
resolution_confidence: existing.resolution_confidence ?? item.resolution_confidence ?? null,
})
}
return Array.from(keyed.values())
}
function appendUniqueSourceItems(
primaryItems: MonitoringSourceItem[],
extraItems: MonitoringSourceItem[],
): MonitoringSourceItem[] {
const output = [...primaryItems]
const seen = new Set<string>()
for (const item of primaryItems) {
const key = normalizeSourceUrl(item.normalized_url ?? item.url)
if (key) {
seen.add(key)
}
}
for (const item of extraItems) {
const key = normalizeSourceUrl(item.normalized_url ?? item.url)
if (!key || seen.has(key)) {
continue
}
seen.add(key)
output.push({
...item,
normalized_url: key,
})
}
return output
}
function buildOrderedDomSourceItems(links: YuanbaoDomLink[]): MonitoringSourceItem[] {
const indexedItems = new Map<number, MonitoringSourceItem>()
const fallbackItems: MonitoringSourceItem[] = []
for (const link of links) {
const sourceItem = buildSourceItem(link)
if (!sourceItem) {
continue
}
const sourceIndex = link.sourceIndex
if (sourceIndex !== null && Number.isFinite(sourceIndex) && sourceIndex > 0) {
if (!indexedItems.has(sourceIndex)) {
indexedItems.set(sourceIndex, sourceItem)
}
continue
}
fallbackItems.push(sourceItem)
}
if (!indexedItems.size) {
return dedupeSourceItems(fallbackItems)
}
const maxIndex = Math.max(...indexedItems.keys())
const hasContiguousIndexes = Array.from({ length: maxIndex }, (_, index) => index + 1).every(
(index) => indexedItems.has(index),
)
if (!hasContiguousIndexes) {
return dedupeSourceItems([
...Array.from(indexedItems.entries())
.sort(([left], [right]) => left - right)
.map(([, item]) => item),
...fallbackItems,
])
}
return Array.from({ length: maxIndex }, (_, index) => indexedItems.get(index + 1)).filter(
(item): item is MonitoringSourceItem => item !== undefined,
)
}
function findUnresolvedCitationIndexes(
answerText: string | null,
citations: MonitoringSourceItem[],
): number[] {
const unresolved: number[] = []
const seen = new Set<number>()
for (const index of extractCitationMarkerIndexes(answerText)) {
if (seen.has(index)) {
continue
}
seen.add(index)
if (!citations[index - 1]) {
unresolved.push(index)
}
}
return unresolved
}
function toJsonSources(items: MonitoringSourceItem[]): JsonValue[] {
return items.map((item) => ({
url: item.url,
title: item.title ?? null,
site_name: item.site_name ?? null,
site_key: item.site_key ?? null,
normalized_url: item.normalized_url ?? null,
host: item.host ?? null,
registrable_domain: item.registrable_domain ?? null,
subdomain: item.subdomain ?? null,
suffix: item.suffix ?? null,
article_id: item.article_id ?? null,
publish_record_id: item.publish_record_id ?? null,
resolution_status: item.resolution_status ?? null,
resolution_confidence: item.resolution_confidence ?? null,
}))
}
function buildAdapterError(
code: string,
message: string,
extras: Record<string, JsonValue> = {},
): Record<string, JsonValue> {
return {
code,
message,
...extras,
}
}
function looksLikeRuntimeDiagnosticQuestion(value: string): boolean {
return (
/^The resource https?:\/\/localhost:[^\s]+ was preloaded using link preload/i.test(value) ||
/^failed to solve:/i.test(value) ||
/did not complete successfully:\s*exit code:\s*\d+/i.test(value)
)
}
function extractQuestionText(payload: Record<string, unknown>): string {
const candidates = [
payload.question_text,
payload.questionText,
payload.query,
payload.question,
payload.prompt,
payload.content,
]
let rejectedDiagnostic = false
for (const candidate of candidates) {
const text = normalizeOptionalString(candidate)
if (!text) {
continue
}
if (looksLikeRuntimeDiagnosticQuestion(text)) {
rejectedDiagnostic = true
continue
}
return text
}
if (rejectedDiagnostic) {
throw new Error('yuanbao_question_text_runtime_diagnostic')
}
throw new Error('yuanbao_question_text_missing')
}
async function sleep(ms: number, signal?: AbortSignal): Promise<void> {
await new Promise<void>((resolve, reject) => {
if (signal?.aborted) {
reject(new Error('adapter_aborted'))
return
}
const timer = setTimeout(() => {
signal?.removeEventListener('abort', onAbort)
resolve()
}, ms)
const onAbort = () => {
clearTimeout(timer)
reject(new Error('adapter_aborted'))
}
signal?.addEventListener('abort', onAbort, { once: true })
})
}
async function ensurePageOnYuanbaoChat(page: PlaywrightPage, signal: AbortSignal): Promise<void> {
if (signal.aborted) {
throw new Error('adapter_aborted')
}
const currentURL = safePageURL(page)
if (!currentURL.startsWith('https://yuanbao.tencent.com/')) {
await page.goto(YUANBAO_BOOTSTRAP_URL, {
waitUntil: 'domcontentloaded',
timeout: YUANBAO_PAGE_READY_TIMEOUT_MS,
})
}
await page
.waitForLoadState('domcontentloaded', {
timeout: YUANBAO_PAGE_READY_TIMEOUT_MS,
})
.catch(() => undefined)
await page.waitForLoadState('networkidle', { timeout: 3_000 }).catch(() => undefined)
if (signal.aborted) {
throw new Error('adapter_aborted')
}
}
function safePageURL(page: PlaywrightPage): string {
try {
return page.url()
} catch {
return ''
}
}
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<void>
pendingCount: () => number
dispose: () => void
} {
const captures: YuanbaoCaptureRecord[] = []
const pending = new Set<Promise<void>>()
const pushCapture = (record: YuanbaoCaptureRecord) => {
captures.push({
...record,
body:
record.body.length > YUANBAO_CAPTURE_BODY_LIMIT
? record.body.slice(0, YUANBAO_CAPTURE_BODY_LIMIT)
: record.body,
})
if (captures.length > YUANBAO_CAPTURE_LIMIT) {
captures.splice(0, captures.length - YUANBAO_CAPTURE_LIMIT)
}
}
const responseHandler = (response: PlaywrightResponse) => {
const url = normalizeOptionalString(response.url())
if (!url) {
return
}
const headers = response.headers()
const contentType = normalizeOptionalString(headers['content-type']) ?? null
const request = response.request()
if (
!isCurrentYuanbaoChatSSE(url, request.method(), contentType, request.postData(), questionText)
) {
return
}
const task = response
.body()
.then((body) => {
const normalizedBody = normalizeOptionalString(decodeResponseBody(body)) ?? ''
pushCapture({
url,
status: response.status(),
contentType,
body: normalizedBody,
})
})
.catch(() => undefined)
pending.add(task)
void task.finally(() => {
pending.delete(task)
})
}
page.on('response', responseHandler)
return {
captures,
async flush(timeoutMs: number) {
const deadline = Date.now() + timeoutMs
while (pending.size > 0 && Date.now() < deadline) {
const snapshot = Array.from(pending)
const remaining = Math.max(0, deadline - Date.now())
await Promise.race([
Promise.allSettled(snapshot),
new Promise<void>((resolve) => {
setTimeout(resolve, remaining)
}),
])
}
},
pendingCount() {
return pending.size
},
dispose() {
page.off('response', responseHandler)
},
}
}
function extractJSONCandidates(body: string): unknown[] {
const values: unknown[] = []
const seen = new Set<string>()
const pushParsed = (raw: string) => {
const normalized = raw.trim()
if (!normalized || seen.has(normalized)) {
return
}
try {
values.push(JSON.parse(normalized))
seen.add(normalized)
} catch {
// Ignore malformed frames and continue with the remaining candidates.
}
}
pushParsed(body)
for (const rawLine of body.split(/\r?\n/)) {
const line = rawLine.trim()
if (!line) {
continue
}
if (line.startsWith('data:')) {
const value = line.slice(5).trim()
if (value && value !== '[DONE]') {
pushParsed(value)
}
continue
}
if (line.startsWith('{') || line.startsWith('[')) {
pushParsed(line)
}
}
return values
}
function collectSourceBucket(input: unknown, bucket: MonitoringSourceItem[]): void {
if (input == null) {
return
}
if (Array.isArray(input)) {
for (const item of input) {
collectSourceBucket(item, bucket)
}
return
}
const sourceItem = buildSourceItem(input)
if (sourceItem) {
bucket.push(sourceItem)
}
if (!isRecord(input)) {
return
}
for (const value of Object.values(input)) {
if (Array.isArray(value) || isRecord(value)) {
collectSourceBucket(value, bucket)
}
}
}
function resolveYuanbaoSourceFieldBucket(key: string): 'citation' | 'search' | null {
if (
/^(?:citations?|citationInfo|citationList|ref_docs|refDocs|references?|referenceList|referenceDocs)$/i.test(
key,
)
) {
return 'citation'
}
if (
/^(?:docs?|docList|doc_list|sources?|sourceList|searchResults?|search_results|webResults?|web_results|webPages?|web_pages|pages?|pageList|thinkDeepSections)$/i.test(
key,
)
) {
return 'search'
}
return null
}
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, false)
}
return
}
if (!isRecord(value)) {
return
}
summary.providerModel =
summary.providerModel ??
getDirectString(value, ['chatModelId', 'chat_model_id', 'model', 'modelName', 'model_name'])
summary.providerRequestID =
summary.providerRequestID ??
getDirectString(value, ['traceID', 'traceId', 'request_id', 'requestId', 'msgId', 'msg_id'])
summary.requestID =
summary.requestID ??
getDirectString(value, ['request_id', 'requestId', 'traceID', 'traceId', 'msgId', 'msg_id'])
summary.conversationID =
summary.conversationID ??
getDirectString(value, ['conversationId', 'conversation_id', 'chatId', 'chat_id'])
const type = getDirectString(value, ['type'])
if (type) {
summary.contentTypes.add(type)
switch (type) {
case 'text':
if (allowAnswer) {
summary.answer = mergeText(
summary.answer,
getDirectTextFragment(value, ['msg', 'text', 'content']),
)
}
break
case 'think':
summary.reasoning = mergeText(
summary.reasoning,
getDirectString(value, ['content', 'msg', 'text']),
)
break
case 'searchGuid':
// The new hunyuan_t1 deep-search SSE places EVERY citable reference in
// `docs[]` (each entry carries a 1-based `index` that aligns with the
// answer's `[N]` markers), and the legacy `citations` field is now
// always `null`. Promote `docs` into both buckets — the citations
// bucket preserves array order so `citations[index - 1]` matches the
// marker, while the search-results bucket keeps the existing
// `dom_reference_links` semantics for downstream consumers.
collectSourceBucket(value.docs, summary.citations)
collectSourceBucket(value.docs, summary.searchResults)
collectSourceBucket(value.citations, summary.citations)
break
case 'toolCall':
collectSourceBucket(value.docs, summary.searchResults)
collectSourceBucket(value.ref_docs, summary.citations)
break
case 'thinkDeep':
summary.reasoning = mergeText(
summary.reasoning,
getDirectString(value, ['content', 'msg', 'text']),
)
collectSourceBucket(value.thinkDeepSections, summary.citations)
break
case 'deepSearch':
walkYuanbaoPayload(value.contents, summary, depth + 1, false)
break
case 'step':
collectSourceBucket(value.thinkDeepSections, summary.citations)
break
default:
break
}
}
for (const [key, nested] of Object.entries(value)) {
const bucket = resolveYuanbaoSourceFieldBucket(key)
if (!bucket) {
continue
}
collectSourceBucket(nested, bucket === 'citation' ? summary.citations : summary.searchResults)
}
if (Array.isArray(value.content)) {
walkYuanbaoPayload(value.content, summary, depth + 1, false)
}
if (Array.isArray(value.contents)) {
walkYuanbaoPayload(value.contents, summary, depth + 1, false)
}
if (Array.isArray(value.speechesV2)) {
walkYuanbaoPayload(value.speechesV2, summary, depth + 1, false)
}
if (Array.isArray(value.convs)) {
walkYuanbaoPayload(value.convs, summary, depth + 1, false)
}
if (Array.isArray(value.docs)) {
walkYuanbaoPayload(value.docs, summary, depth + 1, false)
}
if (Array.isArray(value.citations)) {
walkYuanbaoPayload(value.citations, summary, depth + 1, false)
}
if (Array.isArray(value.ref_docs)) {
walkYuanbaoPayload(value.ref_docs, summary, depth + 1, false)
}
if (Array.isArray(value.thinkDeepSections)) {
walkYuanbaoPayload(value.thinkDeepSections, summary, depth + 1, false)
}
if (Array.isArray(value.list)) {
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, false)
}
}
}
function parseYuanbaoCaptures(captures: YuanbaoCaptureRecord[]): YuanbaoSummary {
const summary: YuanbaoSummary = {
answer: null,
reasoning: null,
providerModel: null,
providerRequestID: null,
requestID: null,
conversationID: null,
citations: [],
searchResults: [],
candidateCount: 0,
contentTypes: new Set<string>(),
}
for (const capture of captures) {
if (!capture.body) {
continue
}
const candidates = extractJSONCandidates(capture.body)
summary.candidateCount += candidates.length
for (const candidate of candidates) {
walkYuanbaoPayload(candidate, summary, 0, true)
}
}
summary.citations = dedupeSourceItems(summary.citations)
summary.searchResults = dedupeSourceItems(summary.searchResults)
return summary
}
function extractConversationID(url: string | null): string | null {
const input = normalizeText(url)
if (!input) {
return null
}
try {
const parsed = new URL(input)
const matched = parsed.pathname.match(/\/chat\/([^/?#]+)/i)
return matched?.[1]?.trim() || null
} catch {
return null
}
}
function resolveProviderModel(pageResult: YuanbaoPageQueryResult, summary: YuanbaoSummary): string {
if (summary.providerModel) {
return summary.providerModel
}
if (pageResult.modelLabel) {
return pageResult.modelLabel
}
const title = normalizeText(pageResult.title)
const matched = title?.match(/体验\s*([^-]+?)(?:全新版|高效AI助手|AI助手)/)
const fromTitle = normalizeText(matched?.[1] ?? null)
if (fromTitle) {
return fromTitle
}
return pageResult.deepThinkEnabled ? 'yuanbao-web-deep-think' : 'yuanbao-web'
}
const yuanbaoQueryInPage = async (parameters: {
questionText: string
timeoutMs: number
readyTimeoutMs: number
enableDeepThink: boolean
enableWebSearch: boolean
}): Promise<YuanbaoPageQueryResult> => {
const { questionText, timeoutMs, readyTimeoutMs, enableDeepThink, enableWebSearch } = parameters
const wait = (timeMs: number) =>
new Promise<void>((resolve) => {
window.setTimeout(resolve, timeMs)
})
const normalize = (value: unknown): string | null => {
if (typeof value !== 'string') {
return null
}
const trimmed = value.trim().replace(/\s+/g, ' ')
return trimmed ? trimmed : null
}
const isVisible = (element: Element | null | undefined): element is HTMLElement => {
if (!(element instanceof HTMLElement)) {
return false
}
const style = window.getComputedStyle(element)
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') {
return false
}
const rect = element.getBoundingClientRect()
return rect.width > 0 && rect.height > 0
}
const isDisabled = (element: HTMLElement | null): boolean => {
if (!element) {
return false
}
if ('disabled' in element && typeof (element as HTMLButtonElement).disabled === 'boolean') {
return Boolean((element as HTMLButtonElement).disabled)
}
const ariaDisabled = element.getAttribute('aria-disabled')
return ariaDisabled === 'true'
}
const parseColor = (
value: string | null,
): { r: number; g: number; b: number; a: number } | null => {
if (!value) {
return null
}
const matched = value.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([0-9.]+))?\)/i)
if (!matched) {
return null
}
return {
r: Number(matched[1]),
g: Number(matched[2]),
b: Number(matched[3]),
a: matched[4] ? Number(matched[4]) : 1,
}
}
const isGreenish = (value: string | null): boolean => {
const color = parseColor(value)
if (!color) {
return false
}
return color.g >= color.r + 12 && color.g >= color.b + 12
}
const toggleState = (element: HTMLElement | null): boolean | null => {
if (!element) {
return null
}
const attrs = [
element.getAttribute('aria-pressed'),
element.getAttribute('aria-checked'),
element.getAttribute('data-state'),
element.getAttribute('data-selected'),
element.getAttribute('data-active'),
]
.map((value) => normalize(value)?.toLowerCase() ?? null)
.filter((value): value is string => value !== null)
if (attrs.some((value) => ['true', 'checked', 'selected', 'active', 'on'].includes(value))) {
return true
}
if (attrs.some((value) => ['false', 'unchecked', 'off'].includes(value))) {
return false
}
const className = normalize(element.className)?.toLowerCase() ?? ''
if (/(^|[\s_-])(active|selected|checked|current|on)([\s_-]|$)/.test(className)) {
return true
}
if (/(^|[\s_-])(inactive|unselected|unchecked|off)([\s_-]|$)/.test(className)) {
return false
}
const style = window.getComputedStyle(element)
if (
isGreenish(style.color) ||
isGreenish(style.borderColor) ||
isGreenish(style.backgroundColor)
) {
return true
}
const parent = element.closest("button, label, [role='button']") as HTMLElement | null
if (parent && parent !== element) {
const parentStyle = window.getComputedStyle(parent)
if (
isGreenish(parentStyle.color) ||
isGreenish(parentStyle.borderColor) ||
isGreenish(parentStyle.backgroundColor)
) {
return true
}
}
return null
}
const textOf = (element: Element | null | undefined): string | null => {
if (!(element instanceof HTMLElement)) {
return null
}
return normalize(element.innerText) ?? normalize(element.textContent)
}
const hintText = (element: Element | null | undefined): string => {
if (!(element instanceof HTMLElement)) {
return ''
}
return [
element.getAttribute('aria-label'),
element.getAttribute('placeholder'),
element.getAttribute('title'),
element.getAttribute('type'),
element.getAttribute('name'),
element.getAttribute('id'),
typeof element.className === 'string' ? element.className : '',
textOf(element),
]
.map((value) => normalize(value)?.toLowerCase() ?? '')
.filter(Boolean)
.join(' ')
.slice(0, 600)
}
const contextText = (element: HTMLElement | null): string => {
if (!element) {
return ''
}
const scopes = [
element,
element.parentElement,
element.parentElement?.parentElement,
element.closest("form, main, article, section, [role='dialog']"),
]
return scopes
.map((scope) => textOf(scope)?.toLowerCase() ?? '')
.filter(Boolean)
.join(' ')
.slice(0, 900)
}
const looksLikeSearchSurface = (element: HTMLElement | null): boolean => {
if (!element) {
return false
}
const searchHint = [
element.getAttribute('aria-label'),
element.getAttribute('placeholder'),
element.getAttribute('title'),
element.getAttribute('type'),
element.getAttribute('name'),
element.getAttribute('id'),
typeof element.className === 'string' ? element.className : '',
]
.map((value) => normalize(value)?.toLowerCase() ?? '')
.filter(Boolean)
.join(' ')
const context = contextText(element)
return (
/(搜索|search)/i.test(searchHint) ||
/(搜索 元宝|全部应用|全部收藏|灵感图库|前往下载中心|下载中心)/i.test(context)
)
}
const looksLikeComposerSurface = (
element: HTMLElement | null,
shell: HTMLElement | null,
): boolean => {
if (!element) {
return false
}
const hint = `${hintText(element)} ${hintText(shell)} ${contextText(shell ?? element)}`
return /(ql-editor|chat-input|text-area|dialogue|composer|editor|提问|问题|输入|深度思考|联网搜索|工具|内容由ai生成,仅供参考)/i.test(
hint,
)
}
const summarizeFailureDetail = (raw: string | null): string | null => {
const normalized = normalize(raw)
if (!normalized) {
return null
}
const cleaned = normalized
.replace(
/The resource http:\/\/localhost:[^\s]+ was preloaded using link preload but not used within a few seconds from the window's load event\.?/gi,
'',
)
.replace(/\s+/g, ' ')
.trim()
if (!cleaned) {
return null
}
if (cleaned.length <= 280) {
return cleaned
}
return `${cleaned.slice(0, 280)}...`
}
const findComposer = (): HTMLElement | null => {
const selectors = [
"[contenteditable='true'][role='textbox']",
"[contenteditable='true'][aria-multiline='true']",
"[contenteditable='true']",
'textarea',
"input[type='text']",
]
const candidates: Array<{ element: HTMLElement; score: number }> = []
for (const selector of selectors) {
const matched = Array.from(document.querySelectorAll(selector))
for (const node of matched) {
if (!(node instanceof HTMLElement) || !isVisible(node)) {
continue
}
if ((node as HTMLInputElement).readOnly || (node as HTMLInputElement).disabled) {
continue
}
const shell = findComposerShell(node)
const rect = node.getBoundingClientRect()
const isPlainInput = node instanceof HTMLInputElement
const composerLike = looksLikeComposerSurface(node, shell)
const searchLike = looksLikeSearchSurface(node) || looksLikeSearchSurface(shell)
if (searchLike && !composerLike) {
continue
}
if (isPlainInput && !composerLike && rect.top < window.innerHeight * 0.75) {
continue
}
let score = rect.top * 3 + Math.min(rect.width, 1200) + rect.height
if (node.isContentEditable) {
score += 2_200
} else if (node instanceof HTMLTextAreaElement) {
score += 1_400
} else if (isPlainInput) {
score -= 1_000
}
if (composerLike) {
score += 1_800
}
if (searchLike) {
score -= 1_800
}
if (rect.top < window.innerHeight * 0.45) {
score -= 1_200
}
if (rect.width >= window.innerWidth * 0.45) {
score += 400
}
if (score <= 0) {
continue
}
candidates.push({
element: node,
score,
})
}
}
candidates.sort((left, right) => right.score - left.score)
return candidates[0]?.element ?? null
}
const findComposerShell = (composer: HTMLElement | null): HTMLElement | null => {
if (!composer) {
return null
}
let current: HTMLElement | null = composer
let best: HTMLElement | null = composer
let bestArea = composer.getBoundingClientRect().width * composer.getBoundingClientRect().height
for (let depth = 0; current && depth < 5; depth += 1) {
if (isVisible(current)) {
const rect = current.getBoundingClientRect()
const area = rect.width * rect.height
if (area >= bestArea) {
best = current
bestArea = area
}
}
current = current.parentElement
}
return best
}
const findInteractiveByText = (
pattern: RegExp,
shell: HTMLElement | null,
): HTMLElement | null => {
const scopes: ParentNode[] = []
if (shell) {
scopes.push(shell)
if (shell.parentElement) {
scopes.push(shell.parentElement)
}
if (shell.parentElement?.parentElement) {
scopes.push(shell.parentElement.parentElement)
}
}
scopes.push(document)
const seen = new Set<HTMLElement>()
const candidates: Array<{ element: HTMLElement; score: number }> = []
// The new yuanbao toolbar renders the deep-think toggle as a plain <div>
// with the business attribute `dt-button-id="deep_think"` (and parallel
// `dt-button-id` / `data-button-id` markers on other toolbar buttons),
// not a <button>/[role=button]. Include those selectors so we still
// match the toggle by visible text. Business attributes are far more
// stable than CSS-module-hashed class names.
for (const scope of scopes) {
const nodes = Array.from(
scope.querySelectorAll(
"button, [role='button'], [role='menuitem'], [role='option'], li.t-dropdown__item, label, [tabindex], [dt-button-id], [data-button-id]",
),
)
for (const node of nodes) {
if (!(node instanceof HTMLElement) || seen.has(node) || !isVisible(node)) {
continue
}
seen.add(node)
const text = normalize(node.innerText) ?? normalize(node.textContent)
if (!text || !pattern.test(text)) {
continue
}
const rect = node.getBoundingClientRect()
const score = rect.top + rect.left + rect.width
candidates.push({ element: node, score })
}
}
candidates.sort((left, right) => right.score - left.score)
return candidates[0]?.element ?? null
}
const deepThinkPattern = /深度(?:思考|搜索)|deep\s*(?:think|search)/i
const webSearchPattern = /联网搜索|网页搜索|web\s*search|online\s*search/i
const toolsPattern = /工具|tools?/i
const clickInteractive = async (element: HTMLElement, delayMs = 180) => {
element.scrollIntoView({ block: 'center', inline: 'center' })
element.click()
await wait(delayMs)
}
const hasActiveComposerMode = (pattern: RegExp, shell: HTMLElement | null): boolean => {
const scopes: ParentNode[] = shell ? [shell, document] : [document]
const seen = new Set<Element>()
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
}
const ensureToggleEnabledByPattern = async (
pattern: RegExp,
shell: HTMLElement | null,
): Promise<boolean | null> => {
const target = findInteractiveByText(pattern, shell)
if (!target) {
return null
}
let state = toggleState(target)
let clicked = false
if (state !== true) {
await clickInteractive(target)
clicked = true
state = toggleState(target)
}
return state ?? (clicked ? true : null)
}
const ensureWebSearchEnabled = async (shell: HTMLElement | null): Promise<boolean | null> => {
if (hasActiveComposerMode(webSearchPattern, shell)) {
return true
}
const toolsButton = findInteractiveByText(toolsPattern, shell)
if (!toolsButton) {
return null
}
await clickInteractive(toolsButton, 220)
const menuItem = findInteractiveByText(webSearchPattern, null)
if (!menuItem) {
return false
}
await clickInteractive(menuItem, 300)
return hasActiveComposerMode(webSearchPattern, shell)
}
const setComposerText = (composer: HTMLElement, text: string) => {
composer.focus()
if (composer instanceof HTMLTextAreaElement || composer instanceof HTMLInputElement) {
const prototype = Object.getPrototypeOf(composer) as {
value?: PropertyDescriptor
}
const setter = Object.getOwnPropertyDescriptor(prototype, 'value')?.set
if (setter) {
setter.call(composer, text)
} else {
composer.value = text
}
composer.dispatchEvent(new Event('input', { bubbles: true }))
composer.dispatchEvent(new Event('change', { bubbles: true }))
return
}
if (composer.isContentEditable) {
const selection = window.getSelection()
const range = document.createRange()
range.selectNodeContents(composer)
selection?.removeAllRanges()
selection?.addRange(range)
const inserted =
typeof document.execCommand === 'function'
? document.execCommand('insertText', false, text)
: false
if (!inserted) {
composer.textContent = text
}
composer.dispatchEvent(
new InputEvent('input', {
bubbles: true,
cancelable: true,
data: text,
inputType: 'insertText',
}),
)
composer.dispatchEvent(new Event('change', { bubbles: true }))
}
}
const findSendButton = (
shell: HTMLElement | null,
composer: HTMLElement | null,
): HTMLElement | null => {
const scopes: ParentNode[] = []
if (shell) {
scopes.push(shell)
if (shell.parentElement) {
scopes.push(shell.parentElement)
}
if (shell.parentElement?.parentElement) {
scopes.push(shell.parentElement.parentElement)
}
}
scopes.push(document)
const composerRect = composer?.getBoundingClientRect() ?? null
const blacklist = /^(深度思考|联网搜索|工具|登录|安装电脑版|下载电脑版)$/
const candidates: Array<{ element: HTMLElement; score: number }> = []
const seen = new Set<HTMLElement>()
for (const scope of scopes) {
const nodes = Array.from(scope.querySelectorAll("button, [role='button']"))
for (const node of nodes) {
if (!(node instanceof HTMLElement) || seen.has(node) || !isVisible(node)) {
continue
}
seen.add(node)
const text = normalize(node.innerText) ?? normalize(node.textContent) ?? ''
const hint = `${hintText(node)} ${contextText(node.parentElement)}`
if (blacklist.test(text)) {
continue
}
if (/(登录|下载电脑版|安装电脑版|前往下载中心|全部应用|全部收藏|搜索)/i.test(hint)) {
continue
}
if (isDisabled(node)) {
continue
}
const rect = node.getBoundingClientRect()
if (rect.width < 20 || rect.height < 20) {
continue
}
if (composerRect) {
if (rect.top < composerRect.top - 120 || rect.top > composerRect.bottom + 140) {
continue
}
if (rect.left < composerRect.left - 80 || rect.left > composerRect.right + 240) {
continue
}
}
let score = rect.left * 2 + rect.top + rect.width + rect.height
if (composerRect) {
const dx = Math.abs(rect.left - composerRect.right)
const dy = Math.abs(rect.top - composerRect.top)
score += Math.max(0, 1_800 - dx * 3 - dy * 3)
if (rect.left >= composerRect.right - 24) {
score += 400
}
}
if (/(发送|submit|send|paper-plane|arrow|up)/i.test(hint)) {
score += 1_200
}
if (!text && rect.width <= 64 && rect.height <= 64) {
score += 280
}
candidates.push({ element: node, score })
}
}
candidates.sort((left, right) => right.score - left.score)
return candidates[0]?.element ?? null
}
const dispatchEnter = (composer: HTMLElement) => {
const eventInit: KeyboardEventInit = {
bubbles: true,
cancelable: true,
key: 'Enter',
code: 'Enter',
}
composer.dispatchEvent(new KeyboardEvent('keydown', eventInit))
composer.dispatchEvent(new KeyboardEvent('keypress', eventInit))
composer.dispatchEvent(new KeyboardEvent('keyup', eventInit))
}
const collectLinks = (scope: HTMLElement | null): YuanbaoDomLink[] => {
if (!scope) {
return []
}
const links: YuanbaoDomLink[] = []
const seen = new Set<string>()
const urlAttributeNames = [
'href',
'data-href',
'data-url',
'data-link',
'data-source-url',
'data-target-url',
'data-jump-url',
'data-doc-url',
'data-article-url',
'data-real-url',
'dt-ext6',
]
const extractUrls = (value: string | null): string[] => {
const normalized = normalize(value)
if (!normalized) {
return []
}
const urls = new Set<string>()
const pushCandidate = (candidate: string) => {
const cleaned = candidate.replace(/&amp;/g, '&')
try {
const decoded = decodeURIComponent(cleaned)
if (/^https?:\/\//i.test(decoded)) {
urls.add(decoded)
}
} catch {
if (/^https?:\/\//i.test(cleaned)) {
urls.add(cleaned)
}
}
}
if (/^https?:\/\//i.test(normalized)) {
pushCandidate(normalized)
}
for (const matched of normalized.matchAll(/https?:\/\/[^\s"'<>\\]+/gi)) {
if (matched[0]) {
pushCandidate(matched[0])
}
}
return Array.from(urls)
}
for (const node of [scope, ...Array.from(scope.querySelectorAll('*'))]) {
if (!(node instanceof HTMLElement) || !isVisible(node)) {
continue
}
const sourceScope = (node.closest(
'.agent-dialogue-references__item, .hyc-common-markdown__ref_card',
) ?? node) as HTMLElement
const urls = new Set<string>()
if (node instanceof HTMLAnchorElement) {
for (const url of extractUrls(node.href)) {
urls.add(url)
}
}
for (const attributeName of urlAttributeNames) {
for (const url of extractUrls(node.getAttribute(attributeName))) {
urls.add(url)
}
}
for (const url of urls) {
const title =
normalize(
sourceScope.querySelector('.hyc-common-markdown__ref_card-title')?.textContent ?? null,
) ??
normalize(node.getAttribute('title')) ??
normalize(node.getAttribute('aria-label'))
const siteName =
normalize(sourceScope.getAttribute('dt-ext3')) ??
normalize(
sourceScope.querySelector('.hyc-common-markdown__ref_card-foot__source_txt')
?.textContent ?? null,
)
const sourceIndex =
Number.parseInt(
sourceScope.getAttribute('data-idx') ??
sourceScope.querySelector('[data-idx]')?.getAttribute('data-idx') ??
node.getAttribute('data-idx') ??
'',
10,
) || null
const seenKey = sourceIndex !== null ? `idx:${sourceIndex}` : `url:${url}`
if (seen.has(seenKey)) {
continue
}
seen.add(seenKey)
links.push({
url,
title,
text:
normalize(node.innerText) ??
normalize(node.textContent) ??
normalize(node.getAttribute('aria-label')),
siteName,
sourceIndex,
})
}
}
return links
}
const collectInlineCitationIndexes = (scope: HTMLElement | null): number[] => {
if (!scope) {
return []
}
const indexes: number[] = []
const seen = new Set<string>()
const triggers = Array.from(scope.querySelectorAll('[data-idx-list]'))
for (const trigger of triggers) {
if (
!(trigger instanceof HTMLElement) ||
!isVisible(trigger) ||
trigger.closest(
'#chatReferenceList, .agent-dialogue-references, .hyc-common-markdown__ref_card',
)
) {
continue
}
const raw = normalize(trigger.getAttribute('data-idx-list'))
if (!raw) {
continue
}
for (const part of raw.split(/[,\s]+/)) {
const index = Number.parseInt(part, 10)
if (!Number.isFinite(index) || index < 1) {
continue
}
const key = `${trigger.getBoundingClientRect().top}:${index}:${indexes.length}`
if (seen.has(key)) {
continue
}
seen.add(key)
indexes.push(index)
}
}
return indexes
}
const textWithInlineCitationMarkers = (scope: HTMLElement | null): string | null => {
if (!scope) {
return null
}
const clone = scope.cloneNode(true) as HTMLElement
for (const node of Array.from(
clone.querySelectorAll(
"#chatReferenceList, [class*='agent-dialogue-references'], [class*='deepsearch-cot__think'], [class*='toolbar'], [class*='question-toolbar'], [class*='suggestion']",
),
)) {
node.remove()
}
for (const node of Array.from(clone.querySelectorAll('[data-idx-list]'))) {
if (!(node instanceof HTMLElement)) {
continue
}
const raw = normalize(node.getAttribute('data-idx-list'))
if (!raw) {
continue
}
const markers = raw
.split(/[,\s]+/)
.map((part) => Number.parseInt(part, 10))
.filter((index) => Number.isFinite(index) && index > 0)
.map((index) => `[${index}]`)
.join('')
if (!markers) {
continue
}
node.replaceWith(document.createTextNode(markers))
}
return normalize(clone.innerText) ?? normalize(clone.textContent)
}
const hasVisibleReferenceCards = (): boolean => {
return Array.from(
document.querySelectorAll(
'#chatReferenceList .hyc-common-markdown__ref_card[data-url], #chatReferenceList [dt-ext6]',
),
).some((node) => node instanceof HTMLElement && isVisible(node))
}
const scrollLatestConversationToBottom = async (): Promise<void> => {
const preferred = document.querySelector('.agent-chat__list__content-wrapper')
if (preferred instanceof HTMLElement && preferred.scrollHeight > preferred.clientHeight) {
preferred.scrollTop = preferred.scrollHeight
await wait(120)
return
}
const scrollers = Array.from(document.querySelectorAll('*'))
.filter(
(node): node is HTMLElement =>
node instanceof HTMLElement &&
isVisible(node) &&
node.scrollHeight > node.clientHeight + 80 &&
node.getBoundingClientRect().width > 240 &&
/agent-chat|chat|dialogue|conversation|content-wrapper/i.test(
`${node.id} ${typeof node.className === 'string' ? node.className : ''}`,
),
)
.sort(
(left, right) =>
right.scrollHeight - right.clientHeight - (left.scrollHeight - left.clientHeight),
)
const target = scrollers[0] ?? null
if (target) {
target.scrollTop = target.scrollHeight
await wait(120)
}
}
const clickLatestThoughtReferenceTrigger = async (composerTop: number | null): Promise<void> => {
const candidates: Array<{ element: HTMLElement; score: number }> = []
for (const node of Array.from(document.querySelectorAll('*'))) {
if (!(node instanceof HTMLElement) || !isVisible(node)) {
continue
}
const text = normalize(node.textContent)
if (!text || !/^找到了\s*\d+\s*篇相关资料/.test(text)) {
continue
}
const style = window.getComputedStyle(node)
const clickable =
style.cursor === 'pointer' ||
node.tabIndex >= 0 ||
typeof (node as { onclick?: unknown }).onclick === 'function'
const className = typeof node.className === 'string' ? node.className : ''
if (!clickable && !/docs__number__words|search/i.test(className)) {
continue
}
const rect = node.getBoundingClientRect()
if (composerTop !== null && rect.top >= composerTop - 12) {
continue
}
candidates.push({
element: node,
score: rect.top + rect.left - Math.abs((composerTop ?? window.innerHeight) - rect.bottom),
})
}
candidates.sort((left, right) => right.score - left.score)
const target = candidates[0]?.element ?? null
if (target) {
await clickInteractive(target, 250)
}
}
const clickLatestCitationPanelTrigger = async (composerTop: number | null): Promise<void> => {
if (hasVisibleReferenceCards()) {
return
}
const candidates: Array<{ element: HTMLElement; score: number }> = []
const nodes = Array.from(
document.querySelectorAll(
"#search-guide-tool, [data-toolbar-type='citation'], [class*='SearchGuid'], [class*='searchGuid']",
),
)
for (const node of nodes) {
if (!(node instanceof HTMLElement) || !isVisible(node)) {
continue
}
const text = normalize(node.textContent) ?? ''
const hint = `${text} ${hintText(node)}`
if (!/(源|引用|citation|source|search)/i.test(hint)) {
continue
}
const rect = node.getBoundingClientRect()
const isCitationTrigger =
node.id === 'search-guide-tool' || node.getAttribute('data-toolbar-type') === 'citation'
if (composerTop !== null && rect.top >= composerTop - 12 && !isCitationTrigger) {
continue
}
candidates.push({
element: node,
score: rect.top * 2 + rect.left + rect.width,
})
}
candidates.sort((left, right) => right.score - left.score)
const target = candidates[0]?.element ?? null
if (target) {
await clickInteractive(target, 350)
}
}
const waitForReferenceCards = async (timeoutMs: number): Promise<void> => {
const deadline = Date.now() + timeoutMs
while (Date.now() <= deadline) {
if (hasVisibleReferenceCards()) {
return
}
await wait(150)
}
}
const revealLatestReferenceSources = async (composerTop: number | null): Promise<void> => {
await scrollLatestConversationToBottom()
await clickLatestThoughtReferenceTrigger(null)
await scrollLatestConversationToBottom()
await clickLatestCitationPanelTrigger(composerTop)
await waitForReferenceCards(2_000)
}
const snapshotConversation = (
composerTop: number | null,
): {
text: string | null
links: YuanbaoDomLink[]
inlineCitationIndexes: number[]
signature: string
} => {
const candidates: Array<{ element: HTMLElement; score: number }> = []
const sourceScopes: HTMLElement[] = []
const elements = Array.from(document.querySelectorAll('article, section, div, main'))
for (const node of elements) {
if (!(node instanceof HTMLElement) || !isVisible(node)) {
continue
}
if (
node.id === 'chatReferenceList' ||
node.classList.contains('agent-dialogue-references') ||
node.closest('#chatReferenceList')
) {
sourceScopes.push(node)
continue
}
const rect = node.getBoundingClientRect()
if (composerTop !== null && rect.top >= composerTop - 12) {
continue
}
if (rect.left < window.innerWidth * 0.12 || rect.width < 180) {
continue
}
const text = normalize(node.innerText)
const links = collectLinks(node)
if ((!text || text.length < 24) && links.length === 0) {
continue
}
if (
text &&
(/^(Hi[,\s].*|内容由AI生成,仅供参考|请登录后输入内容)$/.test(text) ||
/(前往下载中心|全部应用|全部收藏|搜索 元宝|安装电脑版|下载电脑版)/.test(text))
) {
continue
}
if (looksLikeSearchSurface(node)) {
continue
}
const score =
rect.height * 2 +
Math.min(text?.length ?? 0, 800) +
links.length * 80 -
Math.max(0, Math.abs((composerTop ?? window.innerHeight) - rect.bottom))
candidates.push({ element: node, score })
}
candidates.sort((left, right) => right.score - left.score)
const best = candidates[0]?.element ?? null
const text = best ? textWithInlineCitationMarkers(best) : null
const linkMap = new Map<string, YuanbaoDomLink>()
for (const scope of sourceScopes) {
for (const link of collectLinks(scope)) {
const normalizedURL = normalize(link.url)
const linkKey =
link.sourceIndex !== null
? `idx:${link.sourceIndex}`
: normalizedURL
? `url:${normalizedURL}`
: null
if (!linkKey || linkMap.has(linkKey)) {
continue
}
linkMap.set(linkKey, link)
}
}
for (const candidate of candidates.slice(0, 16)) {
for (const link of collectLinks(candidate.element)) {
const normalizedURL = normalize(link.url)
const linkKey =
link.sourceIndex !== null
? `idx:${link.sourceIndex}`
: normalizedURL
? `url:${normalizedURL}`
: null
if (!linkKey || linkMap.has(linkKey)) {
continue
}
linkMap.set(linkKey, link)
}
}
const links = Array.from(linkMap.values())
const inlineCitationIndexes = best ? collectInlineCitationIndexes(best) : []
const signature = `${text ?? ''}|${links.map((item) => item.url).join('|')}`.slice(-1_500)
return { text, links, inlineCitationIndexes, signature }
}
const resolveModelLabel = (): string | null => {
const title = normalize(document.title)
const matched = title?.match(/体验\s*([^-]+?)(?:全新版|高效AI助手|AI助手)/)
return normalize(matched?.[1] ?? null)
}
const bodyText = () => normalize(document.body?.innerText ?? '')
const hasLoginGate = (): boolean => {
if (
document.querySelector(
".hyc-login__dialog, [class*='login__dialog'], [class*='login-dialog']",
)
) {
return true
}
return /请登录后输入内容|请先登录|立即登录|未登录|扫码登录|微信扫码登录/.test(bodyText() ?? '')
}
const hasBusyIndicator = (): boolean => {
const text = bodyText() ?? ''
return /停止生成|思考中|搜索中|回答中|生成中/.test(text)
}
let lastKnownDeepThinkEnabled: boolean | null = null
let lastKnownWebSearchEnabled: boolean | null = null
const fail = (
error: string,
detail: string | null,
composerTop: number | null,
): YuanbaoPageQueryFailureResult => {
const snapshot = snapshotConversation(composerTop)
const currentComposerShell = findComposerShell(findComposer())
return {
ok: false,
error,
detail:
summarizeFailureDetail(detail) ??
summarizeFailureDetail(snapshot.text) ??
summarizeFailureDetail(bodyText()),
url: window.location.href || null,
title: normalize(document.title),
modelLabel: resolveModelLabel(),
deepThinkEnabled:
readToggleStateByPattern(deepThinkPattern, currentComposerShell) ??
lastKnownDeepThinkEnabled,
webSearchEnabled:
readToggleStateByPattern(webSearchPattern, currentComposerShell) ??
lastKnownWebSearchEnabled,
domAnswer: snapshot.text,
domLinks: snapshot.links,
inlineCitationIndexes: snapshot.inlineCitationIndexes,
}
}
const readyDeadline = Date.now() + readyTimeoutMs
let composer = findComposer()
while (!composer && Date.now() <= readyDeadline) {
await wait(250)
composer = findComposer()
}
if (hasLoginGate()) {
return fail('yuanbao_login_required', bodyText(), composer?.getBoundingClientRect().top ?? null)
}
if (!composer) {
return fail('yuanbao_composer_missing', bodyText(), null)
}
let composerShell = findComposerShell(composer)
const composerTop = composer.getBoundingClientRect().top
if (enableDeepThink) {
lastKnownDeepThinkEnabled = await ensureToggleEnabledByPattern(deepThinkPattern, composerShell)
}
if (enableWebSearch) {
lastKnownWebSearchEnabled = await ensureWebSearchEnabled(composerShell)
if (lastKnownWebSearchEnabled !== true) {
return fail('yuanbao_web_search_enable_failed', bodyText(), composerTop)
}
}
setComposerText(composer, questionText)
await wait(220)
composer = findComposer() ?? composer
composerShell = findComposerShell(composer) ?? composerShell
const sendButton = findSendButton(composerShell, composer)
if (sendButton) {
sendButton.click()
} else {
dispatchEnter(composer)
}
await wait(600)
const initialSnapshot = snapshotConversation(composerTop)
let lastSignature = initialSnapshot.signature
let stableCount = 0
let sawChange = false
const deadline = Date.now() + timeoutMs
while (Date.now() <= deadline) {
const snapshot = snapshotConversation(composerTop)
if (snapshot.signature && snapshot.signature !== initialSnapshot.signature) {
sawChange = true
if (snapshot.signature === lastSignature) {
stableCount += 1
} else {
lastSignature = snapshot.signature
stableCount = 0
}
}
if (hasLoginGate()) {
return fail('yuanbao_login_expired', bodyText(), composerTop)
}
if (sawChange && !hasBusyIndicator() && stableCount >= 2) {
await revealLatestReferenceSources(composerTop)
const finalSnapshot = snapshotConversation(composerTop)
const currentDeepThinkEnabled =
readToggleStateByPattern(deepThinkPattern, composerShell) ?? lastKnownDeepThinkEnabled
const currentWebSearchEnabled =
readToggleStateByPattern(webSearchPattern, composerShell) ?? lastKnownWebSearchEnabled
return {
ok: true,
url: window.location.href,
title: normalize(document.title),
modelLabel: resolveModelLabel(),
deepThinkEnabled: currentDeepThinkEnabled,
webSearchEnabled: currentWebSearchEnabled,
domAnswer: finalSnapshot.text ?? snapshot.text,
domLinks: finalSnapshot.links.length ? finalSnapshot.links : snapshot.links,
inlineCitationIndexes: finalSnapshot.inlineCitationIndexes.length
? finalSnapshot.inlineCitationIndexes
: snapshot.inlineCitationIndexes,
}
}
await wait(1_000)
}
return fail('yuanbao_query_timeout', bodyText(), composerTop)
}
export const yuanbaoAdapter: MonitorAdapter = {
provider: 'yuanbao',
executionMode: 'playwright',
async query(context, payload) {
if (!context.playwright?.page) {
return {
status: 'failed',
summary: '元宝监测缺少 Playwright 页面上下文。',
error: buildAdapterError('yuanbao_playwright_required', 'yuanbao_playwright_required'),
}
}
const questionText = extractQuestionText(payload)
const page = context.playwright.page
context.reportProgress('yuanbao.page_ready')
await ensurePageOnYuanbaoChat(page, context.signal)
const capture = attachYuanbaoResponseCapture(page, questionText)
let pageResult: YuanbaoPageQueryResult
try {
context.reportProgress('yuanbao.query')
pageResult = await page.evaluate(yuanbaoQueryInPage, {
questionText,
timeoutMs: YUANBAO_QUERY_TIMEOUT_MS,
readyTimeoutMs: YUANBAO_PAGE_READY_TIMEOUT_MS,
enableDeepThink: true,
enableWebSearch: true,
})
await sleep(600, context.signal).catch(() => undefined)
await capture.flush(YUANBAO_CAPTURE_FLUSH_TIMEOUT_MS)
} finally {
capture.dispose()
}
const parsed = parseYuanbaoCaptures(capture.captures)
const pendingCaptureCount = capture.pendingCount()
const domCitations = buildOrderedDomSourceItems(pageResult.domLinks)
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,
)
const maxCitationMarkerIndex = citationMarkerIndexes.length
? Math.max(...citationMarkerIndexes)
: 0
const markerCitations = dedupeSourceItems(
resolveCitationsFromMarkers(answerWithMarkers ?? pageResult.domAnswer, [
parsed.citations,
parsed.searchResults,
domCitations,
dedupeSourceItems([...parsed.citations, ...parsed.searchResults, ...domCitations]),
]),
)
const answer = normalizeCitationMarkers(answerWithMarkers)
const citations =
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)
.filter((item): item is MonitoringSourceItem => item !== null),
)
const reasoning = repairPotentialMojibake(normalizeText(parsed.reasoning))
const providerModel = resolveProviderModel(pageResult, parsed)
const conversationID = parsed.conversationID ?? extractConversationID(pageResult.url)
const requestID = parsed.requestID ?? conversationID
const unresolvedCitationIndexes = findUnresolvedCitationIndexes(answer, citations)
if (pageResult.ok === false) {
const failedPageResult = pageResult
const detail = normalizeText(failedPageResult.detail) ?? 'unknown'
if (
failedPageResult.error === 'yuanbao_login_required' ||
failedPageResult.error === 'yuanbao_login_expired'
) {
return {
status: 'failed',
summary: '元宝账号未登录或登录态已失效,请先在 desktop client 中重新授权。',
error: buildAdapterError(failedPageResult.error, detail, {
page_url: failedPageResult.url ?? safePageURL(page),
}),
}
}
if (
failedPageResult.error !== 'yuanbao_query_timeout' ||
(!answer && !searchResults.length && !citations.length)
) {
return {
status: 'failed',
summary: `元宝请求失败:${detail}`,
error: buildAdapterError(failedPageResult.error, detail, {
page_url: failedPageResult.url ?? safePageURL(page),
}),
}
}
}
if (!answer && !searchResults.length && !citations.length) {
return {
status: 'unknown',
summary: '元宝返回为空,已回写 unknown 等待后续对账。',
error: buildAdapterError('yuanbao_empty_response', 'yuanbao returned no answer or sources'),
}
}
if (answer && unresolvedCitationIndexes.length > 0) {
return {
status: 'unknown',
summary: '元宝回答含引用标记但未拿到完整引用来源,已回写 unknown 等待后续补采。',
error: buildAdapterError(
'yuanbao_incomplete_citations',
'yuanbao answer contains citation markers but source extraction is incomplete',
{
citation_marker_count: citationMarkerIndexes.length,
max_citation_marker_index: maxCitationMarkerIndex,
unresolved_citation_indexes: unresolvedCitationIndexes,
citation_count: citations.length,
search_result_count: searchResults.length,
dom_citation_count: domCitations.length,
parsed_citation_count: parsed.citations.length,
parsed_search_result_count: parsed.searchResults.length,
},
),
}
}
context.reportProgress('yuanbao.parse_result')
return {
status: 'succeeded',
summary: '元宝监控任务执行成功。',
payload: {
platform: 'yuanbao',
provider_model: providerModel,
provider_request_id: parsed.providerRequestID,
request_id: requestID,
conversation_id: conversationID,
answer,
response_source: responseSource,
reasoning,
citation_count: citations.length,
search_result_count: searchResults.length,
citations: toJsonSources(citations),
search_results: toJsonSources(searchResults),
raw_response_json: {
platform: 'yuanbao',
page_url: pageResult.url,
page_title: pageResult.title,
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,
content_types: Array.from(parsed.contentTypes.values()),
citation_marker_count: citationMarkerIndexes.length,
citation_marker_indexes: citationMarkerIndexes,
unresolved_citation_indexes: unresolvedCitationIndexes,
dom_inline_citation_indexes: pageResult.inlineCitationIndexes,
inline_citation_candidates: toJsonSources(inlineCitationCandidates),
dom_reference_links: toJsonSources(domCitations),
},
},
}
},
}
export const __yuanbaoTestUtils = {
appendUniqueSourceItems,
buildOrderedDomSourceItems,
buildSourceItem,
dedupeSourceItems,
extractCitationMarkerIndexes,
extractQuestionText,
findUnresolvedCitationIndexes,
isCurrentYuanbaoChatSSE,
mergeText,
normalizeCitationMarkers,
normalizeSourceUrl,
parseYuanbaoCaptures,
resolveCitationsFromMarkers,
selectBestAnswerText,
}