Files
geo/apps/desktop-client/src/main/adapters/yuanbao.ts
T
root f91d2645d3 feat(desktop): add Kimi and Yuanbao monitoring adapters
- implement kimi/yuanbao monitor adapters with dedicated citation panel detection
- detect Kimi session via kimi-auth cookie during account binding
- harden hidden Playwright and bound windows with skipTaskbar/focusable/hiddenInMissionControl to stop stealing focus
- tag hidden bootstrap windows with a unique token so CDP retention resolves the correct page
- enable yuanbao/kimi/wenxin platforms in dev-seed and default monitoring quota
- update kimi loginUrl to www.kimi.com

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 09:09:20 +08:00

1727 lines
50 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { JsonValue, MonitoringSourceItem } from "@geo/shared-types";
import type { Page as PlaywrightPage, Response as PlaywrightResponse } from "playwright-core";
import { normalizeText } from "./common";
import type { MonitorAdapter } from "./base";
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_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;
};
type YuanbaoPageQuerySuccessResult = {
ok: true;
url: string;
title: string | null;
modelLabel: string | null;
deepThinkEnabled: boolean | null;
webSearchEnabled: boolean | null;
domAnswer: string | null;
domLinks: YuanbaoDomLink[];
};
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[];
};
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;
}
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+/).map((line) => line.trim()).filter(Boolean);
if (lines.length <= 1) {
return collapsed;
}
const keptLines = lines.filter((line) => !looksLikeNonAnswerNoise(line));
if (!keptLines.length) {
return collapsed;
}
const cleaned = keptLines.join("\n");
return answerQualityScore(cleaned) >= answerQualityScore(collapsed) ? cleaned : 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;
}
if (parsed.includes(dom) && answerQualityScore(dom) >= answerQualityScore(parsed) - 24) {
return dom;
}
return answerQualityScore(dom) > answerQualityScore(parsed) ? dom : 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 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;
}
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 normalizeSourceUrl(value: string | null): string | null {
const input = normalizeText(value);
if (!input) {
return null;
}
try {
const url = new URL(input);
url.hash = "";
return url.toString();
} catch {
return input;
}
}
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",
]),
);
if (!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 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 extractQuestionText(payload: Record<string, unknown>): string {
const candidates = [
payload.question_text,
payload.questionText,
payload.query,
payload.question,
payload.prompt,
payload.content,
payload.title,
];
for (const candidate of candidates) {
const text = normalizeOptionalString(candidate);
if (text) {
return text;
}
}
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 attachYuanbaoResponseCapture(page: PlaywrightPage): {
captures: YuanbaoCaptureRecord[];
dispose: () => void;
} {
const captures: YuanbaoCaptureRecord[] = [];
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 || !url.startsWith("https://yuanbao.tencent.com/")) {
return;
}
const headers = response.headers();
const contentType = normalizeOptionalString(headers["content-type"]) ?? null;
const shouldRead =
url.includes("/api/")
|| response.status() >= 400
|| (contentType !== null && /(json|event-stream|text\/plain)/i.test(contentType));
if (!shouldRead) {
return;
}
void response.body()
.then((body) => {
const normalizedBody = normalizeOptionalString(decodeResponseBody(body)) ?? "";
pushCapture({
url,
status: response.status(),
contentType,
body: normalizedBody,
});
})
.catch(() => undefined);
};
page.on("response", responseHandler);
return {
captures,
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 walkYuanbaoPayload(
value: unknown,
summary: YuanbaoSummary,
depth = 0,
): void {
if (depth > 12 || value == null) {
return;
}
if (Array.isArray(value)) {
for (const item of value) {
walkYuanbaoPayload(item, summary, depth + 1);
}
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":
summary.answer = mergeText(summary.answer, getDirectString(value, ["msg", "text", "content"]));
break;
case "think":
summary.reasoning = mergeText(summary.reasoning, getDirectString(value, ["content", "msg", "text"]));
break;
case "searchGuid":
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);
break;
case "step":
collectSourceBucket(value.thinkDeepSections, summary.citations);
break;
default:
break;
}
}
if (Array.isArray(value.content)) {
walkYuanbaoPayload(value.content, summary, depth + 1);
}
if (Array.isArray(value.contents)) {
walkYuanbaoPayload(value.contents, summary, depth + 1);
}
if (Array.isArray(value.speechesV2)) {
walkYuanbaoPayload(value.speechesV2, summary, depth + 1);
}
if (Array.isArray(value.convs)) {
walkYuanbaoPayload(value.convs, summary, depth + 1);
}
if (Array.isArray(value.docs)) {
walkYuanbaoPayload(value.docs, summary, depth + 1);
}
if (Array.isArray(value.citations)) {
walkYuanbaoPayload(value.citations, summary, depth + 1);
}
if (Array.isArray(value.ref_docs)) {
walkYuanbaoPayload(value.ref_docs, summary, depth + 1);
}
if (Array.isArray(value.thinkDeepSections)) {
walkYuanbaoPayload(value.thinkDeepSections, summary, depth + 1);
}
if (Array.isArray(value.list)) {
walkYuanbaoPayload(value.list, summary, depth + 1);
}
for (const nested of Object.values(value)) {
if (Array.isArray(nested) || isRecord(nested)) {
walkYuanbaoPayload(nested, summary, depth + 1);
}
}
}
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);
}
}
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 }> = [];
for (const scope of scopes) {
const nodes = Array.from(scope.querySelectorAll("button, [role='button'], label, [tabindex]"));
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 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>();
for (const node of Array.from(scope.querySelectorAll("a[href]"))) {
if (!(node instanceof HTMLAnchorElement) || !isVisible(node)) {
continue;
}
const href = normalize(node.href);
if (!href || !/^https?:\/\//i.test(href) || seen.has(href)) {
continue;
}
seen.add(href);
links.push({
url: href,
title: normalize(node.title) ?? normalize(node.getAttribute("aria-label")),
text:
normalize(node.innerText)
?? normalize(node.textContent)
?? normalize(node.getAttribute("aria-label")),
});
}
return links;
};
const snapshotConversation = (composerTop: number | null): {
text: string | null;
links: YuanbaoDomLink[];
signature: string;
} => {
const candidates: Array<{ element: HTMLElement; score: number }> = [];
const elements = Array.from(document.querySelectorAll("article, section, div, main"));
for (const node of elements) {
if (!(node instanceof HTMLElement) || !isVisible(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 ? normalize(best.innerText) : null;
const links = best ? collectLinks(best) : [];
const signature = `${text ?? ""}|${links.map((item) => item.url).join("|")}`.slice(-1_500);
return { text, links, 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);
};
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()));
return {
ok: false,
error,
detail: summarizeFailureDetail(detail) ?? summarizeFailureDetail(snapshot.text) ?? summarizeFailureDetail(bodyText()),
url: window.location.href || null,
title: normalize(document.title),
modelLabel: resolveModelLabel(),
deepThinkEnabled: toggleState(deepThinkButton),
webSearchEnabled: toggleState(webSearchButton),
domAnswer: snapshot.text,
domLinks: snapshot.links,
};
};
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) {
const deepThinkButton = findInteractiveByText(/深度思考/, composerShell);
if (deepThinkButton && toggleState(deepThinkButton) !== true) {
deepThinkButton.click();
await wait(160);
}
}
if (enableWebSearch) {
const webSearchButton = findInteractiveByText(/联网搜索/, composerShell);
if (webSearchButton && toggleState(webSearchButton) !== true) {
webSearchButton.click();
await wait(160);
}
}
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) {
const deepThinkButton = findInteractiveByText(/深度思考/, composerShell);
const webSearchButton = findInteractiveByText(/联网搜索/, composerShell);
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,
};
}
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);
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);
} 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 searchResults = dedupeSourceItems(parsed.searchResults);
const answerWithMarkers = selectBestAnswerText(parsed.answer, pageResult.domAnswer);
const citationMarkerIndexes = extractCitationMarkerIndexes(answerWithMarkers ?? pageResult.domAnswer);
const markerCitations = dedupeSourceItems(resolveCitationsFromMarkers(
answerWithMarkers ?? pageResult.domAnswer,
[
parsed.citations,
parsed.searchResults,
domCitations,
dedupeSourceItems([...parsed.citations, ...parsed.searchResults, ...domCitations]),
],
));
const answer = answerWithMarkers;
const citations = dedupeSourceItems([...parsed.citations, ...markerCitations, ...domCitations]);
const reasoning = repairPotentialMojibake(normalizeText(parsed.reasoning));
const providerModel = resolveProviderModel(pageResult, parsed);
const conversationID = parsed.conversationID ?? extractConversationID(pageResult.url);
const requestID = parsed.requestID ?? conversationID;
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"),
};
}
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,
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,
candidate_count: parsed.candidateCount,
capture_count: capture.captures.length,
content_types: Array.from(parsed.contentTypes.values()),
citation_marker_count: citationMarkerIndexes.length,
citation_marker_indexes: citationMarkerIndexes,
},
},
};
},
};