feat(desktop-client/yuanbao): capture richer DOM citation sources

Raise the response capture body limit, parse siteName and source
indexes from yuanbao DOM links, merge ordered DOM sources with the
streamed list, and reject runtime/build-diagnostic strings from being
treated as a question.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-30 17:12:18 +08:00
parent fb166fa9fe
commit e5f6e93cea
2 changed files with 619 additions and 44 deletions
@@ -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"]);
});
});
+529 -44
View File
@@ -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<number, number>([
[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<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,
@@ -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, unknown>): string {
const candidates = [
payload.question_text,
@@ -617,13 +715,22 @@ function extractQuestionText(payload: Record<string, unknown>): 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<void>;
pendingCount: () => number;
dispose: () => void;
} {
const captures: YuanbaoCaptureRecord[] = [];
const pending = new Set<Promise<void>>();
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<void>((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<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> => {
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<string>();
const pushCandidate = (candidate: string) => {
const cleaned = candidate.replace(/&amp;/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<string>();
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<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) => `[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<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) {
@@ -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<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, 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,
};