Files
geo/apps/desktop-client/src/main/adapters/yuanbao.ts
T

1727 lines
50 KiB
TypeScript
Raw Normal View History

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,
},
},
};
},
};