Files
geo/apps/desktop-client/src/main/adapters/kimi.ts
T
root a6b17bae62 fix(monitoring): reject succeeded results missing answers or citation sources
Server callback now rejects succeeded submissions without a non-empty answer,
or whose answer contains [citation:N] markers without any sources. Desktop
adapters mirror this client-side: kimi returns unknown when sources arrive
without a final answer, and yuanbao returns unknown when the answer has
unresolved citation markers. Yuanbao also unwraps redirect URLs, harvests
citations from more DOM attributes and document URL fields, drops links
pointing back at yuanbao itself, and dedupes overlapping streaming
fragments. Runtime controller forwards only an explicit "succeeded" status
as success; anything else becomes failed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 22:07:21 +08:00

3037 lines
95 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { JsonValue, MonitoringSourceItem } from "@geo/shared-types";
import type { Page as PlaywrightPage } 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_INCOMPLETE_ANSWER_GRACE_MS = 18_000;
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 思考";
const KIMI_REDIRECT_QUERY_KEYS = [
"url",
"u",
"target",
"targetUrl",
"target_url",
"link",
"href",
"sourceUrl",
"source_url",
"originUrl",
"origin_url",
"realUrl",
"real_url",
"redirect",
"redirect_url",
"jump",
"jump_url",
"to",
];
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 KimiClassifiedSources = {
citations: MonitoringSourceItem[];
searchResults: MonitoringSourceItem[];
inlineCitationCandidates: MonitoringSourceItem[];
panelCitations: MonitoringSourceItem[];
};
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<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function normalizeOptionalString(value: unknown): string | null {
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
return trimmed ? trimmed : null;
}
function decodeUrlCandidate(value: string): string {
let decoded = value.trim();
for (let index = 0; index < 3; index += 1) {
try {
const next = decodeURIComponent(decoded);
if (next === decoded) {
break;
}
decoded = next;
} catch {
break;
}
}
return decoded;
}
function coercePotentialUrl(value: string): string {
const trimmed = decodeUrlCandidate(value);
if (/^\/\//.test(trimmed)) {
return `https:${trimmed}`;
}
if (/^[a-z0-9-]+(?:\.[a-z0-9-]+)+(?:[/?#].*)?$/i.test(trimmed)) {
return `https://${trimmed}`;
}
return trimmed;
}
function normalizeAbsoluteUrl(value: string): string | null {
try {
const url = new URL(coercePotentialUrl(value));
url.hash = "";
return url.toString();
} catch {
return null;
}
}
function extractRedirectTargetUrl(value: string): string | null {
const direct = normalizeAbsoluteUrl(value);
if (!direct) {
return null;
}
try {
const url = new URL(direct);
for (const key of KIMI_REDIRECT_QUERY_KEYS) {
const raw = url.searchParams.get(key);
if (!raw) {
continue;
}
const normalized = normalizeAbsoluteUrl(raw);
if (normalized) {
return normalized;
}
}
} catch {
return null;
}
const encodedMatch = direct.match(/https?%3A%2F%2F[^&\s"'<>]+/i);
if (encodedMatch) {
return normalizeAbsoluteUrl(encodedMatch[0]);
}
const plainMatch = direct.match(/https?:\/\/[^&\s"'<>]+/i);
if (plainMatch && plainMatch[0] !== direct) {
return normalizeAbsoluteUrl(plainMatch[0]);
}
return null;
}
function normalizeUrl(value: unknown): string | null {
const input = normalizeText(value);
if (!input) {
return null;
}
return extractRedirectTargetUrl(input) ?? normalizeAbsoluteUrl(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<string, MonitoringSourceItem>();
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 dedupeKimiDomLinks(items: KimiDomLink[]): KimiDomLink[] {
const keyed = new Map<string, KimiDomLink>();
for (const item of items) {
const key = normalizeUrl(item.url);
if (!key) {
continue;
}
const existing = keyed.get(key);
if (!existing) {
keyed.set(key, {
...item,
url: key,
});
continue;
}
keyed.set(key, {
url: key,
title: existing.title ?? item.title ?? null,
text: existing.text ?? item.text ?? null,
siteName: existing.siteName ?? item.siteName ?? null,
});
}
return Array.from(keyed.values());
}
function mergeKimiDomLinks(left: KimiDomLink[], right: KimiDomLink[]): KimiDomLink[] {
return dedupeKimiDomLinks([...left, ...right]);
}
function mergeKimiSnapshots(base: KimiPageSnapshot, extra: KimiPageSnapshot): KimiPageSnapshot {
const citationLinks = mergeKimiDomLinks(base.citationLinks, extra.citationLinks);
const panelCitationLinks = mergeKimiDomLinks(base.panelCitationLinks, extra.panelCitationLinks);
const inlineCitationCandidateLinks = mergeKimiDomLinks(
base.inlineCitationCandidateLinks,
extra.inlineCitationCandidateLinks,
);
const searchResultLinks = mergeKimiDomLinks(base.searchResultLinks, extra.searchResultLinks);
return {
...base,
citationLinks,
panelCitationLinks,
inlineCitationCandidateLinks,
searchResultLinks,
signature: [
base.signature,
extra.signature,
...citationLinks.map((item) => item.url),
...searchResultLinks.map((item) => item.url),
].filter(Boolean).join("\n").slice(-6_000) || base.signature || extra.signature,
};
}
function mergeKimiSourceLinksIntoContentSnapshot(
contentSnapshot: KimiPageSnapshot,
sourceSnapshot: KimiPageSnapshot,
): KimiPageSnapshot {
return mergeKimiSnapshots(contentSnapshot, sourceSnapshot);
}
function classifyKimiSources(snapshot: KimiPageSnapshot): KimiClassifiedSources {
const searchResults = dedupeSourceItems(
snapshot.searchResultLinks
.map((item) => buildSourceItem(item))
.filter((item): item is MonitoringSourceItem => item !== null),
);
const panelCitations = dedupeSourceItems(
snapshot.panelCitationLinks
.map((item) => buildSourceItem(item))
.filter((item): item is MonitoringSourceItem => item !== null),
);
const inlineAnswerCitations = dedupeSourceItems(
snapshot.inlineCitationCandidateLinks
.map((item) => buildSourceItem(item))
.filter((item): item is MonitoringSourceItem => item !== null),
);
const inlineCitationCandidates = dedupeSourceItems([
...panelCitations,
...inlineAnswerCitations,
]);
return {
// Kimi's citation-source list is the "搜索网页" result set. The footer "引用" panel
// is kept as inline/content citation metadata instead.
citations: searchResults.length ? searchResults : panelCitations,
searchResults,
inlineCitationCandidates,
panelCitations,
};
}
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<string, JsonValue> = {};
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<string, JsonValue> = {},
): Record<string, JsonValue> {
return {
code,
message,
...extras,
};
}
function extractQuestionText(payload: Record<string, unknown>): string {
const candidates = [
payload.question_text,
payload.questionText,
payload.query,
payload.question,
payload.prompt,
payload.content,
payload.title,
];
for (const candidate of candidates) {
const text = normalizeOptionalString(candidate);
if (text) {
return text;
}
}
throw new Error("kimi_question_text_missing");
}
function safePageURL(page: PlaywrightPage): string {
try {
return page.url();
} catch {
return "";
}
}
async function sleep(ms: number, signal?: AbortSignal): Promise<void> {
if (!signal) {
await new Promise<void>((resolve) => {
setTimeout(resolve, ms);
});
return;
}
if (signal.aborted) {
throw new Error("adapter_aborted");
}
await new Promise<void>((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<void> {
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<void> {
await page.evaluate(() => {
const scrollers = new Set<HTMLElement>();
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<void> {
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);
}
}
async function ensureKimiSearchPanelOpen(page: PlaywrightPage, signal?: AbortSignal): Promise<void> {
for (let attempt = 0; attempt < 4; attempt += 1) {
const state = 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 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 panelReady = Array.from(document.querySelectorAll("div, section, aside"))
.some((element) => {
if (!(element instanceof HTMLElement) || !isVisible(element)) {
return false;
}
const text = normalize(element.innerText) ?? "";
return /^搜索网页\s+\d+/m.test(text) && element.querySelectorAll("a[href], [data-url], [data-href]").length > 0;
});
if (panelReady) {
return {
ready: true,
clicked: false,
};
}
const candidates = Array.from(document.querySelectorAll("button, [role=\"button\"], a, [tabindex], div, li, span"))
.filter((element): element is HTMLElement => isVisible(element))
.map((element) => {
const text = normalize(element.innerText || element.textContent || "") ?? "";
if (!/搜索网页/.test(text) || !/结果/.test(text)) {
return null;
}
const target = element.closest("button, [role=\"button\"], a, [tabindex], li, [class*=\"search\"], [class*=\"Search\"], [class*=\"web\"], [class*=\"Web\"]") ?? element;
if (!(target instanceof HTMLElement) || !isVisible(target)) {
return null;
}
const rect = target.getBoundingClientRect();
const className = String(target.className || "");
const inSideConsole = Boolean(target.closest(".side-console, .side-console-container"));
let score = 0;
if (/^\s*搜索网页/.test(text)) {
score += 120;
}
if (/\d+\s*个?结果/.test(text)) {
score += 100;
}
if (target.matches("button, [role=\"button\"], a, [tabindex]")) {
score += 60;
}
if (/(search|web|result|思考|research|ole)/i.test(className)) {
score += 30;
}
if (inSideConsole) {
score -= 200;
}
score -= Math.min(rect.width * rect.height / 20_000, 60);
return {
target,
score,
};
})
.filter((item): item is { target: HTMLElement; score: number } => Boolean(item))
.sort((left, right) => right.score - left.score);
const seen = new Set<HTMLElement>();
const trigger = candidates
.map((item) => item.target)
.find((target) => {
if (seen.has(target)) {
return false;
}
seen.add(target);
return true;
});
if (!trigger) {
return {
ready: false,
clicked: false,
};
}
trigger.scrollIntoView({ block: "center", inline: "center" });
trigger.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
trigger.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true }));
trigger.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, cancelable: true }));
trigger.click();
return {
ready: false,
clicked: true,
};
}).catch(() => ({
ready: false,
clicked: false,
}));
if (state.ready) {
return;
}
if (!state.clicked && attempt > 0) {
await pinKimiConversationToBottom(page);
}
await sleep(450, signal).catch(() => undefined);
}
}
async function scrollKimiSearchPanel(page: PlaywrightPage): Promise<{ atBottom: boolean; moved: boolean }> {
return 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("\n");
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 candidates = Array.from(document.querySelectorAll("div, section, aside"))
.filter((element): element is HTMLElement => {
if (!(element instanceof HTMLElement) || !isVisible(element)) {
return false;
}
const text = normalize(element.innerText) ?? "";
return text.includes("搜索网页") && element.scrollHeight - element.clientHeight > 32;
})
.sort((left, right) => {
const leftScrollable = left.scrollHeight - left.clientHeight;
const rightScrollable = right.scrollHeight - right.clientHeight;
return rightScrollable - leftScrollable;
});
const panel = candidates[0] ?? null;
if (!panel) {
return {
atBottom: true,
moved: false,
};
}
const before = panel.scrollTop;
const maxScrollTop = Math.max(panel.scrollHeight - panel.clientHeight, 0);
panel.scrollTop = Math.min(maxScrollTop, panel.scrollTop + Math.max(panel.clientHeight - 96, 420));
return {
atBottom: panel.scrollTop >= maxScrollTop - 2,
moved: panel.scrollTop !== before,
};
}).catch(() => ({
atBottom: true,
moved: false,
}));
}
async function collectKimiSearchPanelLinks(
page: PlaywrightPage,
questionText: string,
signal: AbortSignal,
): Promise<KimiDomLink[]> {
await ensureKimiSearchPanelOpen(page, signal);
let collected: KimiDomLink[] = [];
let stalledPolls = 0;
for (let index = 0; index < 80; index += 1) {
if (signal.aborted) {
throw new Error("adapter_aborted");
}
const snapshot = await readKimiPageSnapshot(page, questionText);
const beforeCount = collected.length;
collected = mergeKimiDomLinks(collected, snapshot.searchResultLinks);
const state = await scrollKimiSearchPanel(page);
if (collected.length === beforeCount) {
stalledPolls += 1;
} else {
stalledPolls = 0;
}
if (state.atBottom && stalledPolls >= 2) {
break;
}
if (!state.moved && stalledPolls >= 3) {
break;
}
await sleep(180, signal).catch(() => undefined);
}
return collected;
}
function countMatches(input: string, pattern: RegExp): number {
const flags = pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`;
return input.match(new RegExp(pattern.source, flags))?.length ?? 0;
}
function hasStructuredAnswerMarkers(text: string): boolean {
return (
/(^|\n)\s*(?:[-*+]\s+|\d+\.\s+|#{1,6}\s+)/m.test(text)
|| /(?:^|\n)\|.+\|(?:\n|$)/m.test(text)
|| /(?:^|\n)\s*[^:\n]{1,24}[:]\s+\S+/m.test(text)
);
}
function isKimiTerminalShortAnswer(text: string): boolean {
const normalized = text.replace(/\s+/g, " ").trim();
if (!normalized || normalized.length > 48) {
return false;
}
return /(未找到|暂无|没有|无法|不能|抱歉|不确定|未检索到|未发现|无明显结果)/.test(normalized);
}
function isKimiKeywordBlob(text: string): boolean {
const normalized = text.replace(/\s+/g, " ").trim();
if (!normalized) {
return false;
}
const punctuationCount = countMatches(normalized, /[。!?;:,、,.!?;:]/g);
const lineCount = normalized.split(/\n+/).filter(Boolean).length;
const tokenCount = normalized.split(/\s+/).filter(Boolean).length;
return (
!isKimiTerminalShortAnswer(normalized)
&& !hasStructuredAnswerMarkers(normalized)
&& lineCount <= 1
&& punctuationCount === 0
&& tokenCount >= 3
&& normalized.length <= 140
);
}
function isKimiSubstantiveAnswer(answer: string, answerBlocks: string[]): boolean {
const normalized = answer.trim();
if (!normalized) {
return false;
}
const punctuationCount = countMatches(normalized, /[。!?;:,、,.!?;:]/g);
const lineCount = normalized.split(/\n+/).filter(Boolean).length;
if (hasStructuredAnswerMarkers(normalized)) {
return true;
}
if (answerBlocks.length >= 2) {
return true;
}
if (normalized.length >= 140) {
return true;
}
if (normalized.length >= 80 && punctuationCount >= 2) {
return true;
}
if (normalized.length >= 60 && lineCount >= 3) {
return true;
}
return false;
}
function isKimiAnswerComplete(snapshot: KimiPageSnapshot): boolean {
const answer = normalizeOptionalString(snapshot.answerText);
if (!answer) {
return false;
}
if (isKimiKeywordBlob(answer)) {
return false;
}
if (isKimiTerminalShortAnswer(answer)) {
return true;
}
if (isKimiSubstantiveAnswer(answer, snapshot.answerBlocks)) {
return true;
}
const punctuationCount = countMatches(answer, /[。!?;:,、,.!?;:]/g);
if (snapshot.citationLinks.length >= 1 && answer.length >= 50 && punctuationCount >= 1) {
return true;
}
if (snapshot.searchResultLinks.length >= 3 && answer.length >= 70 && punctuationCount >= 1) {
return true;
}
if (snapshot.reasoningBlocks.length >= 2 && answer.length >= 70 && punctuationCount >= 1) {
return true;
}
return false;
}
function shouldReturnStableKimiAnswer(
snapshot: KimiPageSnapshot,
firstChangedContentAt: number | null,
now: number,
): boolean {
if (isKimiAnswerComplete(snapshot)) {
return true;
}
const answer = normalizeOptionalString(snapshot.answerText);
if (!answer || isKimiKeywordBlob(answer) || firstChangedContentAt === null) {
return false;
}
const changedContentAge = now - firstChangedContentAt;
if (changedContentAge < KIMI_INCOMPLETE_ANSWER_GRACE_MS) {
return false;
}
const punctuationCount = countMatches(answer, /[。!?;:,、,.!?;:]/g);
return (
answer.length >= 48
|| punctuationCount >= 1
|| snapshot.answerBlocks.length >= 2
|| isKimiTerminalShortAnswer(answer)
);
}
function hasKimiObservedContent(snapshot: KimiPageSnapshot | null): boolean {
if (!snapshot) {
return false;
}
return Boolean(
normalizeOptionalString(snapshot.answerText)
|| normalizeOptionalString(snapshot.reasoningText)
|| snapshot.answerBlocks.length > 0
|| snapshot.reasoningBlocks.length > 0
|| snapshot.citationLinks.length > 0
|| snapshot.searchResultLinks.length > 0,
);
}
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 (!hasKimiObservedContent(snapshot)) {
score -= 2_000;
}
if (answer && isKimiKeywordBlob(answer)) {
score -= 1_000;
}
if (isKimiAnswerComplete(snapshot)) {
score += 1_500;
}
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;
}
const currentHasContent = hasKimiObservedContent(current);
const candidateHasContent = hasKimiObservedContent(candidate);
if (candidateHasContent && !currentHasContent) {
return candidate;
}
if (currentHasContent && !candidateHasContent) {
return current;
}
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<void> {
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<void> {
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<KimiModeSwitchResult> {
let result = await page.evaluate(
async ({ preferredModelLabel, timeoutMs }) => {
const wait = (timeMs: number) =>
new Promise<void>((resolve) => {
window.setTimeout(resolve, timeMs);
});
const normalize = (value: unknown): string | null => {
if (typeof value !== "string") {
return null;
}
const trimmed = value
.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<KimiPageSnapshot> {
return page.evaluate(
({ question, redirectQueryKeys }) => {
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, "<br />")
.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 isAuxiliaryPanelElement = (element: Element | null | undefined): boolean => {
let current: Element | null = element ?? null;
for (let depth = 0; current && depth < 10; depth += 1) {
const descriptor = [
current.className || "",
current.getAttribute("role") || "",
current.getAttribute("aria-label") || "",
current.getAttribute("data-testid") || "",
].join(" ");
const text = current instanceof HTMLElement ? normalizeInline(current.innerText)?.slice(0, 160) ?? "" : "";
if (
/(side-console|console|drawer|modal|popover|popup|tooltip|floating|hover-card|dropdown|search-result|search-panel|reference-panel|ref-panel|citation-panel)/i.test(descriptor)
|| /^(引用来源|搜索网页)\s*\d*/.test(text)
) {
return true;
}
current = current.parentElement;
}
return false;
};
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<Element, number>();
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 decodeUrlCandidate = (value: string): string => {
let decoded = value.trim();
for (let index = 0; index < 3; index += 1) {
try {
const next = decodeURIComponent(decoded);
if (next === decoded) {
break;
}
decoded = next;
} catch {
break;
}
}
return decoded;
};
const coercePotentialUrl = (value: string): string => {
const trimmed = decodeUrlCandidate(value);
if (/^\/\//.test(trimmed)) {
return `https:${trimmed}`;
}
if (/^[a-z0-9-]+(?:\.[a-z0-9-]+)+(?:[/?#].*)?$/i.test(trimmed)) {
return `https://${trimmed}`;
}
return trimmed;
};
const normalizeAbsoluteHref = (value: string): string | null => {
try {
const url = new URL(coercePotentialUrl(value), window.location.href);
url.hash = "";
return url.toString();
} catch {
return null;
}
};
const extractRedirectTargetHref = (value: string): string | null => {
const direct = normalizeAbsoluteHref(value);
if (!direct) {
return null;
}
try {
const url = new URL(direct);
for (const key of redirectQueryKeys) {
const raw = url.searchParams.get(key);
if (!raw) {
continue;
}
const normalized = normalizeAbsoluteHref(raw);
if (normalized) {
return normalized;
}
}
} catch {
return null;
}
const encodedMatch = direct.match(/https?%3A%2F%2F[^&\s"'<>]+/i);
if (encodedMatch) {
return normalizeAbsoluteHref(encodedMatch[0]);
}
return null;
};
const normalizeHref = (value: string | null): string | null => {
const href = normalizeInline(value);
if (!href) {
return null;
}
if (/^(?:javascript|data|blob|mailto|tel):/i.test(href)) {
return null;
}
return extractRedirectTargetHref(href) ?? normalizeAbsoluteHref(href);
};
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 firstUrlFromText = (value: string | null): string | null => {
if (!value) {
return null;
}
const httpMatch = value.match(/https?:\/\/[^\s"'<>,。;、]+/i);
if (httpMatch) {
return normalizeHref(httpMatch[0]);
}
const domainMatch = value.match(/\b[a-z0-9-]+(?:\.[a-z0-9-]+)+(?:\/[^\s"'<>,。;、]*)?/i);
return domainMatch ? normalizeHref(domainMatch[0]) : null;
};
const readElementUrl = (element: Element): string | null => {
const candidateAttributes = [
"href",
"data-href",
"data-url",
"data-link",
"data-target-url",
"data-source-url",
"data-origin-url",
"data-real-url",
"data-page-url",
"data-web-url",
"data-log",
"data-click",
"data-extra",
"data-info",
"data-value",
];
const candidates: string[] = [];
for (const name of candidateAttributes) {
const value = element.getAttribute(name);
if (value) {
candidates.push(value);
}
}
if (element instanceof HTMLElement) {
candidates.push(...Object.values(element.dataset).filter((value): value is string => Boolean(value)));
}
for (const candidate of candidates) {
const href = normalizeHref(candidate) ?? firstUrlFromText(candidate);
if (isExternalSourceUrl(href)) {
return href;
}
}
const text = normalizeBlock(element.textContent ?? "");
if (text && text.length <= 2_000) {
const href = firstUrlFromText(text);
if (isExternalSourceUrl(href)) {
return href;
}
}
return null;
};
const extractSearchResultLink = (element: Element): KimiDomLink | null => {
const anchorLink = extractExternalAnchorLink(element);
if (anchorLink) {
return anchorLink;
}
if (!(element instanceof HTMLElement) || !isVisible(element)) {
return null;
}
const href = readElementUrl(element);
if (!isExternalSourceUrl(href)) {
return null;
}
const card = element.closest([
"a",
"li",
"article",
"section",
"[role=\"listitem\"]",
"[class*=\"item\"]",
"[class*=\"Item\"]",
"[class*=\"card\"]",
"[class*=\"Card\"]",
"[class*=\"result\"]",
"[class*=\"Result\"]",
"[class*=\"source\"]",
"[class*=\"Source\"]",
"[class*=\"web\"]",
"[class*=\"Web\"]",
].join(",")) ?? element;
const title = normalizeInline(
card.querySelector("[class*=\"title\"], [class*=\"Title\"], h1, h2, h3, h4")?.textContent
?? element.getAttribute("title")
?? element.getAttribute("aria-label")
?? card.textContent,
);
const siteName = sanitizeSiteName(
card.querySelector("[class*=\"site\"], [class*=\"Site\"], [class*=\"domain\"], [class*=\"Domain\"], [class*=\"source\"], [class*=\"Source\"]")?.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 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<string>();
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 collectUniqueRootLinks = (
roots: HTMLElement[],
selectors: string,
extractor: (element: Element) => KimiDomLink | null,
): KimiDomLink[] => {
const results: KimiDomLink[] = [];
const seen = new Set<string>();
for (const root of roots) {
const sourceNodes = [
root,
...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;
}
if (isAuxiliaryPanelElement(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<Element>();
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;
}
if (isAuxiliaryPanelElement(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 searchLinkCount = collectUniqueRootLinks(
[element],
[
"a[href]",
"[data-href]",
"[data-url]",
"[data-link]",
"[data-target-url]",
"[data-source-url]",
"[data-origin-url]",
"[data-real-url]",
"[data-page-url]",
"[data-web-url]",
"[data-log]",
"[data-click]",
"[data-extra]",
"[data-info]",
"li",
"article",
"[role=\"listitem\"]",
"[class*=\"item\"]",
"[class*=\"Item\"]",
"[class*=\"card\"]",
"[class*=\"Card\"]",
"[class*=\"result\"]",
"[class*=\"Result\"]",
].join(","),
extractSearchResultLink,
).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,
searchLinkCount,
area,
className,
};
})
.filter((candidate) =>
candidate.searchLinkCount >= 1
&& (
/^搜索网页(?:\s+\d+)?/m.test(candidate.text)
|| (
candidate.text.includes("搜索网页")
&& (
candidate.text.includes("结果")
|| candidate.className.includes("ole")
|| candidate.className.includes("search")
|| candidate.className.includes("web")
)
)
))
.sort((left, right) => left.area - right.area || right.searchLinkCount - left.searchLinkCount);
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]",
"[data-href]",
"[data-url]",
"[data-link]",
"[data-target-url]",
"[data-source-url]",
"[data-origin-url]",
"[data-real-url]",
"[data-page-url]",
"[data-web-url]",
"[data-log]",
"[data-click]",
"[data-extra]",
"[data-info]",
"li",
"article",
"[role=\"listitem\"]",
"[class*=\"item\"]",
"[class*=\"Item\"]",
"[class*=\"card\"]",
"[class*=\"Card\"]",
"[class*=\"result\"]",
"[class*=\"Result\"]",
].join(","),
extractSearchResultLink,
);
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,
redirectQueryKeys: KIMI_REDIRECT_QUERY_KEYS,
},
);
}
async function waitForKimiAnswer(
page: PlaywrightPage,
questionText: string,
signal: AbortSignal,
): Promise<KimiQueryWaitResult> {
const startedAt = Date.now();
await pinKimiConversationToBottom(page);
let stablePollCount = 0;
let bestSnapshot = await readKimiPageSnapshot(page, questionText);
let bestContentSnapshot = hasKimiObservedContent(bestSnapshot) ? bestSnapshot : null;
const initialSignature = bestSnapshot.signature;
let lastSignature: string | null = initialSignature;
let sawChange = false;
let firstChangedContentAt: number | null = null;
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 (hasKimiObservedContent(snapshot)) {
bestContentSnapshot = pickBetterSnapshot(bestContentSnapshot, 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 (firstChangedContentAt === null) {
firstChangedContentAt = Date.now();
}
}
if (snapshot.signature === lastSignature) {
stablePollCount = snapshot.busy ? 0 : stablePollCount + 1;
} else {
lastSignature = snapshot.signature;
stablePollCount = 0;
}
}
const now = Date.now();
if (
sawChange
&& hasContent
&& !snapshot.busy
&& stablePollCount >= KIMI_STABLE_POLLS_REQUIRED
&& shouldReturnStableKimiAnswer(snapshot, firstChangedContentAt, now)
) {
const contentSnapshot = pickBetterSnapshot(bestSnapshot, snapshot);
const revealedSearchLinks = await collectKimiSearchPanelLinks(page, questionText, signal).catch(() => []);
let sourceSnapshot = await readKimiPageSnapshot(page, questionText);
if (revealedSearchLinks.length) {
sourceSnapshot = mergeKimiSnapshots(sourceSnapshot, {
...sourceSnapshot,
searchResultLinks: revealedSearchLinks,
});
}
await ensureKimiCitationPanelOpen(page, signal);
sourceSnapshot = mergeKimiSnapshots(sourceSnapshot, await readKimiPageSnapshot(page, questionText));
let finalSnapshot = mergeKimiSourceLinksIntoContentSnapshot(contentSnapshot, sourceSnapshot);
if (!hasKimiObservedContent(finalSnapshot) && bestContentSnapshot) {
finalSnapshot = pickBetterSnapshot(finalSnapshot, bestContentSnapshot);
}
return {
ok: true,
finalSnapshot,
stablePollCount,
elapsedMs: Date.now() - startedAt,
};
}
await sleep(KIMI_QUERY_POLL_INTERVAL_MS, signal);
}
await pinKimiConversationToBottom(page);
const contentSnapshot = bestContentSnapshot ?? bestSnapshot;
const revealedSearchLinks = await collectKimiSearchPanelLinks(page, questionText, signal).catch(() => []);
let sourceSnapshot = await readKimiPageSnapshot(page, questionText);
if (revealedSearchLinks.length) {
sourceSnapshot = mergeKimiSnapshots(sourceSnapshot, {
...sourceSnapshot,
searchResultLinks: revealedSearchLinks,
});
}
await ensureKimiCitationPanelOpen(page, signal);
sourceSnapshot = mergeKimiSnapshots(sourceSnapshot, await readKimiPageSnapshot(page, questionText));
let finalSnapshot = mergeKimiSourceLinksIntoContentSnapshot(contentSnapshot, sourceSnapshot);
if (!hasKimiObservedContent(finalSnapshot) && bestContentSnapshot) {
finalSnapshot = pickBetterSnapshot(finalSnapshot, bestContentSnapshot);
}
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 classifiedSources = classifyKimiSources(finalSnapshot);
const citations = classifiedSources.citations;
const searchResults = classifiedSources.searchResults;
const inlineCitationCandidates = classifiedSources.inlineCitationCandidates;
const panelCitations = classifiedSources.panelCitations;
const answer = normalizeText(finalSnapshot.answerText);
const reasoning = normalizeText(finalSnapshot.reasoningText);
const answerComplete = isKimiAnswerComplete(finalSnapshot);
const keywordBlobDetected = answer ? isKimiKeywordBlob(answer) : false;
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) {
const timedOut = !queryResult.ok && queryResult.error === "kimi_query_timeout";
const hasReasoningOnly = Boolean(reasoning);
if (timedOut || hasReasoningOnly || searchResults.length > 0 || citations.length > 0) {
const incompleteDetail = !queryResult.ok
? queryResult.detail ?? queryResult.error
: "kimi returned sources without a final answer";
return {
status: "unknown",
summary: "Kimi 未拿到完整答案,已回写 unknown 等待后续补采。",
error: buildAdapterError(
timedOut ? queryResult.error : "kimi_incomplete_response",
incompleteDetail,
{
page_url: finalSnapshot.url,
current_model_label: finalSnapshot.currentModelLabel,
reasoning_present: Boolean(reasoning),
citation_count: citations.length,
search_result_count: searchResults.length,
busy: finalSnapshot.busy,
},
),
};
}
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_complete: answerComplete,
answer_keyword_blob_detected: keywordBlobDetected,
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: panelCitations.length,
panel_citations: toJsonSources(panelCitations),
inline_citation_candidate_count: inlineCitationCandidates.length,
inline_citation_candidates: toJsonSources(inlineCitationCandidates),
inline_answer_citation_candidate_count: finalSnapshot.inlineCitationCandidateLinks.length,
inline_answer_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),
},
},
};
},
};
export const __kimiTestUtils = {
buildSourceItem,
classifyKimiSources,
dedupeSourceItems,
mergeKimiSourceLinksIntoContentSnapshot,
normalizeUrl,
};