diff --git a/apps/desktop-client/src/main/adapters/yuanbao.test.ts b/apps/desktop-client/src/main/adapters/yuanbao.test.ts index 53f8524..711359a 100644 --- a/apps/desktop-client/src/main/adapters/yuanbao.test.ts +++ b/apps/desktop-client/src/main/adapters/yuanbao.test.ts @@ -3,10 +3,15 @@ import { describe, expect, it } from "vitest"; import { __yuanbaoTestUtils } from "./yuanbao"; const { + appendUniqueSourceItems, + buildOrderedDomSourceItems, buildSourceItem, extractCitationMarkerIndexes, + extractQuestionText, + findUnresolvedCitationIndexes, mergeText, normalizeSourceUrl, + parseYuanbaoCaptures, resolveCitationsFromMarkers, } = __yuanbaoTestUtils; @@ -29,6 +34,18 @@ describe("yuanbao adapter helpers", () => { }); }); + it("rejects browser and build diagnostics as monitoring questions", () => { + expect(extractQuestionText({ question_text: "合肥全屋定制推荐" })).toBe("合肥全屋定制推荐"); + expect(() => extractQuestionText({ + question_text: + "The resource http://localhost:8000/static/js/runtime.c632b2fe.js was preloaded using link preload but not used within a few seconds from the window's load event.", + })).toThrow("yuanbao_question_text_runtime_diagnostic"); + expect(() => extractQuestionText({ + question_text: + "failed to solve: process \"/bin/sh -c go build\" did not complete successfully: exit code: 1", + })).toThrow("yuanbao_question_text_runtime_diagnostic"); + }); + it("merges streaming answer fragments without duplicating a repeated prefix", () => { const first = "通过三轮搜索,我已经掌握了合肥全屋定制市场的高性价比品牌。一定要合同中的定标准确和责任。"; const repeated = "通过三轮搜索,我已经掌握了合肥全屋定制市场的高性价比品牌。结合你的预算,我建议优先看本土老牌。"; @@ -49,4 +66,77 @@ describe("yuanbao adapter helpers", () => { "https://example.com/3", ]); }); + + it("preserves indexed DOM citation slots even when URLs repeat", () => { + const domSources = buildOrderedDomSourceItems([ + { + url: "https://example.com/repeated", + title: "来源 1", + text: null, + siteName: "Example", + sourceIndex: 1, + }, + { + url: "https://example.com/repeated", + title: "来源 2", + text: null, + siteName: "Example", + sourceIndex: 2, + }, + { + url: "https://example.com/third", + title: "来源 3", + text: null, + siteName: "Example", + sourceIndex: 3, + }, + ]); + + expect(domSources).toHaveLength(3); + expect(domSources.map((item) => item.title)).toEqual(["来源 1", "来源 2", "来源 3"]); + expect(findUnresolvedCitationIndexes("正文 [citation:2] [citation:3]", domSources)).toEqual([]); + }); + + it("keeps primary DOM citation order when appending parsed extras", () => { + const primary = [ + { url: "https://example.com/1", normalized_url: "https://example.com/1" }, + { url: "https://example.com/2", normalized_url: "https://example.com/2" }, + ]; + const appended = appendUniqueSourceItems(primary, [ + { url: "https://example.com/2", normalized_url: "https://example.com/2", title: "duplicate" }, + { url: "https://example.com/3", normalized_url: "https://example.com/3" }, + ]); + + expect(appended.map((item) => item.url)).toEqual([ + "https://example.com/1", + "https://example.com/2", + "https://example.com/3", + ]); + }); + + it("does not report incomplete citations when the answer has no markers", () => { + expect(findUnresolvedCitationIndexes("正文没有引用小图标", [])).toEqual([]); + }); + + it("collects source candidates from deep search thinking fields", () => { + const summary = parseYuanbaoCaptures([ + { + url: "https://yuanbao.tencent.com/api/chat", + status: 200, + contentType: "text/event-stream", + body: [ + "data: {\"type\":\"deepSearch\",\"contents\":[{\"type\":\"step\",\"webPages\":[{\"targetUrl\":\"https://example.com/a#ref\",\"title\":\"来源 A\",\"siteName\":\"Example\"}],\"references\":[{\"sourceUrl\":\"https://example.com/b\",\"title\":\"来源 B\"}]}]}", + "data: {\"type\":\"text\",\"msg\":\"正文 [citation:1] [citation:2]\"}", + ].join("\n\n"), + }, + ]); + + expect(summary.searchResults.map((item) => item.url)).toContain("https://example.com/a"); + expect(summary.citations.map((item) => item.url)).toContain("https://example.com/b"); + expect(resolveCitationsFromMarkers(summary.answer, [ + summary.citations, + summary.searchResults, + [...summary.citations, ...summary.searchResults], + ]).map((item) => item.url)).toEqual(["https://example.com/b", "https://example.com/a"]); + }); }); diff --git a/apps/desktop-client/src/main/adapters/yuanbao.ts b/apps/desktop-client/src/main/adapters/yuanbao.ts index 88bcd37..907794c 100644 --- a/apps/desktop-client/src/main/adapters/yuanbao.ts +++ b/apps/desktop-client/src/main/adapters/yuanbao.ts @@ -8,7 +8,8 @@ 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 = 160_000; +const YUANBAO_CAPTURE_BODY_LIMIT = 1_200_000; +const YUANBAO_CAPTURE_FLUSH_TIMEOUT_MS = 20_000; const YUANBAO_CITATION_MARKER_PATTERN = /\[citation:\s*(\d+)\]/gi; const WINDOWS_1252_REVERSE_MAP = new Map([ [0x20ac, 0x80], @@ -51,6 +52,8 @@ type YuanbaoDomLink = { url: string; title: string | null; text: string | null; + siteName: string | null; + sourceIndex: number | null; }; type YuanbaoPageQuerySuccessResult = { @@ -62,6 +65,7 @@ type YuanbaoPageQuerySuccessResult = { webSearchEnabled: boolean | null; domAnswer: string | null; domLinks: YuanbaoDomLink[]; + inlineCitationIndexes: number[]; }; type YuanbaoPageQueryFailureResult = { @@ -75,6 +79,7 @@ type YuanbaoPageQueryFailureResult = { webSearchEnabled: boolean | null; domAnswer: string | null; domLinks: YuanbaoDomLink[]; + inlineCitationIndexes: number[]; }; type YuanbaoPageQueryResult = YuanbaoPageQuerySuccessResult | YuanbaoPageQueryFailureResult; @@ -576,6 +581,91 @@ function dedupeSourceItems(items: MonitoringSourceItem[]): MonitoringSourceItem[ return Array.from(keyed.values()); } +function appendUniqueSourceItems( + primaryItems: MonitoringSourceItem[], + extraItems: MonitoringSourceItem[], +): MonitoringSourceItem[] { + const output = [...primaryItems]; + const seen = new Set(); + for (const item of primaryItems) { + const key = normalizeSourceUrl(item.normalized_url ?? item.url); + if (key) { + seen.add(key); + } + } + + for (const item of extraItems) { + const key = normalizeSourceUrl(item.normalized_url ?? item.url); + if (!key || seen.has(key)) { + continue; + } + seen.add(key); + output.push({ + ...item, + normalized_url: key, + }); + } + + return output; +} + +function buildOrderedDomSourceItems(links: YuanbaoDomLink[]): MonitoringSourceItem[] { + const indexedItems = new Map(); + const fallbackItems: MonitoringSourceItem[] = []; + + for (const link of links) { + const sourceItem = buildSourceItem(link); + if (!sourceItem) { + continue; + } + + const sourceIndex = link.sourceIndex; + if (sourceIndex !== null && Number.isFinite(sourceIndex) && sourceIndex > 0) { + if (!indexedItems.has(sourceIndex)) { + indexedItems.set(sourceIndex, sourceItem); + } + continue; + } + + fallbackItems.push(sourceItem); + } + + if (!indexedItems.size) { + return dedupeSourceItems(fallbackItems); + } + + const maxIndex = Math.max(...indexedItems.keys()); + const hasContiguousIndexes = Array.from({ length: maxIndex }, (_, index) => index + 1) + .every((index) => indexedItems.has(index)); + + if (!hasContiguousIndexes) { + return dedupeSourceItems([ + ...Array.from(indexedItems.entries()) + .sort(([left], [right]) => left - right) + .map(([, item]) => item), + ...fallbackItems, + ]); + } + + return Array.from({ length: maxIndex }, (_, index) => indexedItems.get(index + 1)) + .filter((item): item is MonitoringSourceItem => item !== undefined); +} + +function findUnresolvedCitationIndexes(answerText: string | null, citations: MonitoringSourceItem[]): number[] { + const unresolved: number[] = []; + const seen = new Set(); + for (const index of extractCitationMarkerIndexes(answerText)) { + if (seen.has(index)) { + continue; + } + seen.add(index); + if (!citations[index - 1]) { + unresolved.push(index); + } + } + return unresolved; +} + function toJsonSources(items: MonitoringSourceItem[]): JsonValue[] { return items.map((item) => ({ url: item.url, @@ -606,6 +696,14 @@ function buildAdapterError( }; } +function looksLikeRuntimeDiagnosticQuestion(value: string): boolean { + return ( + /^The resource https?:\/\/localhost:[^\s]+ was preloaded using link preload/i.test(value) + || /^failed to solve:/i.test(value) + || /did not complete successfully:\s*exit code:\s*\d+/i.test(value) + ); +} + function extractQuestionText(payload: Record): string { const candidates = [ payload.question_text, @@ -617,13 +715,22 @@ function extractQuestionText(payload: Record): string { payload.title, ]; + let rejectedDiagnostic = false; for (const candidate of candidates) { const text = normalizeOptionalString(candidate); - if (text) { - return text; + 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"); } @@ -681,9 +788,12 @@ function safePageURL(page: PlaywrightPage): string { function attachYuanbaoResponseCapture(page: PlaywrightPage): { captures: YuanbaoCaptureRecord[]; + flush: (timeoutMs: number) => Promise; + pendingCount: () => number; dispose: () => void; } { const captures: YuanbaoCaptureRecord[] = []; + const pending = new Set>(); const pushCapture = (record: YuanbaoCaptureRecord) => { captures.push({ @@ -713,7 +823,7 @@ function attachYuanbaoResponseCapture(page: PlaywrightPage): { return; } - void response.body() + const task = response.body() .then((body) => { const normalizedBody = normalizeOptionalString(decodeResponseBody(body)) ?? ""; pushCapture({ @@ -724,12 +834,32 @@ function attachYuanbaoResponseCapture(page: PlaywrightPage): { }); }) .catch(() => undefined); + pending.add(task); + void task.finally(() => { + pending.delete(task); + }); }; page.on("response", responseHandler); return { captures, + async flush(timeoutMs: number) { + const deadline = Date.now() + timeoutMs; + while (pending.size > 0 && Date.now() < deadline) { + const snapshot = Array.from(pending); + const remaining = Math.max(0, deadline - Date.now()); + await Promise.race([ + Promise.allSettled(snapshot), + new Promise((resolve) => { + setTimeout(resolve, remaining); + }), + ]); + } + }, + pendingCount() { + return pending.size; + }, dispose() { page.off("response", responseHandler); }, @@ -803,6 +933,18 @@ function collectSourceBucket(input: unknown, bucket: MonitoringSourceItem[]): vo } } +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, @@ -869,6 +1011,14 @@ function walkYuanbaoPayload( } } + 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); } @@ -1325,6 +1475,67 @@ const yuanbaoQueryInPage = async ( 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 readToggleStateByPattern = (pattern: RegExp, shell: HTMLElement | null): boolean | null => { + const target = findInteractiveByText(pattern, shell); + return target ? toggleState(target) : null; + }; + + const ensureToggleEnabledByPattern = async ( + pattern: RegExp, + shell: HTMLElement | null, + ): Promise => { + 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 => { + const directState = await ensureToggleEnabledByPattern(webSearchPattern, shell); + if (directState === true) { + return true; + } + + const toolsButton = findInteractiveByText(toolsPattern, shell); + if (!toolsButton) { + return directState; + } + + await clickInteractive(toolsButton, 220); + const menuItem = findInteractiveByText(webSearchPattern, null); + if (!menuItem) { + return directState; + } + + let menuState = toggleState(menuItem); + if (menuState !== true) { + await clickInteractive(menuItem, 220); + menuState = toggleState(menuItem); + } + + return menuState ?? true; + }; + const setComposerText = (composer: HTMLElement, text: string) => { composer.focus(); @@ -1473,6 +1684,8 @@ const yuanbaoQueryInPage = async ( "data-jump-url", "data-doc-url", "data-article-url", + "data-real-url", + "dt-ext6", ]; const extractUrls = (value: string | null): string[] => { const normalized = normalize(value); @@ -1482,14 +1695,15 @@ const yuanbaoQueryInPage = async ( const urls = new Set(); const pushCandidate = (candidate: string) => { + const cleaned = candidate.replace(/&/g, "&"); try { - const decoded = decodeURIComponent(candidate); + const decoded = decodeURIComponent(cleaned); if (/^https?:\/\//i.test(decoded)) { urls.add(decoded); } } catch { - if (/^https?:\/\//i.test(candidate)) { - urls.add(candidate); + if (/^https?:\/\//i.test(cleaned)) { + urls.add(cleaned); } } }; @@ -1510,6 +1724,10 @@ const yuanbaoQueryInPage = async ( continue; } + const sourceScope = ( + node.closest(".agent-dialogue-references__item, .hyc-common-markdown__ref_card") + ?? node + ) as HTMLElement; const urls = new Set(); if (node instanceof HTMLAnchorElement) { for (const url of extractUrls(node.href)) { @@ -1523,29 +1741,239 @@ const yuanbaoQueryInPage = async ( } for (const url of urls) { - if (seen.has(url)) { + 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(url); + seen.add(seenKey); links.push({ url, - title: normalize(node.getAttribute("title")) ?? normalize(node.getAttribute("aria-label")), + 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(); + 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) => `[citation:${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 => { + 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 => { + 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 => { + 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 => { + const deadline = Date.now() + timeoutMs; + while (Date.now() <= deadline) { + if (hasVisibleReferenceCards()) { + return; + } + await wait(150); + } + }; + + const revealLatestReferenceSources = async (composerTop: number | null): Promise => { + 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) { @@ -1553,6 +1981,15 @@ const yuanbaoQueryInPage = async ( 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; @@ -1589,11 +2026,33 @@ const yuanbaoQueryInPage = async ( candidates.sort((left, right) => right.score - left.score); const best = candidates[0]?.element ?? null; - const text = best ? normalize(best.innerText) : null; - const links = best ? collectLinks(best) : []; + const text = best ? textWithInlineCitationMarkers(best) : null; + const linkMap = new Map(); + 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, signature }; + return { text, links, inlineCitationIndexes, signature }; }; const resolveModelLabel = (): string | null => { @@ -1616,14 +2075,16 @@ const yuanbaoQueryInPage = async ( 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 deepThinkButton = findInteractiveByText(/深度思考/, findComposerShell(findComposer())); - const webSearchButton = findInteractiveByText(/联网搜索/, findComposerShell(findComposer())); + const currentComposerShell = findComposerShell(findComposer()); return { ok: false, error, @@ -1631,10 +2092,11 @@ const yuanbaoQueryInPage = async ( url: window.location.href || null, title: normalize(document.title), modelLabel: resolveModelLabel(), - deepThinkEnabled: toggleState(deepThinkButton), - webSearchEnabled: toggleState(webSearchButton), + deepThinkEnabled: readToggleStateByPattern(deepThinkPattern, currentComposerShell) ?? lastKnownDeepThinkEnabled, + webSearchEnabled: readToggleStateByPattern(webSearchPattern, currentComposerShell) ?? lastKnownWebSearchEnabled, domAnswer: snapshot.text, domLinks: snapshot.links, + inlineCitationIndexes: snapshot.inlineCitationIndexes, }; }; @@ -1657,19 +2119,11 @@ const yuanbaoQueryInPage = async ( const composerTop = composer.getBoundingClientRect().top; if (enableDeepThink) { - const deepThinkButton = findInteractiveByText(/深度思考/, composerShell); - if (deepThinkButton && toggleState(deepThinkButton) !== true) { - deepThinkButton.click(); - await wait(160); - } + lastKnownDeepThinkEnabled = await ensureToggleEnabledByPattern(deepThinkPattern, composerShell); } if (enableWebSearch) { - const webSearchButton = findInteractiveByText(/联网搜索/, composerShell); - if (webSearchButton && toggleState(webSearchButton) !== true) { - webSearchButton.click(); - await wait(160); - } + lastKnownWebSearchEnabled = await ensureWebSearchEnabled(composerShell); } setComposerText(composer, questionText); @@ -1709,17 +2163,24 @@ const yuanbaoQueryInPage = async ( } if (sawChange && !hasBusyIndicator() && stableCount >= 2) { - const deepThinkButton = findInteractiveByText(/深度思考/, composerShell); - const webSearchButton = findInteractiveByText(/联网搜索/, composerShell); + 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: toggleState(deepThinkButton), - webSearchEnabled: toggleState(webSearchButton), - domAnswer: snapshot.text, - domLinks: snapshot.links, + 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, }; } @@ -1760,35 +2221,48 @@ export const yuanbaoAdapter: MonitorAdapter = { 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 domCitations = dedupeSourceItems( - pageResult.domLinks - .map((item) => buildSourceItem(item)) - .filter((item): item is MonitoringSourceItem => item !== null), - ); + const pendingCaptureCount = capture.pendingCount(); + const domCitations = buildOrderedDomSourceItems(pageResult.domLinks); const searchResults = dedupeSourceItems(parsed.searchResults); - const answerWithMarkers = selectBestAnswerText(parsed.answer, pageResult.domAnswer); + let answerWithMarkers = selectBestAnswerText(parsed.answer, pageResult.domAnswer); + if ( + pageResult.inlineCitationIndexes.length > 0 + && extractCitationMarkerIndexes(answerWithMarkers).length === 0 + && extractCitationMarkerIndexes(pageResult.domAnswer).length > 0 + ) { + answerWithMarkers = pageResult.domAnswer; + } const citationMarkerIndexes = extractCitationMarkerIndexes(answerWithMarkers ?? pageResult.domAnswer); + const maxCitationMarkerIndex = citationMarkerIndexes.length ? Math.max(...citationMarkerIndexes) : 0; const markerCitations = dedupeSourceItems(resolveCitationsFromMarkers( answerWithMarkers ?? pageResult.domAnswer, [ + domCitations, parsed.citations, parsed.searchResults, - domCitations, - dedupeSourceItems([...parsed.citations, ...parsed.searchResults, ...domCitations]), + dedupeSourceItems([...domCitations, ...parsed.citations, ...parsed.searchResults]), ], )); const answer = answerWithMarkers; - const citations = dedupeSourceItems([...parsed.citations, ...markerCitations, ...domCitations]); + const citations = 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 maxCitationMarkerIndex = citationMarkerIndexes.length ? Math.max(...citationMarkerIndexes) : 0; + const unresolvedCitationIndexes = findUnresolvedCitationIndexes(answer, citations); if (pageResult.ok === false) { const failedPageResult = pageResult; @@ -1836,7 +2310,7 @@ export const yuanbaoAdapter: MonitorAdapter = { }; } - if (answer && maxCitationMarkerIndex > 0 && citations.length < maxCitationMarkerIndex) { + if (answer && unresolvedCitationIndexes.length > 0) { return { status: "unknown", summary: "元宝回答含引用标记但未拿到完整引用来源,已回写 unknown 等待后续补采。", @@ -1846,6 +2320,7 @@ export const yuanbaoAdapter: MonitorAdapter = { { 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, @@ -1881,9 +2356,14 @@ export const yuanbaoAdapter: MonitorAdapter = { web_search_enabled: pageResult.webSearchEnabled, 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), }, }, }; @@ -1891,11 +2371,16 @@ export const yuanbaoAdapter: MonitorAdapter = { }; export const __yuanbaoTestUtils = { + appendUniqueSourceItems, + buildOrderedDomSourceItems, buildSourceItem, dedupeSourceItems, extractCitationMarkerIndexes, + extractQuestionText, + findUnresolvedCitationIndexes, mergeText, normalizeSourceUrl, + parseYuanbaoCaptures, resolveCitationsFromMarkers, selectBestAnswerText, };