From f91d2645d3bd839704852b2728c62cd5cfa8b0ff Mon Sep 17 00:00:00 2001 From: liangxu Date: Thu, 23 Apr 2026 09:09:20 +0800 Subject: [PATCH] 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) --- .../desktop-client/src/main/account-binder.ts | 88 +- .../desktop-client/src/main/adapters/index.ts | 2 + apps/desktop-client/src/main/adapters/kimi.ts | 2109 +++++++++++++++++ .../src/main/adapters/yuanbao.ts | 1726 ++++++++++++++ apps/desktop-client/src/main/bootstrap.ts | 20 + .../desktop-client/src/main/playwright-cdp.ts | 86 +- .../src/main/runtime-controller.ts | 8 + packages/shared-types/src/ai-platforms.ts | 4 +- server/cmd/dev-seed/main.go | 2 +- ...0410103000_create_monitoring_tables.up.sql | 2 +- 10 files changed, 4035 insertions(+), 12 deletions(-) create mode 100644 apps/desktop-client/src/main/adapters/kimi.ts create mode 100644 apps/desktop-client/src/main/adapters/yuanbao.ts diff --git a/apps/desktop-client/src/main/account-binder.ts b/apps/desktop-client/src/main/account-binder.ts index 6bfada3..a288aaa 100644 --- a/apps/desktop-client/src/main/account-binder.ts +++ b/apps/desktop-client/src/main/account-binder.ts @@ -1163,6 +1163,32 @@ async function detectQwenFromSession( }); } +async function detectKimiFromSession( + session: Session, + hints: { + displayName?: string | null; + avatarUrl?: string | null; + } = {}, +): Promise { + const cookies = await session.cookies.get({ url: "https://www.kimi.com/" }).catch(() => []); + if (!cookies.length) { + return null; + } + + const authCookie = cookies.find((cookie) => + cookie.name === "kimi-auth" && isLikelyGenericAICredentialValue(cookie.value), + ); + if (!authCookie) { + return null; + } + + return sanitizeDetectedAccount({ + platformUid: boundedIdentity("kimi-auth", authCookie.value), + displayName: hints.displayName ?? "Kimi 会话", + avatarUrl: hints.avatarUrl ?? null, + }); +} + async function detectQwenPlatform( platformId: string, label: string, @@ -1215,6 +1241,58 @@ async function detectQwenPlatform( }); } +async function detectKimiPlatform( + platformId: string, + label: string, + context: DetectContext, +): Promise { + const genericDetected = await detectGenericAIPlatform(platformId, label, context); + if (genericDetected) { + return genericDetected; + } + + if (!context.webContents || context.webContents.isDestroyed()) { + return null; + } + + const currentURL = context.webContents.getURL(); + const platformMeta = aiPlatformCatalog.find((item) => item.id === platformId) ?? null; + if (!platformMeta) { + return null; + } + + if (currentURL && looksLikeLoginRedirect(currentURL, platformMeta.loginUrl)) { + return null; + } + + const pageState = await readGenericAIPageState(context.webContents).catch(() => null); + if ((pageState?.challengeSignalCount ?? 0) > 0) { + return null; + } + if ((pageState?.loggedOutSignalCount ?? 0) > 0) { + return null; + } + + const fromSession = await detectKimiFromSession(context.session, { + displayName: pageState?.displayName ?? `${label} 会话`, + avatarUrl: pageState?.avatarUrl ?? null, + }); + if (fromSession) { + return fromSession; + } + + const fingerprint = await buildGenericAISessionFingerprint(platformId, context.session, pageState); + if (!fingerprint) { + return null; + } + + return sanitizeDetectedAccount({ + platformUid: fingerprint, + displayName: pageState?.displayName ?? `${label} 会话`, + avatarUrl: pageState?.avatarUrl ?? null, + }); +} + async function detectAIPlatformAccount( platformId: string, label: string, @@ -1224,6 +1302,10 @@ async function detectAIPlatformAccount( return await detectQwenPlatform(platformId, label, context); } + if (platformId === "kimi") { + return await detectKimiPlatform(platformId, label, context); + } + return await detectGenericAIPlatform(platformId, label, context); } @@ -2446,14 +2528,18 @@ function createBoundWindow( sessionHandle: Session, options: { show?: boolean } = {}, ): BrowserWindow { + const show = options.show ?? true; const window = new BrowserWindow({ - show: options.show ?? true, + show, width: 1320, height: 900, minWidth: 1100, minHeight: 760, title, autoHideMenuBar: true, + skipTaskbar: !show, + focusable: show, + hiddenInMissionControl: !show, backgroundColor: nativeTheme.shouldUseDarkColors ? "#15191a" : "#f4f1ea", webPreferences: { session: sessionHandle, diff --git a/apps/desktop-client/src/main/adapters/index.ts b/apps/desktop-client/src/main/adapters/index.ts index 294c8f0..b8bce7f 100644 --- a/apps/desktop-client/src/main/adapters/index.ts +++ b/apps/desktop-client/src/main/adapters/index.ts @@ -1,5 +1,7 @@ export * from "./base"; export * from "./doubao"; +export * from "./kimi"; export * from "./qwen"; export * from "./toutiao"; +export * from "./yuanbao"; export * from "./zhihu"; diff --git a/apps/desktop-client/src/main/adapters/kimi.ts b/apps/desktop-client/src/main/adapters/kimi.ts new file mode 100644 index 0000000..be2b0ce --- /dev/null +++ b/apps/desktop-client/src/main/adapters/kimi.ts @@ -0,0 +1,2109 @@ +import type { JsonValue, MonitoringSourceItem } from "@geo/shared-types"; +import type { Page as PlaywrightPage } from "playwright-core"; + +import { normalizeText } from "./common"; +import type { MonitorAdapter } from "./base"; + +const KIMI_BOOTSTRAP_URL = "https://www.kimi.com/"; +const KIMI_PAGE_READY_TIMEOUT_MS = 20_000; +const KIMI_MODE_SWITCH_TIMEOUT_MS = 8_000; +const KIMI_QUERY_TIMEOUT_MS = 120_000; +const KIMI_QUERY_POLL_INTERVAL_MS = 1_200; +const KIMI_STABLE_POLLS_REQUIRED = 5; +const KIMI_EDITOR_SELECTOR = "div[role=\"textbox\"].chat-input-editor, .chat-input-editor[role=\"textbox\"], [role=\"textbox\"][class*=\"chat-input-editor\"]"; +const KIMI_SEND_BUTTON_SELECTOR = ".send-button-container"; +const KIMI_THINKING_MODE_LABEL = "K2.6 思考"; + +type KimiDomLink = { + url: string; + title: string | null; + text: string | null; + siteName: string | null; +}; + +type KimiPageSnapshot = { + url: string; + title: string | null; + currentModelLabel: string | null; + loginRequired: boolean; + loginReason: string | null; + busy: boolean; + busySignals: string[]; + sendDisabled: boolean | null; + answerText: string | null; + answerBlocks: string[]; + reasoningText: string | null; + reasoningBlocks: string[]; + questionMatched: boolean; + candidateAnswerCount: number; + candidateReasoningCount: number; + citationLinks: KimiDomLink[]; + panelCitationLinks: KimiDomLink[]; + inlineCitationCandidateLinks: KimiDomLink[]; + searchResultLinks: KimiDomLink[]; + signature: string | null; +}; + +type KimiModeSwitchResult = + | { + ok: true; + currentModelLabel: string | null; + } + | { + ok: false; + error: string; + detail: string | null; + currentModelLabel: string | null; + url: string | null; + }; + +type KimiQueryWaitResult = + | { + ok: true; + finalSnapshot: KimiPageSnapshot; + stablePollCount: number; + elapsedMs: number; + } + | { + ok: false; + error: string; + detail: string | null; + finalSnapshot: KimiPageSnapshot; + stablePollCount: number; + elapsedMs: number; + }; + +function isRecord(value: unknown): value is Record { + 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 normalizeUrl(value: unknown): 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(source: KimiDomLink): MonitoringSourceItem | null { + const url = normalizeUrl(source.url); + if (!url) { + return null; + } + + let host: string | null = null; + try { + host = new URL(url).hostname || null; + } catch { + host = null; + } + + return { + url, + title: normalizeText(source.title ?? source.text ?? source.siteName), + site_name: normalizeText(source.siteName), + normalized_url: url, + host, + }; +} + +function dedupeSourceItems(items: MonitoringSourceItem[]): MonitoringSourceItem[] { + const keyed = new Map(); + for (const item of items) { + const key = normalizeUrl(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, + host: existing.host ?? item.host ?? null, + }); + } + + return Array.from(keyed.values()); +} + +function toJsonValue(value: unknown, depth = 0): JsonValue { + if (value == null || depth > 12) { + return null; + } + + if (typeof value === "string" || typeof value === "boolean") { + return value; + } + + if (typeof value === "number") { + return Number.isFinite(value) ? value : null; + } + + if (Array.isArray(value)) { + return value.map((item) => toJsonValue(item, depth + 1)); + } + + if (!isRecord(value)) { + return null; + } + + const result: Record = {}; + for (const [key, item] of Object.entries(value)) { + result[key] = toJsonValue(item, depth + 1); + } + return result; +} + +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 = {}, +): Record { + return { + code, + message, + ...extras, + }; +} + +function extractQuestionText(payload: Record): 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("kimi_question_text_missing"); +} + +function safePageURL(page: PlaywrightPage): string { + try { + return page.url(); + } catch { + return ""; + } +} + +async function sleep(ms: number, signal?: AbortSignal): Promise { + if (!signal) { + await new Promise((resolve) => { + setTimeout(resolve, ms); + }); + return; + } + + if (signal.aborted) { + throw new Error("adapter_aborted"); + } + + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + signal.removeEventListener("abort", onAbort); + resolve(); + }, ms); + + const onAbort = () => { + clearTimeout(timer); + signal.removeEventListener("abort", onAbort); + reject(new Error("adapter_aborted")); + }; + + signal.addEventListener("abort", onAbort, { once: true }); + }); +} + +async function ensurePageOnKimiChat(page: PlaywrightPage, signal: AbortSignal): Promise { + if (signal.aborted) { + throw new Error("adapter_aborted"); + } + + const currentURL = safePageURL(page); + if (!currentURL.startsWith("https://www.kimi.com/")) { + await page.goto(KIMI_BOOTSTRAP_URL, { + waitUntil: "domcontentloaded", + timeout: KIMI_PAGE_READY_TIMEOUT_MS, + }); + } + + await page.waitForLoadState("domcontentloaded", { + timeout: KIMI_PAGE_READY_TIMEOUT_MS, + }).catch(() => undefined); + await page.waitForLoadState("networkidle", { + timeout: 3_000, + }).catch(() => undefined); + + if (signal.aborted) { + throw new Error("adapter_aborted"); + } +} + +async function pinKimiConversationToBottom(page: PlaywrightPage): Promise { + await page.evaluate(() => { + const scrollers = new Set(); + const maybeTrack = (value: Element | null) => { + if (!(value instanceof HTMLElement)) { + return; + } + if (value.scrollHeight - value.clientHeight < 48) { + return; + } + scrollers.add(value); + }; + + maybeTrack(document.scrollingElement as HTMLElement | null); + maybeTrack(document.documentElement); + maybeTrack(document.body); + + for (const selector of [ + "main", + "section", + "article", + "[class*=\"scroll\"]", + "[class*=\"conversation\"]", + "[class*=\"message\"]", + "[class*=\"chat\"]", + "[class*=\"list\"]", + "[class*=\"content\"]", + ]) { + for (const element of Array.from(document.querySelectorAll(selector))) { + maybeTrack(element); + } + } + + for (const scroller of scrollers) { + scroller.scrollTop = scroller.scrollHeight; + } + + window.scrollTo(0, Math.max( + document.body?.scrollHeight ?? 0, + document.documentElement?.scrollHeight ?? 0, + )); + }).catch(() => undefined); +} + +async function ensureKimiCitationPanelOpen(page: PlaywrightPage, signal?: AbortSignal): Promise { + for (let attempt = 0; attempt < 4; attempt += 1) { + await pinKimiConversationToBottom(page); + + const locatorVisibleCount = await page.locator( + ".side-console-container .ref .sites a.site-item[href], .side-console .ref .sites a.site-item[href]", + ).count().catch(() => 0); + if (locatorVisibleCount > 0) { + return; + } + + const refAction = page.locator(".chat-content-item-assistant .ref-action, .segment-assistant-actions .ref-action").last(); + if (await refAction.isVisible().catch(() => false)) { + const clicked = await refAction.click({ force: true }).then(() => true).catch(() => false); + if (clicked) { + await sleep(250, signal).catch(() => undefined); + } + } + + const state = await page.evaluate(() => { + 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 visibleCitationCount = Array.from( + document.querySelectorAll(".side-console-container .ref .sites a.site-item[href], .side-console .ref .sites a.site-item[href]"), + ).filter((node) => isVisible(node)).length; + if (visibleCitationCount > 0) { + return { + ready: true, + visibleCitationCount, + }; + } + + const refActions = Array.from(document.querySelectorAll(".chat-content-item-assistant .ref-action, .segment-assistant-actions .ref-action")); + const target = refActions.at(-1); + if (target instanceof HTMLElement) { + target.click(); + return { + ready: false, + visibleCitationCount: 0, + }; + } + + return { + ready: false, + visibleCitationCount: 0, + }; + }).catch(() => ({ + ready: false, + visibleCitationCount: 0, + })); + + if (state.ready) { + return; + } + + await sleep(250, signal).catch(() => undefined); + } +} + +function answerQualityScore(snapshot: KimiPageSnapshot | null): number { + if (!snapshot) { + return Number.NEGATIVE_INFINITY; + } + + const answer = normalizeOptionalString(snapshot.answerText); + const reasoning = normalizeOptionalString(snapshot.reasoningText); + let score = 0; + if (answer) { + score += Math.min(answer.length, 4_000); + } + if (reasoning) { + score += Math.min(reasoning.length, 2_000) / 2; + } + score += snapshot.answerBlocks.length * 240; + score += snapshot.reasoningBlocks.length * 80; + score += snapshot.citationLinks.length * 180; + score += Math.min(snapshot.searchResultLinks.length, 80) * 18; + if (snapshot.currentModelLabel?.includes("思考")) { + score += 120; + } + if (snapshot.questionMatched) { + score += 600; + } + if (snapshot.busy) { + score -= 120; + } + if (snapshot.loginRequired) { + score -= 10_000; + } + return score; +} + +function pickBetterSnapshot(current: KimiPageSnapshot | null, candidate: KimiPageSnapshot): KimiPageSnapshot { + if (!current) { + return candidate; + } + + return answerQualityScore(candidate) >= answerQualityScore(current) ? candidate : current; +} + +function extractConversationID(url: string | null): string | null { + const input = normalizeText(url); + if (!input) { + return null; + } + + try { + const parsed = new URL(input); + const chatMatch = parsed.pathname.match(/\/chat\/([^/?#]+)/i); + if (chatMatch?.[1]?.trim()) { + return chatMatch[1].trim(); + } + return normalizeText(parsed.searchParams.get("conversation_id") ?? parsed.searchParams.get("session_id")); + } catch { + return null; + } +} + +async function fillKimiEditorText(page: PlaywrightPage, questionText: string): Promise { + const editor = page.locator(KIMI_EDITOR_SELECTOR).first(); + await editor.waitFor({ + state: "visible", + timeout: KIMI_PAGE_READY_TIMEOUT_MS, + }); + + await editor.scrollIntoViewIfNeeded().catch(() => undefined); + await editor.evaluate((node) => { + if (node instanceof HTMLElement) { + node.focus(); + } + }).catch(() => undefined); + let filled = false; + try { + await editor.fill(questionText); + filled = true; + } catch { + filled = false; + } + + const normalizedExpected = questionText.trim().replace(/\s+/g, " "); + const normalizedActual = (await editor.innerText().catch(() => "")).trim().replace(/\s+/g, " "); + if (!filled || normalizedActual !== normalizedExpected) { + await page.evaluate( + ({ selector, text }) => { + const editorElement = document.querySelector(selector); + if (!(editorElement instanceof HTMLElement)) { + throw new Error("kimi_editor_missing"); + } + + const selection = window.getSelection(); + const range = document.createRange(); + range.selectNodeContents(editorElement); + selection?.removeAllRanges(); + selection?.addRange(range); + + editorElement.focus(); + try { + document.execCommand("delete"); + document.execCommand("insertText", false, text); + } catch { + // Ignore and fall back to direct DOM mutation below. + } + + const current = editorElement.innerText.trim().replace(/\s+/g, " "); + if (current !== text.trim().replace(/\s+/g, " ")) { + editorElement.innerHTML = ""; + editorElement.textContent = text; + editorElement.dispatchEvent(new InputEvent("input", { + bubbles: true, + inputType: "insertText", + data: text, + })); + editorElement.dispatchEvent(new Event("change", { bubbles: true })); + } + }, + { + selector: KIMI_EDITOR_SELECTOR, + text: questionText, + }, + ); + } +} + +async function submitKimiQuestion(page: PlaywrightPage, questionText: string, signal: AbortSignal): Promise { + if (signal.aborted) { + throw new Error("adapter_aborted"); + } + + await fillKimiEditorText(page, questionText); + await sleep(200, signal); + + const enabledSendButton = page.locator(`${KIMI_SEND_BUTTON_SELECTOR}:not(.disabled)`).first(); + if (await enabledSendButton.isVisible().catch(() => false)) { + const clicked = await enabledSendButton.evaluate((node) => { + if (!(node instanceof HTMLElement)) { + return false; + } + node.focus?.(); + node.click(); + return true; + }).catch(() => false); + if (clicked) { + return; + } + + const forceClicked = await enabledSendButton.click({ force: true }).then(() => true).catch(() => false); + if (forceClicked) { + return; + } + } + + const editor = page.locator(KIMI_EDITOR_SELECTOR).first(); + await editor.press("Enter").catch(() => undefined); +} + +async function ensureKimiThinkingMode(page: PlaywrightPage): Promise { + let result = await page.evaluate( + async ({ preferredModelLabel, timeoutMs }) => { + const wait = (timeMs: number) => + new Promise((resolve) => { + window.setTimeout(resolve, timeMs); + }); + + const normalize = (value: unknown): string | null => { + if (typeof value !== "string") { + return null; + } + const trimmed = value + .replace(/\u00a0/g, " ") + .split(/\n+/) + .map((line) => line.trim()) + .filter(Boolean) + .join(" "); + 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 clickElement = (element: HTMLElement | null): boolean => { + if (!element) { + return false; + } + element.focus?.(); + element.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })); + element.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true })); + element.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, cancelable: true })); + element.click(); + return true; + }; + + const loginSignals = [ + "微信扫码登录", + "手机号快捷登录", + "发送验证码", + "登录后", + "有问题,免费聊", + ]; + + const buildDetail = () => { + const bodyText = normalize(document.body?.innerText)?.slice(0, 200) ?? null; + return [normalize(document.title), normalize(window.location.href), bodyText].filter(Boolean).join(" | ") || null; + }; + + const readModelLabel = () => + normalize( + document.querySelector(".current-model .name") + ?.textContent + ?? document.querySelector(".current-model .model-name")?.textContent + ?? document.querySelector(".current-model")?.textContent, + ); + + const loginReason = () => { + const bodyText = normalize(document.body?.innerText) ?? ""; + for (const signal of loginSignals) { + if (bodyText.includes(signal)) { + return signal; + } + } + return null; + }; + + const findTrigger = () => { + const selectors = [".current-model", ".model-name", ".current-model .name"]; + for (const selector of selectors) { + const element = document.querySelector(selector); + if (isVisible(element)) { + return element; + } + } + return null; + }; + + const findThinkingOption = () => { + const candidates = Array.from(document.querySelectorAll("button, [role=\"button\"], [role=\"menuitem\"], li, div, span")); + let best: { element: HTMLElement; score: number } | null = null; + + for (const candidate of candidates) { + if (!isVisible(candidate)) { + continue; + } + + const text = normalize(candidate.textContent)?.replace(/\s+/g, " ") ?? ""; + if (!text) { + continue; + } + + let score = Number.NEGATIVE_INFINITY; + if (text === preferredModelLabel) { + score = 10_000; + } else if (text.includes(preferredModelLabel)) { + score = 8_000; + } else if (text.includes("思考")) { + score = 3_000; + } + + if (!Number.isFinite(score)) { + continue; + } + + const className = normalize(candidate.getAttribute("class"))?.toLowerCase() ?? ""; + if (className.includes("menu")) { + score += 100; + } + + if (!best || score > best.score) { + best = { + element: candidate, + score, + }; + } + } + + return best?.element ?? null; + }; + + const deadline = Date.now() + timeoutMs; + while (Date.now() <= deadline) { + const currentModelLabel = readModelLabel(); + if (currentModelLabel?.includes("思考")) { + return { + ok: true as const, + currentModelLabel, + }; + } + + const loginSignal = loginReason(); + if (loginSignal) { + return { + ok: false as const, + error: "kimi_login_required", + detail: loginSignal, + currentModelLabel, + url: window.location.href || null, + }; + } + + const trigger = findTrigger(); + if (trigger) { + clickElement(trigger); + } + + await wait(200); + + const nextLabel = readModelLabel(); + if (nextLabel?.includes("思考")) { + return { + ok: true as const, + currentModelLabel: nextLabel, + }; + } + + const option = findThinkingOption(); + if (option && clickElement(option)) { + await wait(250); + } + + const resolvedLabel = readModelLabel(); + if (resolvedLabel?.includes("思考")) { + return { + ok: true as const, + currentModelLabel: resolvedLabel, + }; + } + } + + return { + ok: false as const, + error: readModelLabel() ? "kimi_thinking_mode_not_selected" : "kimi_model_switch_trigger_missing", + detail: buildDetail(), + currentModelLabel: readModelLabel(), + url: window.location.href || null, + }; + }, + { + preferredModelLabel: KIMI_THINKING_MODE_LABEL, + timeoutMs: KIMI_MODE_SWITCH_TIMEOUT_MS, + }, + ); + + if (result.ok) { + return result; + } + + const trigger = page.locator(".current-model").first(); + if (!(await trigger.isVisible().catch(() => false))) { + return result; + } + + await trigger.click().catch(() => undefined); + await sleep(250).catch(() => undefined); + + for (const text of [KIMI_THINKING_MODE_LABEL, "思考"]) { + const option = page.getByText(text, { + exact: text === KIMI_THINKING_MODE_LABEL, + }).first(); + if (await option.isVisible().catch(() => false)) { + await option.click().catch(() => undefined); + await sleep(300).catch(() => undefined); + break; + } + } + + result = await page.evaluate( + () => { + const normalize = (value: unknown): string | null => { + if (typeof value !== "string") { + return null; + } + const trimmed = value + .replace(/\u00a0/g, " ") + .split(/\n+/) + .map((line) => line.trim()) + .filter(Boolean) + .join(" "); + return trimmed ? trimmed : null; + }; + + const currentModelLabel = normalize( + document.querySelector(".current-model .name") + ?.textContent + ?? document.querySelector(".current-model .model-name")?.textContent + ?? document.querySelector(".current-model")?.textContent, + ); + + if (currentModelLabel?.includes("思考")) { + return { + ok: true as const, + currentModelLabel, + }; + } + + return { + ok: false as const, + error: "kimi_thinking_mode_not_selected", + detail: normalize(document.body?.innerText)?.slice(0, 200) ?? null, + currentModelLabel, + url: window.location.href || null, + }; + }, + ); + + return result; +} + +async function readKimiPageSnapshot(page: PlaywrightPage, questionText: string): Promise { + return page.evaluate( + ({ question }) => { + type CandidateKind = "answer" | "reasoning"; + type Candidate = { + element: HTMLElement; + text: string; + serializedText: string; + score: number; + order: number; + kind: CandidateKind; + }; + + const normalizeInline = (value: unknown): string | null => { + if (typeof value !== "string") { + return null; + } + const trimmed = value + .replace(/\u00a0/g, " ") + .split(/\n+/) + .map((line) => line.trim()) + .filter(Boolean) + .join(" "); + return trimmed ? trimmed : null; + }; + + const tidyInlineText = (value: unknown): string | null => { + if (typeof value !== "string") { + return null; + } + const trimmed = value + .replace(/\u00a0/g, " ") + .replace(/[ \t\f\v]+/g, " ") + .replace(/ *\n */g, "\n") + .replace(/\n{3,}/g, "\n\n") + .trim(); + return trimmed ? trimmed : null; + }; + + const normalizeBlock = (value: unknown): string | null => { + if (typeof value !== "string") { + return null; + } + const trimmed = value + .replace(/\u00a0/g, " ") + .split(/\n+/) + .map((line) => line.trim()) + .filter(Boolean) + .join("\n"); + return trimmed ? trimmed : null; + }; + + const sanitizeSiteName = (value: unknown): string | null => { + const normalized = normalizeInline(value); + if (!normalized) { + return null; + } + const cleaned = normalized.replace(/^来源[::]\s*/i, "").trim(); + return cleaned ? cleaned : null; + }; + + const escapeMarkdownText = (value: string): string => + value + .replace(/\\/g, "\\\\") + .replace(/\[/g, "\\[") + .replace(/\]/g, "\\]"); + + const escapeTableCell = (value: string): string => + value + .replace(/\|/g, "\\|") + .replace(/\r?\n+/g, "
") + .trim(); + + 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 loginSignals = [ + "微信扫码登录", + "手机号快捷登录", + "发送验证码", + "登录后", + "有问题,免费聊", + ]; + const busySignalsPattern = /思考中|搜索中|生成中|回答中|整理中|正在搜索|正在思考|正在生成|停止生成|停止回答|停止思考/i; + const tableWrapperPattern = /markdown-table|table-container/i; + const structuredClassPattern = /paragraph|markdown|rich-text|content|answer|response|message|reason|thought|analysis/i; + const blockTagNames = new Set([ + "ARTICLE", + "ASIDE", + "BLOCKQUOTE", + "DIV", + "FIGCAPTION", + "FIGURE", + "FOOTER", + "H1", + "H2", + "H3", + "H4", + "H5", + "H6", + "HEADER", + "LI", + "MAIN", + "NAV", + "OL", + "P", + "PRE", + "SECTION", + "TABLE", + "TBODY", + "TD", + "TH", + "THEAD", + "TR", + "UL", + ]); + const ignoredTagNames = new Set([ + "CANVAS", + "IFRAME", + "NOSCRIPT", + "SCRIPT", + "STYLE", + ]); + + const exactNoiseLines = new Set([ + "复制", + "重试", + "重新生成", + "继续", + "发送", + "上传", + "搜索", + "分享", + ]); + + const partialNoiseSnippets = [ + "微信扫码登录", + "手机号快捷登录", + "发送验证码", + "登录后", + "有问题,免费聊", + ]; + const elementOrders = new Map(); + let nextOrder = 1; + const normalizedQuestion = normalizeBlock(question); + + const getElementOrder = (element: Element): number => { + const existing = elementOrders.get(element); + if (existing !== undefined) { + return existing; + } + const order = nextOrder; + nextOrder += 1; + elementOrders.set(element, order); + return order; + }; + + const normalizeHref = (value: string | null): string | null => { + const href = normalizeInline(value); + if (!href) { + return null; + } + + try { + const url = new URL(href, window.location.href); + url.hash = ""; + return url.toString(); + } catch { + return null; + } + }; + + const buildSyntheticSourceUrl = (siteName: string | null): string | null => { + const cleaned = sanitizeSiteName(siteName); + if (!cleaned) { + return null; + } + + if (/^https?:\/\//i.test(cleaned)) { + return normalizeHref(cleaned); + } + + const domainMatch = cleaned.match(/([a-z0-9-]+(?:\.[a-z0-9-]+)+)(?:\/.*)?$/i); + if (domainMatch?.[1]) { + return normalizeHref(`https://${domainMatch[1]}/`); + } + + const siteAliases: Array<[RegExp, string]> = [ + [/搜狐焦点/i, "https://house.focus.cn/"], + [/(今日头条|toutiao)/i, "https://www.toutiao.com/"], + [/(搜狐|sohu)/i, "https://www.sohu.com/"], + [/(新浪|sina)/i, "https://www.sina.com.cn/"], + [/(网易|163)/i, "https://www.163.com/"], + [/(腾讯|qq)/i, "https://www.qq.com/"], + [/(凤凰|ifeng)/i, "https://www.ifeng.com/"], + [/(澎湃|thepaper)/i, "https://www.thepaper.cn/"], + [/(人民网|people)/i, "https://www.people.com.cn/"], + [/(新华|news\\.cn|xinhuanet)/i, "https://www.news.cn/"], + [/(中国新闻网|chinanews)/i, "https://www.chinanews.com.cn/"], + [/(百度|baidu)/i, "https://www.baidu.com/"], + [/(知乎|zhihu)/i, "https://www.zhihu.com/"], + [/(小红书|xiaohongshu|xhslink)/i, "https://www.xiaohongshu.com/"], + [/(抖音|douyin)/i, "https://www.douyin.com/"], + [/(快手|kuaishou)/i, "https://www.kuaishou.com/"], + ]; + + for (const [pattern, target] of siteAliases) { + if (pattern.test(cleaned)) { + return target; + } + } + + return null; + }; + + const isExternalSourceUrl = (href: string | null): boolean => { + if (!href) { + return false; + } + + try { + const hostname = new URL(href).hostname.toLowerCase(); + return Boolean( + hostname + && !hostname.endsWith("kimi.com") + && !hostname.endsWith("moonshot.cn"), + ); + } catch { + return false; + } + }; + + const isCitationElement = (element: Element | null | undefined): element is HTMLElement => { + if (!(element instanceof HTMLElement)) { + return false; + } + const className = normalizeInline(element.getAttribute("class"))?.toLowerCase() ?? ""; + return className.includes("rag-tag") || Boolean(sanitizeSiteName(element.getAttribute("data-site-name"))); + }; + + const extractDomLink = (element: Element): KimiDomLink | null => { + if (!(element instanceof HTMLElement)) { + return null; + } + + const siteName = sanitizeSiteName( + element.getAttribute("data-site-name") + ?? element.dataset.siteName + ?? element.getAttribute("title") + ?? element.getAttribute("aria-label"), + ); + const href = + element instanceof HTMLAnchorElement + ? normalizeHref(element.getAttribute("href") ?? element.href) + : normalizeHref(element.getAttribute("href")); + const url = isExternalSourceUrl(href) ? href : buildSyntheticSourceUrl(siteName); + if (!url) { + return null; + } + + let text = normalizeInline(element.textContent) ?? siteName; + if (!text) { + try { + text = new URL(url).hostname; + } catch { + text = siteName; + } + } + + return { + url, + title: sanitizeSiteName(element.getAttribute("title")) ?? siteName, + text, + siteName, + }; + }; + + const extractExternalAnchorLink = (element: Element): KimiDomLink | null => { + if (!(element instanceof HTMLAnchorElement)) { + return null; + } + + const href = normalizeHref(element.getAttribute("href") ?? element.href); + if (!isExternalSourceUrl(href)) { + return null; + } + + const siteName = sanitizeSiteName( + element.getAttribute("data-site-name") + ?? element.dataset.siteName + ?? element.getAttribute("aria-label"), + ); + const text = normalizeInline(element.textContent); + return { + url: href as string, + title: sanitizeSiteName(element.getAttribute("title")) ?? text ?? siteName, + text, + siteName, + }; + }; + + const extractCitationPanelLink = (element: Element): KimiDomLink | null => { + if (!(element instanceof HTMLAnchorElement)) { + return null; + } + + const href = normalizeHref(element.getAttribute("href") ?? element.href); + if (!isExternalSourceUrl(href)) { + return null; + } + + const title = normalizeInline( + element.querySelector(".site-title")?.textContent + ?? element.getAttribute("title") + ?? element.textContent, + ); + const siteName = sanitizeSiteName( + element.querySelector(".site-name-text")?.textContent + ?? element.getAttribute("data-site-name") + ?? element.dataset.siteName + ?? element.getAttribute("aria-label"), + ); + + return { + url: href as string, + title: title ?? siteName, + text: title ?? siteName, + siteName, + }; + }; + + const shouldIgnoreLink = (link: KimiDomLink | null): boolean => { + const linkText = normalizeInline(link?.text); + return Boolean(linkText && ["用户协议", "隐私政策", "联系我们", "下载 App"].includes(linkText)); + }; + + const collectUniqueLinks = ( + roots: HTMLElement[], + selectors: string, + extractor: (element: Element) => KimiDomLink | null, + ): KimiDomLink[] => { + const results: KimiDomLink[] = []; + const seen = new Set(); + for (const root of roots) { + const sourceNodes = Array.from(root.querySelectorAll(selectors)); + for (const sourceNode of sourceNodes) { + const link = extractor(sourceNode); + if (!link || shouldIgnoreLink(link) || seen.has(link.url)) { + continue; + } + + seen.add(link.url); + results.push(link); + } + } + return results; + }; + + const formatMarkdownLink = (label: string | null, url: string | null): string => { + const normalizedLabel = normalizeInline(label); + if (!url) { + return normalizedLabel ? escapeMarkdownText(normalizedLabel) : ""; + } + const safeLabel = escapeMarkdownText(normalizedLabel || url); + return `[${safeLabel}](${url})`; + }; + + const resolveStandaloneTable = (element: HTMLElement): HTMLTableElement | null => { + if (element instanceof HTMLTableElement) { + return element; + } + + const className = normalizeInline(element.getAttribute("class")) ?? ""; + if (!tableWrapperPattern.test(className)) { + return null; + } + + const nestedTable = element.querySelector("table"); + return nestedTable instanceof HTMLTableElement ? nestedTable : null; + }; + + const hasStructuredChildren = (element: HTMLElement): boolean => + Array.from(element.children).some((child) => { + if (!(child instanceof HTMLElement)) { + return false; + } + const childClassName = normalizeInline(child.getAttribute("class")) ?? ""; + return Boolean( + resolveStandaloneTable(child) + || blockTagNames.has(child.tagName) + || structuredClassPattern.test(childClassName), + ); + }); + + const serializeInlineNode = (node: any): string => { + if (node.nodeType === Node.TEXT_NODE) { + return node.textContent?.replace(/\u00a0/g, " ").replace(/\s+/g, " ") ?? ""; + } + + if (!(node instanceof HTMLElement)) { + return ""; + } + const element = node as any; + + if (isCitationElement(element as Element)) { + const citation = extractDomLink(element); + if (!citation) { + return sanitizeSiteName(element.getAttribute("data-site-name")) ?? ""; + } + const label = citation.siteName ?? citation.text ?? citation.title ?? "来源"; + return ` ${formatMarkdownLink(label, citation.url)}`; + } + + if (!isVisible(element as Element) || ignoredTagNames.has(element.tagName)) { + return ""; + } + + if (element.tagName === "BR") { + return "\n"; + } + + const standaloneTable = resolveStandaloneTable(element as HTMLElement); + if (standaloneTable) { + return `\n\n${serializeTable(standaloneTable)}\n\n`; + } + + if (element.tagName === "A") { + const anchor = element as HTMLAnchorElement; + const href = normalizeHref(anchor.getAttribute("href") ?? anchor.href); + const inlineText = + tidyInlineText(Array.from(element.childNodes).map((child) => serializeInlineNode(child)).join("")) + ?? normalizeInline(element.textContent); + if (!href || !isExternalSourceUrl(href)) { + return inlineText ?? ""; + } + return formatMarkdownLink(inlineText ?? href, href); + } + + const childrenText = tidyInlineText( + Array.from(element.childNodes).map((child) => serializeInlineNode(child)).join(""), + ); + + switch (element.tagName) { + case "STRONG": + case "B": + return childrenText ? `**${childrenText}**` : ""; + case "EM": + case "I": + return childrenText ? `*${childrenText}*` : ""; + case "CODE": + return childrenText ? `\`${childrenText}\`` : ""; + default: + return childrenText ?? ""; + } + }; + + function serializeInlineChildren(element: ParentNode): string { + return tidyInlineText( + Array.from(element.childNodes).map((child) => serializeInlineNode(child)).join(""), + ) ?? ""; + } + + const serializeTable = (table: HTMLTableElement): string => { + const rows = Array.from(table.rows) + .map((row) => + Array.from(row.cells) + .map((cell) => { + const serialized = tidyInlineText(serializeInlineChildren(cell) || normalizeBlock(cell.innerText) || ""); + return serialized ? escapeTableCell(serialized) : ""; + }), + ) + .filter((row) => row.some((cell) => Boolean(cell))); + + if (!rows.length) { + return ""; + } + + const firstDomRow = table.rows.item(0); + const firstRowLooksLikeHeader = Boolean( + table.tHead?.rows.length + || ( + firstDomRow + && Array.from(firstDomRow.cells).every((cell) => cell.tagName === "TH") + ), + ); + + const rowWidth = rows.reduce((max, row) => Math.max(max, row.length), 0); + if (!rowWidth) { + return ""; + } + + const padRow = (row: string[]) => + Array.from({ length: rowWidth }, (_, index) => row[index] ?? ""); + + let header = firstRowLooksLikeHeader + ? padRow(rows[0]) + : Array.from({ length: rowWidth }, (_, index) => `列${index + 1}`); + const body = (firstRowLooksLikeHeader ? rows.slice(1) : rows).map(padRow); + + if (!header.some((cell) => Boolean(cell))) { + header = Array.from({ length: rowWidth }, (_, index) => `列${index + 1}`); + } + + const divider = Array.from({ length: rowWidth }, () => "---"); + return [ + `| ${header.join(" | ")} |`, + `| ${divider.join(" | ")} |`, + ...body.map((row) => `| ${row.join(" | ")} |`), + ].join("\n"); + }; + + const serializeList = (element: HTMLElement, ordered: boolean): string => { + const items = Array.from(element.children) + .filter((child): child is HTMLElement => child instanceof HTMLElement && child.tagName === "LI") + .map((item, index) => { + const label = ordered ? `${index + 1}.` : "-"; + const text = serializeInlineChildren(item); + return text ? `${label} ${text}` : ""; + }) + .filter(Boolean); + + return items.join("\n"); + }; + + const serializeBlockNode = (node: any): string => { + if (node.nodeType === Node.TEXT_NODE) { + return normalizeInline(node.textContent) ?? ""; + } + + if (!(node instanceof HTMLElement)) { + return ""; + } + const element = node as any; + + if (isCitationElement(element as Element)) { + return serializeInlineNode(element); + } + + if (!isVisible(element as Element) || ignoredTagNames.has(element.tagName)) { + return ""; + } + + if (element.tagName === "BR") { + return "\n"; + } + + const standaloneTable = resolveStandaloneTable(element as HTMLElement); + if (standaloneTable) { + return serializeTable(standaloneTable); + } + + if (element.tagName === "UL") { + return serializeList(element as HTMLElement, false); + } + if (element.tagName === "OL") { + return serializeList(element as HTMLElement, true); + } + if (element.tagName === "LI") { + const text = serializeInlineChildren(element as ParentNode); + return text ? `- ${text}` : ""; + } + if (element.tagName === "BLOCKQUOTE") { + const text = serializeInlineChildren(element as ParentNode); + return text + ? text + .split(/\n+/) + .map((line) => `> ${line}`) + .join("\n") + : ""; + } + if (/^H[1-6]$/.test(element.tagName)) { + const depth = Number(element.tagName.slice(1)); + const text = serializeInlineChildren(element as ParentNode); + return text ? `${"#".repeat(Math.max(1, Math.min(depth, 6)))} ${text}` : ""; + } + + const className = normalizeInline(element.getAttribute("class")) ?? ""; + if (element.tagName === "P" || /paragraph/i.test(className)) { + return serializeInlineChildren(element as ParentNode); + } + + if (hasStructuredChildren(element as HTMLElement)) { + const parts = Array.from(element.childNodes) + .map((child) => normalizeBlock(serializeBlockNode(child))) + .filter((value): value is string => Boolean(value)); + return parts.join("\n\n"); + } + + return serializeInlineChildren(element as ParentNode); + }; + + const serializeRichElement = (element: HTMLElement): string | null => + normalizeBlock(serializeBlockNode(element)); + + const cleanCandidateText = (value: string | null) => { + if (!value) { + return null; + } + + const lines = value + .split(/\n+/) + .map((line) => line.trim()) + .filter(Boolean); + + const filtered = lines.filter((line) => { + if (exactNoiseLines.has(line)) { + return false; + } + if (normalizedQuestion && line === normalizedQuestion) { + return false; + } + return !partialNoiseSnippets.some((snippet) => line.includes(snippet)); + }); + + return filtered.length > 0 ? filtered.join("\n") : null; + }; + + const findQuestionAnchor = (): HTMLElement | null => { + if (!normalizedQuestion) { + return null; + } + + let best: { element: HTMLElement; order: number } | null = null; + const nodes = Array.from(document.querySelectorAll("article, section, div, p, span")); + for (const node of nodes) { + if (!(node instanceof HTMLElement) || !isVisible(node)) { + continue; + } + + const text = normalizeBlock(node.innerText); + if (!text) { + continue; + } + if ( + text !== normalizedQuestion + && !(text.includes(normalizedQuestion) && text.length <= normalizedQuestion.length + 80) + ) { + continue; + } + + const container = + (node.closest("article, section, [class*=\"message\"], [class*=\"question\"], [class*=\"user\"], li, div") as HTMLElement | null) + ?? node; + const order = getElementOrder(container); + if (!best || order >= best.order) { + best = { + element: container, + order, + }; + } + } + + return best?.element ?? null; + }; + + const questionAnchor = findQuestionAnchor(); + + const isAfterQuestionAnchor = (element: HTMLElement): boolean => { + if (!questionAnchor) { + return true; + } + if (element === questionAnchor || questionAnchor.contains(element) || element.contains(questionAnchor)) { + return false; + } + + const relation = questionAnchor.compareDocumentPosition(element); + if (relation & Node.DOCUMENT_POSITION_FOLLOWING) { + return true; + } + + const elementRect = element.getBoundingClientRect(); + const anchorRect = questionAnchor.getBoundingClientRect(); + return elementRect.top >= anchorRect.top - 12; + }; + + const resolveCandidateKind = (element: HTMLElement, text: string): CandidateKind => { + const className = normalizeInline(element.getAttribute("class"))?.toLowerCase() ?? ""; + const merged = `${className}\n${text}`; + if ( + /think|reason|analysis|thought/.test(className) + || /^思考已完成/.test(text) + || /(搜索网页|搜索结果|检索关键词|思考过程)/.test(merged) + ) { + return "reasoning"; + } + return "answer"; + }; + + const candidateScore = (element: HTMLElement, text: string, kind: CandidateKind) => { + const className = normalizeInline(element.getAttribute("class"))?.toLowerCase() ?? ""; + const rect = element.getBoundingClientRect(); + let score = Math.min(text.length, 3_500); + if (className.includes("markdown")) { + score += 1_100; + } + if (className.includes("assistant")) { + score += 700; + } + if (className.includes("answer") || className.includes("response")) { + score += 500; + } + if (className.includes("message")) { + score += 160; + } + if (className.includes("content")) { + score += 140; + } + if (kind === "reasoning") { + score += 220; + } + score += Math.min(rect.height, 1_400) / 8; + + const linkCount = element.querySelectorAll("a[href], .rag-tag[data-site-name], [data-site-name]").length; + score += Math.min(linkCount, 12) * 30; + score += Math.min(element.querySelectorAll("table").length, 4) * 260; + score += Math.min(text.split("\n").length, 24) * 20; + + if (normalizedQuestion && text === normalizedQuestion) { + score -= 8_000; + } + if (loginSignals.some((signal) => text.includes(signal))) { + score -= 5_000; + } + if (questionAnchor) { + const anchorRect = questionAnchor.getBoundingClientRect(); + score += Math.max(0, 800 - Math.abs(rect.top - anchorRect.top)); + } + return score; + }; + + const collectCandidates = (selectors: string[]) => { + const seen = new Set(); + const candidates: Candidate[] = []; + + for (const selector of selectors) { + const elements = Array.from(document.querySelectorAll(selector)); + for (const element of elements) { + if (!(element instanceof HTMLElement) || seen.has(element) || !isVisible(element)) { + continue; + } + seen.add(element); + const order = getElementOrder(element); + + if (!isAfterQuestionAnchor(element)) { + continue; + } + + const rawText = normalizeBlock(element.innerText); + const text = cleanCandidateText(rawText); + const hasStructuredContent = Boolean(element.querySelector("table, .table.markdown-table, .table-container")); + if (!text || (text.length < 24 && !hasStructuredContent)) { + continue; + } + const serializedText = cleanCandidateText(serializeRichElement(element)) ?? text; + if (element.childElementCount > 36 && text.length < 220) { + continue; + } + + const kind = resolveCandidateKind(element, text); + + candidates.push({ + element, + text, + serializedText, + score: candidateScore(element, text, kind), + order, + kind, + }); + } + } + + candidates.sort((left, right) => right.score - left.score || left.order - right.order); + return candidates; + }; + + const selectDistinctCandidates = (candidates: Candidate[], kind: CandidateKind, maxBlocks: number) => { + const selected: Candidate[] = []; + + for (const candidate of candidates) { + if (candidate.kind !== kind) { + continue; + } + + let handled = false; + for (let index = 0; index < selected.length; index += 1) { + const existing = selected[index]; + if (existing.text === candidate.text || existing.text.includes(candidate.text)) { + handled = true; + break; + } + if (candidate.text.includes(existing.text)) { + if (candidate.score >= existing.score) { + selected[index] = candidate; + } + handled = true; + break; + } + } + + if (!handled) { + selected.push(candidate); + } + + if (selected.length >= maxBlocks && selected.some((item) => item.text.length >= 1_200)) { + break; + } + } + + selected.sort((left, right) => left.order - right.order); + return selected; + }; + + const mergeTextBlocks = (blocks: Candidate[]) => { + const merged: Candidate[] = []; + for (const block of blocks) { + let duplicate = false; + for (let index = 0; index < merged.length; index += 1) { + const existing = merged[index]; + if (existing.text === block.text || existing.text.includes(block.text)) { + duplicate = true; + break; + } + if (block.text.includes(existing.text)) { + merged[index] = block; + duplicate = true; + break; + } + } + + if (!duplicate) { + merged.push(block); + } + } + return merged.map((item) => item.serializedText || item.text); + }; + + const candidates = collectCandidates([ + ".chat-content-item.chat-content-item-assistant", + ".chat-content-item-assistant", + ".paragraph", + ".table.markdown-table", + ".table-container", + ".markdown", + "[class*=\"markdown\"]", + "[class*=\"rich-text\"]", + "[class*=\"assistant\"]", + "[data-role*=\"assistant\"]", + "[class*=\"answer\"]", + "[class*=\"response\"]", + "[class*=\"reason\"]", + "[class*=\"thought\"]", + "[class*=\"analysis\"]", + "[class*=\"content\"]", + "article", + "section", + "[class*=\"message\"]", + "main article", + "main section", + ]); + const answerCandidates = selectDistinctCandidates(candidates, "answer", 6); + const reasoningCandidates = selectDistinctCandidates(candidates, "reasoning", 5); + const answerBlocks = mergeTextBlocks(answerCandidates); + const reasoningBlocks = mergeTextBlocks( + reasoningCandidates.filter((candidate) => + !answerCandidates.some((answerCandidate) => + answerCandidate.text.includes(candidate.text) || candidate.text.includes(answerCandidate.text)) + ), + ); + const answerText = answerBlocks.join("\n\n") || null; + const reasoningText = reasoningBlocks.join("\n\n") || null; + + const citationRoots = [ + ...answerCandidates.map((candidate) => candidate.element), + ...reasoningCandidates.map((candidate) => candidate.element), + ].filter((value): value is HTMLElement => value instanceof HTMLElement); + + const inlineCitationCandidateLinks = collectUniqueLinks( + citationRoots, + ".rag-tag[data-site-name], [data-site-name].rag-tag", + extractDomLink, + ); + + const citationPanelRoots = Array.from( + document.querySelectorAll(".side-console-container .ref, .side-console .ref"), + ).filter((value): value is HTMLElement => + value instanceof HTMLElement + && isVisible(value) + && (normalizeBlock(value.innerText)?.includes("引用来源") ?? false)); + + const panelCitationLinks = collectUniqueLinks( + citationPanelRoots, + "a.site-item[href]", + extractCitationPanelLink, + ); + + const citationLinks = panelCitationLinks; + + const searchPanelCandidates = Array.from(document.querySelectorAll("div, section, aside")) + .filter((value): value is HTMLElement => value instanceof HTMLElement && isVisible(value)) + .map((element) => { + const text = normalizeBlock(element.innerText) ?? ""; + const externalAnchorCount = Array.from(element.querySelectorAll("a[href]")) + .filter((node) => extractExternalAnchorLink(node) !== null) + .length; + const rect = element.getBoundingClientRect(); + const area = Math.round(Math.max(rect.width, 0) * Math.max(rect.height, 0)); + const className = normalizeInline(element.getAttribute("class"))?.toLowerCase() ?? ""; + return { + element, + text, + externalAnchorCount, + area, + className, + }; + }) + .filter((candidate) => + candidate.externalAnchorCount >= 3 + && ( + /^搜索网页(?:\s+\d+)?/m.test(candidate.text) + || ( + candidate.text.includes("搜索网页") + && (candidate.className.includes("ole") || candidate.className.includes("search")) + ) + )) + .sort((left, right) => left.area - right.area || right.externalAnchorCount - left.externalAnchorCount); + + const searchPanelRoots: HTMLElement[] = []; + for (const candidate of searchPanelCandidates) { + if (searchPanelRoots.some((root) => root === candidate.element || root.contains(candidate.element) || candidate.element.contains(root))) { + continue; + } + searchPanelRoots.push(candidate.element); + } + + const searchResultLinks = collectUniqueLinks( + searchPanelRoots, + "a[href]", + extractExternalAnchorLink, + ); + + const bodyText = normalizeBlock(document.body?.innerText) ?? ""; + const loginReason = loginSignals.find((signal) => bodyText.includes(signal)) ?? null; + const busySignals: string[] = []; + if (!bodyText.includes("思考已完成") && busySignalsPattern.test(bodyText)) { + busySignals.push("body_status_text"); + } + const visibleBusyTextExists = Array.from( + document.querySelectorAll("button, [role=\"button\"], [class*=\"status\"], [class*=\"loading\"], [class*=\"state\"]"), + ).some((node) => { + if (!(node instanceof HTMLElement) || !isVisible(node)) { + return false; + } + const text = normalizeInline(node.innerText); + return Boolean(text && text !== "思考已完成" && busySignalsPattern.test(text)); + }); + if (visibleBusyTextExists) { + busySignals.push("visible_busy_text"); + } + const loadingIndicators = Array.from( + document.querySelectorAll("[class*=\"loading\"], [class*=\"typing\"], [class*=\"spinner\"], [class*=\"stream\"]"), + ).filter((node) => isVisible(node)); + if (loadingIndicators.length > 0) { + busySignals.push("loading_indicator"); + } + const sendButton = document.querySelector(".send-button-container"); + const sendDisabled = + sendButton instanceof HTMLElement + ? sendButton.classList.contains("disabled") || sendButton.getAttribute("aria-disabled") === "true" + : null; + + const currentModelLabel = normalizeInline( + document.querySelector(".current-model .name") + ?.textContent + ?? document.querySelector(".current-model .model-name")?.textContent + ?? document.querySelector(".current-model")?.textContent, + ); + + return { + url: window.location.href, + title: normalizeInline(document.title), + currentModelLabel, + loginRequired: Boolean(loginReason), + loginReason, + busy: busySignals.length > 0, + busySignals, + sendDisabled, + answerText, + answerBlocks, + reasoningText, + reasoningBlocks, + questionMatched: Boolean(questionAnchor), + candidateAnswerCount: answerCandidates.length, + candidateReasoningCount: reasoningCandidates.length, + citationLinks, + panelCitationLinks, + inlineCitationCandidateLinks, + searchResultLinks, + signature: [ + currentModelLabel, + answerText, + reasoningText, + answerBlocks.join("\n"), + reasoningBlocks.join("\n"), + ...citationLinks.map((item) => `${item.siteName ?? item.text ?? item.url}:${item.url}`), + ...searchResultLinks.map((item) => item.url), + ].filter(Boolean).join("\n").slice(-6_000) || null, + }; + }, + { + question: questionText, + }, + ); +} + +async function waitForKimiAnswer( + page: PlaywrightPage, + questionText: string, + signal: AbortSignal, +): Promise { + const startedAt = Date.now(); + await pinKimiConversationToBottom(page); + let stablePollCount = 0; + let bestSnapshot = await readKimiPageSnapshot(page, questionText); + const initialSignature = bestSnapshot.signature; + let lastSignature: string | null = initialSignature; + let sawChange = false; + + while (Date.now() - startedAt <= KIMI_QUERY_TIMEOUT_MS) { + if (signal.aborted) { + throw new Error("adapter_aborted"); + } + + await pinKimiConversationToBottom(page); + const snapshot = await readKimiPageSnapshot(page, questionText); + bestSnapshot = pickBetterSnapshot(bestSnapshot, snapshot); + + if (snapshot.loginRequired) { + return { + ok: false, + error: "kimi_login_expired", + detail: snapshot.loginReason ?? "login_required", + finalSnapshot: pickBetterSnapshot(bestSnapshot, snapshot), + stablePollCount, + elapsedMs: Date.now() - startedAt, + }; + } + + const hasAnswer = Boolean(normalizeOptionalString(snapshot.answerText)); + const hasLinks = snapshot.citationLinks.length > 0 || snapshot.searchResultLinks.length > 0; + const hasReasoning = snapshot.reasoningBlocks.length > 0; + const hasContent = hasAnswer || hasLinks || hasReasoning; + if (snapshot.signature && hasContent) { + if (snapshot.signature !== initialSignature) { + sawChange = true; + } + if (snapshot.signature === lastSignature) { + stablePollCount = snapshot.busy ? 0 : stablePollCount + 1; + } else { + lastSignature = snapshot.signature; + stablePollCount = 0; + } + } + + if (sawChange && hasContent && !snapshot.busy && stablePollCount >= KIMI_STABLE_POLLS_REQUIRED) { + await ensureKimiCitationPanelOpen(page, signal); + const finalSnapshot = pickBetterSnapshot(bestSnapshot, await readKimiPageSnapshot(page, questionText)); + return { + ok: true, + finalSnapshot, + stablePollCount, + elapsedMs: Date.now() - startedAt, + }; + } + + await sleep(KIMI_QUERY_POLL_INTERVAL_MS, signal); + } + + await pinKimiConversationToBottom(page); + await ensureKimiCitationPanelOpen(page, signal); + const finalSnapshot = pickBetterSnapshot(bestSnapshot, await readKimiPageSnapshot(page, questionText)); + return { + ok: false, + error: "kimi_query_timeout", + detail: "timed out while waiting for Kimi answer", + finalSnapshot, + stablePollCount, + elapsedMs: Date.now() - startedAt, + }; +} + +export const kimiAdapter: MonitorAdapter = { + provider: "kimi", + executionMode: "playwright", + async query(context, payload) { + if (!context.playwright?.page) { + return { + status: "failed", + summary: "Kimi 监测缺少 Playwright 页面上下文。", + error: buildAdapterError("kimi_playwright_required", "kimi_playwright_required"), + }; + } + + const questionText = extractQuestionText(payload); + const page = context.playwright.page; + + context.reportProgress("kimi.page_ready"); + await ensurePageOnKimiChat(page, context.signal); + + const initialSnapshot = await readKimiPageSnapshot(page, questionText); + if (initialSnapshot.loginRequired) { + return { + status: "failed", + summary: "Kimi 账号未登录或登录态已失效,请先在 desktop client 中重新授权。", + error: buildAdapterError( + "kimi_login_required", + initialSnapshot.loginReason ?? "login_required", + { + page_url: initialSnapshot.url, + }, + ), + }; + } + + context.reportProgress("kimi.mode_switch"); + const modeSwitchResult = await ensureKimiThinkingMode(page); + if (!modeSwitchResult.ok) { + const isAuthFailure = modeSwitchResult.error === "kimi_login_required"; + return { + status: "failed", + summary: isAuthFailure + ? "Kimi 账号未登录或登录态已失效,请先在 desktop client 中重新授权。" + : "Kimi 未能切换到 K2.6 思考,已中止本次监控任务。", + error: buildAdapterError( + modeSwitchResult.error, + modeSwitchResult.detail ?? modeSwitchResult.error, + { + page_url: modeSwitchResult.url ?? safePageURL(page), + current_model_label: modeSwitchResult.currentModelLabel, + }, + ), + }; + } + + context.reportProgress("kimi.query"); + await submitKimiQuestion(page, questionText, context.signal); + await sleep(400, context.signal).catch(() => undefined); + await pinKimiConversationToBottom(page); + + const postSubmitSnapshot = await readKimiPageSnapshot(page, questionText); + if (postSubmitSnapshot.loginRequired) { + return { + status: "failed", + summary: "Kimi 账号未登录或登录态已失效,请先在 desktop client 中重新授权。", + error: buildAdapterError( + "kimi_login_expired", + postSubmitSnapshot.loginReason ?? "login_required", + { + page_url: postSubmitSnapshot.url, + }, + ), + }; + } + + context.reportProgress("kimi.wait_answer"); + const queryResult = await waitForKimiAnswer(page, questionText, context.signal); + const finalSnapshot = queryResult.finalSnapshot; + const citations = dedupeSourceItems( + finalSnapshot.citationLinks + .map((item) => buildSourceItem(item)) + .filter((item): item is MonitoringSourceItem => item !== null), + ); + const searchResults = dedupeSourceItems( + finalSnapshot.searchResultLinks + .map((item) => buildSourceItem(item)) + .filter((item): item is MonitoringSourceItem => item !== null), + ); + const answer = normalizeText(finalSnapshot.answerText); + const reasoning = normalizeText(finalSnapshot.reasoningText); + const providerModel = normalizeText(finalSnapshot.currentModelLabel) ?? KIMI_THINKING_MODE_LABEL; + const conversationID = extractConversationID(finalSnapshot.url); + + if (!queryResult.ok && queryResult.error === "kimi_login_expired") { + return { + status: "failed", + summary: "Kimi 账号未登录或登录态已失效,请先在 desktop client 中重新授权。", + error: buildAdapterError( + queryResult.error, + queryResult.detail ?? "login_required", + { + page_url: finalSnapshot.url, + }, + ), + }; + } + + if (!answer && !searchResults.length && !citations.length) { + return queryResult.ok + ? { + status: "unknown", + summary: "Kimi 返回为空,已回写 unknown 等待后续对账。", + error: buildAdapterError("kimi_empty_response", "kimi returned no answer, citations, or search results"), + } + : { + status: "failed", + summary: `Kimi 请求失败:${queryResult.detail ?? queryResult.error}`, + error: buildAdapterError( + queryResult.error, + queryResult.detail ?? queryResult.error, + { + page_url: finalSnapshot.url, + current_model_label: finalSnapshot.currentModelLabel, + }, + ), + }; + } + + context.reportProgress("kimi.parse_result"); + return { + status: "succeeded", + summary: queryResult.ok ? "Kimi 监控任务执行成功。" : "Kimi 监控任务执行成功(超时前已收集到页面答案)。", + payload: { + platform: "kimi", + provider_model: providerModel, + provider_request_id: null, + request_id: conversationID, + 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: "kimi", + status: queryResult.ok ? "succeeded" : queryResult.error, + page_url: finalSnapshot.url, + page_title: finalSnapshot.title, + model_label: finalSnapshot.currentModelLabel, + thinking_mode_requested: KIMI_THINKING_MODE_LABEL, + login_required: finalSnapshot.loginRequired, + busy: finalSnapshot.busy, + busy_signals: finalSnapshot.busySignals, + send_disabled: finalSnapshot.sendDisabled, + answer: answer ?? null, + answer_block_count: finalSnapshot.answerBlocks.length, + answer_blocks: finalSnapshot.answerBlocks, + reasoning: reasoning ?? null, + reasoning_block_count: finalSnapshot.reasoningBlocks.length, + reasoning_blocks: finalSnapshot.reasoningBlocks, + citation_count: citations.length, + citations: toJsonSources(citations), + panel_citation_count: finalSnapshot.panelCitationLinks.length, + panel_citations: finalSnapshot.panelCitationLinks.map((item) => ({ + url: item.url, + title: item.title ?? item.text ?? null, + site_name: item.siteName ?? null, + })), + inline_citation_candidate_count: finalSnapshot.inlineCitationCandidateLinks.length, + inline_citation_candidates: finalSnapshot.inlineCitationCandidateLinks.map((item) => ({ + url: item.url, + title: item.title ?? item.text ?? null, + site_name: item.siteName ?? null, + })), + search_result_count: searchResults.length, + search_results: toJsonSources(searchResults), + source_count: citations.length, + sources: toJsonSources(citations), + question_matched: finalSnapshot.questionMatched, + candidate_answer_count: finalSnapshot.candidateAnswerCount, + candidate_reasoning_count: finalSnapshot.candidateReasoningCount, + stable_poll_count: queryResult.stablePollCount, + elapsed_ms: queryResult.elapsedMs, + signature: finalSnapshot.signature, + snapshot: toJsonValue(finalSnapshot), + }, + }, + }; + }, +}; diff --git a/apps/desktop-client/src/main/adapters/yuanbao.ts b/apps/desktop-client/src/main/adapters/yuanbao.ts new file mode 100644 index 0000000..a10b7ee --- /dev/null +++ b/apps/desktop-client/src/main/adapters/yuanbao.ts @@ -0,0 +1,1726 @@ +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([ + [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; +}; + +function isRecord(value: unknown): value is Record { + 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(); + 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, 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(); + 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 = {}, +): Record { + return { + code, + message, + ...extras, + }; +} + +function extractQuestionText(payload: Record): 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 { + await new Promise((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 { + 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(); + + 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(), + }; + + 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 => { + const { questionText, timeoutMs, readyTimeoutMs, enableDeepThink, enableWebSearch } = parameters; + + const wait = (timeMs: number) => + new Promise((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(); + 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(); + + 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(); + 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, + }, + }, + }; + }, +}; diff --git a/apps/desktop-client/src/main/bootstrap.ts b/apps/desktop-client/src/main/bootstrap.ts index b0a8a5d..ce33009 100644 --- a/apps/desktop-client/src/main/bootstrap.ts +++ b/apps/desktop-client/src/main/bootstrap.ts @@ -233,6 +233,26 @@ async function createMainWindow(): Promise { } }); + window.on("minimize", () => { + console.warn("[desktop-main] main window minimize event", { + at: new Date().toISOString(), + stack: new Error("trace").stack, + }); + }); + window.on("hide", () => { + console.warn("[desktop-main] main window hide event", { + at: new Date().toISOString(), + stack: new Error("trace").stack, + }); + }); + window.on("blur", () => { + console.warn("[desktop-main] main window blur event", { + at: new Date().toISOString(), + visible: window.isVisible(), + minimized: window.isMinimized(), + }); + }); + await mountRendererView(window); return window; } diff --git a/apps/desktop-client/src/main/playwright-cdp.ts b/apps/desktop-client/src/main/playwright-cdp.ts index ba119d5..7725fc3 100644 --- a/apps/desktop-client/src/main/playwright-cdp.ts +++ b/apps/desktop-client/src/main/playwright-cdp.ts @@ -8,6 +8,7 @@ const hiddenPlaywrightIdleTTLms = 5 * 60_000; const hiddenPlaywrightReaperIntervalMs = 60_000; const defaultConnectTimeoutMs = 10_000; const defaultPageTimeoutMs = 45_000; +const hiddenBootstrapMetaName = "geo-hidden-playwright-token"; const cdpPort = Number.parseInt(process.env.GEO_ELECTRON_CDP_PORT ?? "9339", 10); const cdpEndpointURL = `http://127.0.0.1:${cdpPort}`; const cdpTargetsListURL = `${cdpEndpointURL}/json/list`; @@ -79,8 +80,10 @@ export async function retainHiddenPlaywrightPage(options: { const browser = await connectBrowserOverCDP(); const knownPages = new Set(defaultContext(browser).pages()); - const window = createHiddenWindow(options.title, options.session); - const page = await waitForNewPage(browser, knownPages); + const bootstrapToken = createHiddenBootstrapToken(); + const bootstrapURL = buildHiddenWindowBootstrapURL(options.title, bootstrapToken); + const window = createHiddenWindow(options.title, options.session, bootstrapURL); + const page = await waitForNewPage(browser, knownPages, bootstrapToken); page.setDefaultTimeout(defaultPageTimeoutMs); page.setDefaultNavigationTimeout(defaultPageTimeoutMs); @@ -181,11 +184,18 @@ function createLease(record: HiddenPlaywrightRecord): HiddenPlaywrightLease { }; } -function createHiddenWindow(title: string, session: Session): ElectronBrowserWindow { +function createHiddenWindow( + title: string, + session: Session, + bootstrapURL: string, +): ElectronBrowserWindow { const window = new BrowserWindow({ show: false, width: 1320, height: 900, + skipTaskbar: true, + focusable: false, + hiddenInMissionControl: true, autoHideMenuBar: true, backgroundColor: nativeTheme.shouldUseDarkColors ? "#15191a" : "#f4f1ea", title, @@ -199,10 +209,41 @@ function createHiddenWindow(title: string, session: Session): ElectronBrowserWin }); window.webContents.setUserAgent(STANDARD_USER_AGENT); - void window.loadURL("about:blank"); + void window.loadURL(bootstrapURL); return window; } +function createHiddenBootstrapToken(): string { + if (typeof globalThis.crypto?.randomUUID === "function") { + return globalThis.crypto.randomUUID(); + } + return `${Date.now()}-${Math.random().toString(16).slice(2)}`; +} + +function escapeHtml(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll("\"", """) + .replaceAll("'", "'"); +} + +function buildHiddenWindowBootstrapURL(title: string, token: string): string { + const html = [ + "", + "", + "", + " ", + ` `, + ` ${escapeHtml(title)} · hidden-playwright`, + "", + ` `, + "", + ].join(""); + return `data:text/html;charset=utf-8,${encodeURIComponent(html)}`; +} + async function ensureRecordTarget(record: HiddenPlaywrightRecord, targetURL: string): Promise { record.targetURL = targetURL; @@ -346,18 +387,21 @@ function isConnectOverCDPTimeout(error: unknown): boolean { async function waitForNewPage( browser: PlaywrightBrowser, knownPages: Set, + bootstrapToken: string, ): Promise { const context = defaultContext(browser); const startedAt = Date.now(); while (Date.now() - startedAt < defaultPageTimeoutMs) { - const nextPage = context.pages().find((page) => !knownPages.has(page)); - if (nextPage) { + const nextPages = context.pages().filter((page) => !knownPages.has(page)); + for (const nextPage of nextPages) { if (isDevtoolsPage(nextPage)) { knownPages.add(nextPage); continue; } - return nextPage; + if (await matchesHiddenBootstrapPage(nextPage, bootstrapToken)) { + return nextPage; + } } await wait(100); } @@ -365,6 +409,34 @@ async function waitForNewPage( throw new Error("playwright_hidden_page_unavailable"); } +async function matchesHiddenBootstrapPage( + page: PlaywrightPage, + bootstrapToken: string, +): Promise { + const url = safePageURL(page); + if (url.includes(bootstrapToken) || url.includes(encodeURIComponent(bootstrapToken))) { + return true; + } + + try { + return await page.evaluate( + ({ token, metaName }) => { + const metaToken = + document.querySelector(`meta[name="${metaName}"]`)?.getAttribute("content") + ?? document.body?.getAttribute(`data-${metaName}`) + ?? null; + return metaToken === token; + }, + { + token: bootstrapToken, + metaName: hiddenBootstrapMetaName, + }, + ); + } catch { + return false; + } +} + function defaultContext(browser: PlaywrightBrowser) { const context = browser.contexts()[0]; if (!context) { diff --git a/apps/desktop-client/src/main/runtime-controller.ts b/apps/desktop-client/src/main/runtime-controller.ts index f0e69a7..a50c14c 100644 --- a/apps/desktop-client/src/main/runtime-controller.ts +++ b/apps/desktop-client/src/main/runtime-controller.ts @@ -18,8 +18,10 @@ import { getAIPlatformCatalogItem, isAIPlatformId } from "@geo/shared-types"; import { doubaoAdapter, + kimiAdapter, qwenAdapter, toutiaoAdapter, + yuanbaoAdapter, zhihuAdapter, type AdapterExecutionResult, type MonitorAdapter, @@ -2640,6 +2642,12 @@ function selectPublishAdapter(platform: string): PublishAdapter | null { } function selectMonitorAdapter(platform: string): MonitorAdapter | null { + if (platform === yuanbaoAdapter.provider) { + return yuanbaoAdapter; + } + if (platform === kimiAdapter.provider) { + return kimiAdapter; + } if (platform === doubaoAdapter.provider) { return doubaoAdapter; } diff --git a/packages/shared-types/src/ai-platforms.ts b/packages/shared-types/src/ai-platforms.ts index 56bf92e..0b7fd99 100644 --- a/packages/shared-types/src/ai-platforms.ts +++ b/packages/shared-types/src/ai-platforms.ts @@ -24,8 +24,8 @@ export const aiPlatformCatalog: readonly AIPlatformCatalogItem[] = [ shortName: "K", accent: "#111827", description: "Moonshot AI 账号绑定与静默监测", - loginUrl: "https://kimi.moonshot.cn/", - consoleUrl: "https://kimi.moonshot.cn/", + loginUrl: "https://www.kimi.com/", + consoleUrl: "https://www.kimi.com/", }, { id: "wenxin", diff --git a/server/cmd/dev-seed/main.go b/server/cmd/dev-seed/main.go index 624b519..507b44d 100644 --- a/server/cmd/dev-seed/main.go +++ b/server/cmd/dev-seed/main.go @@ -693,7 +693,7 @@ func ensureDesktopClient(ctx context.Context, tx pgx.Tx, tenantID, workspaceID, } func ensureMonitoringQuota(ctx context.Context, tx pgx.Tx, state *seedState) error { - enabledPlatforms, _ := json.Marshal([]string{"deepseek", "qwen", "doubao"}) + enabledPlatforms, _ := json.Marshal([]string{"yuanbao", "kimi", "wenxin", "deepseek", "doubao", "qwen"}) _, err := tx.Exec(ctx, ` INSERT INTO tenant_monitoring_quotas ( tenant_id, workspace_id, max_brands, collection_mode, question_selection_mode, collect_frequency, diff --git a/server/migrations/20260410103000_create_monitoring_tables.up.sql b/server/migrations/20260410103000_create_monitoring_tables.up.sql index a5dfcb9..7036036 100644 --- a/server/migrations/20260410103000_create_monitoring_tables.up.sql +++ b/server/migrations/20260410103000_create_monitoring_tables.up.sql @@ -4,7 +4,7 @@ CREATE TABLE tenant_monitoring_quotas ( collection_mode VARCHAR(20) NOT NULL DEFAULT 'desktop', question_selection_mode VARCHAR(20) NOT NULL DEFAULT 'best_effort_all', collect_frequency VARCHAR(20) NOT NULL DEFAULT 'daily', - enabled_platforms JSONB NOT NULL DEFAULT '["deepseek","qwen","doubao"]'::jsonb, + enabled_platforms JSONB NOT NULL DEFAULT '["yuanbao","kimi","wenxin","deepseek","doubao","qwen"]'::jsonb, primary_client_id UUID, task_daily_hard_cap INT NOT NULL DEFAULT 36, plan_tier VARCHAR(20) NOT NULL DEFAULT 'free',