fix: preserve monitor unknown and adapt doubao references
Desktop Client Build / Resolve Build Metadata (push) Successful in 32s
Backend CI / Backend (push) Successful in 15m37s
Desktop Client Build / Build Desktop Client (push) Successful in 22m53s
Desktop Client Build / Publish Client Artifacts to NAS (push) Successful in 30s

This commit is contained in:
2026-06-22 13:16:38 +08:00
parent 8c6789dca6
commit 365a41597e
4 changed files with 271 additions and 31 deletions
@@ -5,7 +5,9 @@ import { __doubaoTestUtils } from './doubao'
const {
buildSourceItem,
dedupeSourceItems,
doubaoModeKindFromLabelText,
extractQuestionText,
isDoubaoRecoverablePageError,
isNonCitationAssetDomain,
isNonCitationAssetUrl,
parseDoubaoStreamBody,
@@ -20,6 +22,19 @@ describe('doubao adapter helpers', () => {
)
})
it('treats query timeouts as recoverable page failures', () => {
expect(isDoubaoRecoverablePageError('doubao_query_timeout')).toBe(true)
expect(isDoubaoRecoverablePageError('doubao_page_navigation_interrupted')).toBe(true)
expect(isDoubaoRecoverablePageError('doubao_login_expired')).toBe(false)
})
it('maps the new expert/deep-thinking mode label to expert mode', () => {
expect(doubaoModeKindFromLabelText('专家')).toBe('expert')
expect(doubaoModeKindFromLabelText('专家 深度思考 研究级智能模型')).toBe('expert')
expect(doubaoModeKindFromLabelText('思考')).toBe('thinking')
expect(doubaoModeKindFromLabelText('快速')).toBe('fast')
})
it('filters byteimg and bytednsdoc asset CDN links from source items', () => {
expect(isNonCitationAssetDomain('p3-spider-image-sign.byteimg.com')).toBe(true)
expect(isNonCitationAssetDomain('lf3-static.bytednsdoc.com')).toBe(true)
@@ -99,6 +114,22 @@ describe('doubao adapter helpers', () => {
})
})
it('extracts source URLs embedded in Doubao thinking-panel text', () => {
const item = buildSourceItem({
url: '1. 合肥全屋定制榜单 https://www.doubao.com/link?target=https%3A%2F%2Fexample.com%2Fsource%3Ffrom%3Dthink#card',
title: '思考链路里的来源',
site_name: '示例站点',
})
expect(item).toMatchObject({
url: 'https://example.com/source?from=think',
normalized_url: 'https://example.com/source?from=think',
host: 'example.com',
title: '思考链路里的来源',
site_name: '示例站点',
})
})
it('parses Doubao answer deltas and source cards from the completion SSE stream', () => {
const sourceChunkPayload = {
patch_op: [
+202 -28
View File
@@ -491,13 +491,27 @@ function looksLikeSourceUrl(value: string | null): boolean {
return false
}
if (/^https?:\/\//i.test(normalized) || /^\/\//.test(normalized)) {
if (/https?:\/\//i.test(normalized) || /^\/\//.test(normalized)) {
return true
}
return /^[a-z0-9-]+(?:\.[a-z0-9-]+)+(?:[/?#].*)?$/i.test(normalized)
}
function extractEmbeddedHttpUrl(value: string | null): string | null {
const input = normalizeText(value)
if (!input) {
return null
}
const match = input.match(/https?:\/\/[^\s"'<>\\]+/i)?.[0] ?? null
if (!match) {
return null
}
return match.replace(/[),;]+$/u, '')
}
function extractDoubaoEmbeddedSourceUrl(url: URL, depth = 0): string | null {
if (depth > 3) {
return null
@@ -550,7 +564,8 @@ function normalizeSourceUrl(value: string | null, depth = 0): string | null {
return null
}
const candidates = [input]
const embeddedURL = extractEmbeddedHttpUrl(input)
const candidates = embeddedURL && embeddedURL !== input ? [embeddedURL, input] : [input]
try {
const decoded = decodeURIComponent(input)
if (decoded && decoded !== input) {
@@ -1313,12 +1328,7 @@ async function ensurePageOnDoubaoChat(page: PlaywrightPage, signal: AbortSignal)
}
const currentURL = safePageURL(page)
if (!currentURL.startsWith('https://www.doubao.com/')) {
await page.goto(DOUBAO_BOOTSTRAP_URL, {
waitUntil: 'domcontentloaded',
timeout: DOUBAO_PAGE_READY_TIMEOUT_MS,
})
} else if (!currentURL.startsWith('https://www.doubao.com/chat')) {
if (currentURL !== DOUBAO_BOOTSTRAP_URL) {
await page.goto(DOUBAO_BOOTSTRAP_URL, {
waitUntil: 'domcontentloaded',
timeout: DOUBAO_PAGE_READY_TIMEOUT_MS,
@@ -1337,6 +1347,26 @@ async function ensurePageOnDoubaoChat(page: PlaywrightPage, signal: AbortSignal)
}
}
async function resetDoubaoPageAfterRecoverableFailure(
page: PlaywrightPage,
error: string,
signal: AbortSignal,
): Promise<void> {
if (!isDoubaoRecoverablePageError(error) || signal.aborted) {
return
}
try {
await page.goto(DOUBAO_BOOTSTRAP_URL, {
waitUntil: 'domcontentloaded',
timeout: DOUBAO_PAGE_READY_TIMEOUT_MS,
})
await page.waitForLoadState('networkidle', { timeout: 3_000 }).catch(() => undefined)
} catch {
// Best effort: timeout recovery must not mask the original unknown result.
}
}
function attachDoubaoResponseCapture(page: PlaywrightPage): {
captures: DoubaoCaptureRecord[]
flush: (timeoutMs: number) => Promise<void>
@@ -1483,7 +1513,16 @@ function doubaoModeProviderModel(mode: DoubaoAnswerModeKind | null): string | nu
}
function normalizeDoubaoModeLabel(value: string | null): DoubaoAnswerModeKind | null {
switch (normalizeText(value)) {
return doubaoModeKindFromLabelText(value)
}
function isDoubaoThinkingMode(mode: DoubaoAnswerModeKind | null): boolean {
return mode === 'thinking' || mode === 'expert'
}
function doubaoModeKindFromLabelText(value: string | null): DoubaoAnswerModeKind | null {
const label = normalizeText(value)
switch (label) {
case '快速':
return 'fast'
case '思考':
@@ -1491,6 +1530,9 @@ function normalizeDoubaoModeLabel(value: string | null): DoubaoAnswerModeKind |
case '专家':
return 'expert'
default:
if (label?.includes('深度思考') || label?.includes('研究级智能模型')) {
return 'expert'
}
return null
}
}
@@ -1505,7 +1547,7 @@ function resolveProviderModel(pageResult: DoubaoPageQueryResult): string {
return label
}
if (pageResult.thinkingModeApplied) {
return 'doubao-web-thinking'
return pageResult.modeLabel === '专家' ? 'doubao-web-expert' : 'doubao-web-thinking'
}
return pageResult.deepThinkEnabled ? 'doubao-web-thinking' : 'doubao-web'
}
@@ -1807,13 +1849,22 @@ const doubaoQueryInPage = async (
if (label === ANSWER_MODE_LABELS.expert) {
return 'expert'
}
if (label.includes('专家')) {
return 'expert'
}
if (label.includes('思考') || label.includes('深度思考')) {
return 'thinking'
}
return null
}
const isThinkingAnswerMode = (mode: AnswerModeKind | null): boolean =>
mode === 'thinking' || mode === 'expert'
const classifyAnswerModeText = (value: unknown): AnswerModeKind | null => {
const multiline = normalizeMultiline(typeof value === 'string' ? value : null)
const normalized = multiline ?? normalize(value)
if (!normalized || /深度思考|正在思考|思考中/.test(normalized)) {
if (!normalized || /正在思考|思考中/.test(normalized)) {
return null
}
@@ -1845,7 +1896,7 @@ const doubaoQueryInPage = async (
if (compact.startsWith(label)) {
return answerModeKindFromLabel(label)
}
return null
return answerModeKindFromLabel(label)
}
const readElementAnswerMode = (element: Element | null | undefined): AnswerModeSnapshot => {
@@ -2049,7 +2100,7 @@ const doubaoQueryInPage = async (
): Promise<AnswerModeSnapshot & { applied: boolean | null }> => {
const initialTrigger = findAnswerModeTrigger(shell, composer)
const initialMode = readElementAnswerMode(initialTrigger)
if (initialMode.kind === 'thinking') {
if (isThinkingAnswerMode(initialMode.kind)) {
return { ...initialMode, applied: true }
}
if (!initialTrigger) {
@@ -2059,12 +2110,14 @@ const doubaoQueryInPage = async (
clickModeElement(initialTrigger)
await wait(260)
const thinkingOption = findAnswerModeOption('thinking', initialTrigger)
const thinkingOption =
findAnswerModeOption('expert', initialTrigger) ??
findAnswerModeOption('thinking', initialTrigger)
if (!thinkingOption) {
pressEscape()
await wait(120)
const current = readCurrentAnswerMode(shell, composer)
return { ...current, applied: current.kind === 'thinking' ? true : null }
return { ...current, applied: isThinkingAnswerMode(current.kind) ? true : null }
}
clickModeElement(thinkingOption)
@@ -2073,7 +2126,7 @@ const doubaoQueryInPage = async (
const current = readCurrentAnswerMode(shell, composer)
return {
...current,
applied: current.kind === 'thinking' ? true : null,
applied: isThinkingAnswerMode(current.kind) ? true : null,
}
}
@@ -2442,25 +2495,76 @@ const doubaoQueryInPage = async (
}
const links: DoubaoDomLink[] = []
const seen = new Set<string>()
for (const node of Array.from(scope.querySelectorAll('a[href]'))) {
if (!(node instanceof HTMLAnchorElement) || !isVisible(node)) {
continue
}
const href = normalizeHref(node.getAttribute('href') ?? node.href)
const pushLink = (
hrefCandidate: string | null | undefined,
element: HTMLElement,
fallbackText: string | null,
): void => {
const href = normalizeHref(hrefCandidate ?? null)
if (!href || !isExternalHref(href) || seen.has(href)) {
continue
return
}
seen.add(href)
const visibleText = normalize(node.innerText) ?? normalize(node.textContent)
const visibleText = normalize(element.innerText) ?? normalize(element.textContent) ?? fallbackText
links.push({
url: href,
title: normalize(node.title) ?? normalize(node.getAttribute('aria-label')) ?? visibleText,
title:
normalize(element.getAttribute('title')) ??
normalize(element.getAttribute('aria-label')) ??
visibleText,
text: visibleText,
siteName:
normalize(node.getAttribute('data-site-name')) ??
normalize(node.dataset.siteName ?? null),
normalize(element.getAttribute('data-site-name')) ??
normalize(element.dataset.siteName ?? null) ??
normalize(element.getAttribute('data-source-name')) ??
normalize(element.dataset.sourceName ?? null),
})
}
const selector = [
'a[href]',
'[data-url]',
'[data-href]',
'[data-link]',
'[data-target-url]',
'[data-targetUrl]',
'[data-source-url]',
'[data-sourceUrl]',
'[data-jump-url]',
'[data-jumpUrl]',
'[aria-label*="http"]',
'[title*="http"]',
].join(',')
for (const node of Array.from(scope.querySelectorAll(selector))) {
if (!(node instanceof HTMLElement) || !isVisible(node)) {
continue
}
const candidates = [
node.getAttribute('href'),
node.getAttribute('data-url'),
node.getAttribute('data-href'),
node.getAttribute('data-link'),
node.getAttribute('data-target-url'),
node.getAttribute('data-targetUrl'),
node.getAttribute('data-source-url'),
node.getAttribute('data-sourceUrl'),
node.getAttribute('data-jump-url'),
node.getAttribute('data-jumpUrl'),
node.getAttribute('aria-label'),
node.getAttribute('title'),
]
for (const candidate of candidates) {
pushLink(candidate, node, null)
}
}
const text = normalizeMultiline(scope.innerText)
for (const match of text?.matchAll(/https?:\/\/[^\s"'<>\\]+/gi) ?? []) {
pushLink(match[0], scope, null)
}
return links
}
@@ -2535,6 +2639,21 @@ const doubaoQueryInPage = async (
const normalizedQuestion = normalizeMultiline(questionText)
const isReferenceHeaderLine = (line: string): boolean =>
/搜索\s*\d+\s*个关键词|参考\s*\d+\s*篇资料|查看\d+篇资料|参考资料|资料来源/.test(line)
const isReferenceQueryLine = (line: string): boolean =>
/[“"「].+[”"」]/.test(line) ||
/、/.test(line) ||
/关键词|搜索词|查询词/.test(line)
const isReferenceItemLine = (line: string): boolean =>
/^\d+[.、]\s*\S+/.test(line) &&
(/https?:\/\//i.test(line) ||
/新闻|新浪|搜狐|网易|腾讯|百度|知乎|什么值得买|列举网|装修网|家居|资料|来源|推荐|排名|榜|厂家|品牌|公司|工厂|门店/.test(
line,
))
// Tokens that only appear on Doubao's empty-chat surface (prompt templates, nav, feature chips).
const EMPTY_CHAT_TOKENS = [
'快速',
@@ -2580,7 +2699,30 @@ const doubaoQueryInPage = async (
.split(/\n+/)
.map((line) => line.trim())
.filter(Boolean)
let inReferenceBlock = false
let referenceIntroBudget = 0
const kept = lines.filter((line) => {
if (/^-{4,}$/.test(line.replace(/\s+/g, ''))) {
inReferenceBlock = false
referenceIntroBudget = 0
return false
}
if (isReferenceHeaderLine(line)) {
inReferenceBlock = true
referenceIntroBudget = 1
return false
}
if (inReferenceBlock) {
if (referenceIntroBudget > 0 && isReferenceQueryLine(line)) {
referenceIntroBudget -= 1
return false
}
referenceIntroBudget = 0
if (isReferenceItemLine(line)) {
return false
}
inReferenceBlock = false
}
if (EMPTY_CHAT_TOKENS.includes(line)) {
return false
}
@@ -2632,11 +2774,31 @@ const doubaoQueryInPage = async (
return best?.element ?? null
}
const collectReferencePanelLinks = (): DoubaoDomLink[] => {
const groups: DoubaoDomLink[][] = []
const nodes = Array.from(document.querySelectorAll('article, section, div, main, li'))
for (const node of nodes) {
if (!(node instanceof HTMLElement) || !isVisible(node)) {
continue
}
const text = normalizeMultiline(node.innerText)
if (!text || !isReferenceHeaderLine(text)) {
continue
}
const links = collectLinks(node)
if (links.length > 0) {
groups.push(links)
}
}
return mergeLinks(...groups)
}
const snapshotConversation = (composerTop: number | null): ConversationSnapshot => {
expandReferenceControls()
const questionAnchor = findQuestionAnchor()
const anchorRect = questionAnchor?.getBoundingClientRect() ?? null
const referencePanelLinks = collectReferencePanelLinks()
const candidates: Array<{
element: HTMLElement
@@ -2708,6 +2870,9 @@ const doubaoQueryInPage = async (
if (questionAnchor && rect.top > (anchorRect?.bottom ?? 0)) {
score += 1_000
}
if (rawText && isReferenceHeaderLine(rawText)) {
score -= 1_200
}
if (score <= 0) {
continue
@@ -2801,6 +2966,7 @@ const doubaoQueryInPage = async (
}
const scopedReferenceLinks = mergeLinks(
referencePanelLinks,
...orderedCandidates
.filter((candidate) => {
if (!candidate.text) {
@@ -2856,7 +3022,7 @@ const doubaoQueryInPage = async (
title: normalize(document.title),
modelLabel: resolveModelLabel(),
modeLabel,
thinkingModeApplied: modeKind === 'thinking' ? true : (modeSnapshot.applied ?? null),
thinkingModeApplied: isThinkingAnswerMode(modeKind) ? true : (modeSnapshot.applied ?? null),
deepThinkEnabled: toggleState(deepThinkButton),
domAnswer: snapshot.answerText,
domReasoning: snapshot.reasoningText,
@@ -2973,7 +3139,9 @@ const doubaoQueryInPage = async (
answerMode = {
kind: currentMode.kind ?? answerMode.kind,
label: currentMode.label ?? answerMode.label,
applied: (currentMode.kind ?? answerMode.kind) === 'thinking' ? true : answerMode.applied,
applied: isThinkingAnswerMode(currentMode.kind ?? answerMode.kind)
? true
: answerMode.applied,
}
return {
ok: true,
@@ -3077,6 +3245,10 @@ export const doubaoAdapter: MonitorAdapter = {
const hasRecoveredContent = hasRecoveredAnswer || searchResults.length > 0
const retryableStreamError = isDoubaoRetryableErrorMessage(streamSummary.error_message)
if (pageResult.ok === false && pageRecoverableError && !hasRecoveredAnswer) {
await resetDoubaoPageAfterRecoverableFailure(page, pageResult.error, context.signal)
}
if (pageResult.ok === false) {
const failed = pageResult
const detail = normalizeText(failed.detail) ?? 'unknown'
@@ -3255,9 +3427,11 @@ export const doubaoAdapter: MonitorAdapter = {
export const __doubaoTestUtils = {
buildSourceItem,
dedupeSourceItems,
doubaoModeKindFromLabelText,
extractQuestionText,
isNonCitationAssetDomain,
isNonCitationAssetUrl,
isDoubaoRecoverablePageError,
parseDoubaoStreamBody,
resolveDoubaoSubmissionAnswer,
}