70b9f75aea
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>
2607 lines
76 KiB
TypeScript
2607 lines
76 KiB
TypeScript
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(/&/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,
|
||
}
|