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