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
// 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([
[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
}
function isRecord(value: unknown): value is Record {
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()
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, keys: string[]): string | null {
for (const key of keys) {
const value = normalizeOptionalString(record[key])
if (value) {
return value
}
}
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) {
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()
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()
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()
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()
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 = {},
): Record {
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 {
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 {
await new Promise((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 {
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
pendingCount: () => number
dispose: () => void
} {
const captures: YuanbaoCaptureRecord[] = []
const pending = new Set>()
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((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()
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(),
}
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 => {
const { questionText, timeoutMs, readyTimeoutMs, enableDeepThink, enableWebSearch } = parameters
const wait = (timeMs: number) =>
new Promise((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()
const candidates: Array<{ element: HTMLElement; score: number }> = []
// The new yuanbao toolbar renders the deep-think toggle as a plain
// with the business attribute `dt-button-id="deep_think"` (and parallel
// `dt-button-id` / `data-button-id` markers on other toolbar buttons),
// not a