From e53319039e15c124a91926c5fbeecdb78cb117b2 Mon Sep 17 00:00:00 2001 From: liangxu Date: Sat, 25 Apr 2026 17:38:32 +0800 Subject: [PATCH] feat(kimi): unwrap redirect urls and merge search panel into citations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pull Kimi's "搜索网页" panel results into the regular citation source flow, drop the dedicated-citation-panel branch on the server side, and unwrap Kimi redirect URLs so citation source items resolve to the real target host. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/main/adapters/kimi.test.ts | 138 +++ apps/desktop-client/src/main/adapters/kimi.ts | 804 +++++++++++++++++- .../tenant/app/monitoring_callback_service.go | 7 +- .../app/monitoring_callback_service_test.go | 17 +- 4 files changed, 912 insertions(+), 54 deletions(-) create mode 100644 apps/desktop-client/src/main/adapters/kimi.test.ts diff --git a/apps/desktop-client/src/main/adapters/kimi.test.ts b/apps/desktop-client/src/main/adapters/kimi.test.ts new file mode 100644 index 0000000..6f4ef0a --- /dev/null +++ b/apps/desktop-client/src/main/adapters/kimi.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from "vitest"; + +import { __kimiTestUtils } from "./kimi"; + +const { + buildSourceItem, + classifyKimiSources, + mergeKimiSourceLinksIntoContentSnapshot, + normalizeUrl, +} = __kimiTestUtils; + +function buildSnapshot(overrides: Partial[0]>): Parameters[0] { + return { + url: "https://www.kimi.com/chat/test", + title: "Kimi", + currentModelLabel: "K2.6 思考", + loginRequired: false, + loginReason: null, + busy: false, + busySignals: [], + sendDisabled: null, + answerText: "答案", + answerBlocks: ["答案"], + reasoningText: null, + reasoningBlocks: [], + questionMatched: true, + candidateAnswerCount: 1, + candidateReasoningCount: 0, + citationLinks: [], + panelCitationLinks: [], + inlineCitationCandidateLinks: [], + searchResultLinks: [], + signature: "sig", + ...overrides, + }; +} + +describe("kimi adapter helpers", () => { + it("unwraps Kimi redirect URLs before storing source items", () => { + expect(normalizeUrl("https://www.kimi.com/redirect?url=https%3A%2F%2Fexample.com%2Fsource%23ref")).toBe( + "https://example.com/source", + ); + + expect(buildSourceItem({ + url: "https://www.kimi.com/redirect?target=https%3A%2F%2Fsource.example.com%2Fnews%3Fid%3D1", + title: "搜索结果标题", + text: null, + siteName: "来源站点", + })).toMatchObject({ + url: "https://source.example.com/news?id=1", + normalized_url: "https://source.example.com/news?id=1", + title: "搜索结果标题", + site_name: "来源站点", + host: "source.example.com", + }); + }); + + it("uses search web results as citation sources and footer references as content citation candidates", () => { + const classified = classifyKimiSources(buildSnapshot({ + searchResultLinks: [ + { + url: "https://search.example.com/a", + title: "搜索网页 A", + text: "搜索网页 A", + siteName: "搜索站点", + }, + ], + panelCitationLinks: [ + { + url: "https://content.example.com/cited", + title: "页尾引用文章", + text: "页尾引用文章", + siteName: "内容站点", + }, + ], + inlineCitationCandidateLinks: [ + { + url: "https://content.example.com/site", + title: null, + text: "内容站点", + siteName: "内容站点", + }, + ], + })); + + expect(classified.citations).toHaveLength(1); + expect(classified.citations[0]?.url).toBe("https://search.example.com/a"); + expect(classified.searchResults[0]?.url).toBe("https://search.example.com/a"); + expect(classified.panelCitations[0]?.url).toBe("https://content.example.com/cited"); + expect(classified.inlineCitationCandidates.map((item) => item.url)).toEqual([ + "https://content.example.com/cited", + "https://content.example.com/site", + ]); + }); + + it("falls back to footer references as citation sources when no search web results are visible", () => { + const classified = classifyKimiSources(buildSnapshot({ + panelCitationLinks: [ + { + url: "https://content.example.com/cited", + title: "页尾引用文章", + text: "页尾引用文章", + siteName: "内容站点", + }, + ], + })); + + expect(classified.searchResults).toHaveLength(0); + expect(classified.citations).toHaveLength(1); + expect(classified.citations[0]?.url).toBe("https://content.example.com/cited"); + }); + + it("keeps the answer text from the content snapshot when merging side-panel sources", () => { + const merged = mergeKimiSourceLinksIntoContentSnapshot( + buildSnapshot({ + answerText: "真实回答正文", + answerBlocks: ["真实回答正文"], + }), + buildSnapshot({ + answerText: "搜索网页 48\nhttps://example.com/a 很长的侧栏文本", + answerBlocks: ["搜索网页 48\nhttps://example.com/a 很长的侧栏文本"], + searchResultLinks: [ + { + url: "https://example.com/a", + title: "搜索结果 A", + text: "搜索结果 A", + siteName: "Example", + }, + ], + }), + ); + + expect(merged.answerText).toBe("真实回答正文"); + expect(merged.answerBlocks).toEqual(["真实回答正文"]); + expect(merged.searchResultLinks).toHaveLength(1); + expect(merged.searchResultLinks[0]?.url).toBe("https://example.com/a"); + }); +}); diff --git a/apps/desktop-client/src/main/adapters/kimi.ts b/apps/desktop-client/src/main/adapters/kimi.ts index f2bb1ce..50a090c 100644 --- a/apps/desktop-client/src/main/adapters/kimi.ts +++ b/apps/desktop-client/src/main/adapters/kimi.ts @@ -14,6 +14,26 @@ 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; @@ -45,6 +65,13 @@ type KimiPageSnapshot = { signature: string | null; }; +type KimiClassifiedSources = { + citations: MonitoringSourceItem[]; + searchResults: MonitoringSourceItem[]; + inlineCitationCandidates: MonitoringSourceItem[]; + panelCitations: MonitoringSourceItem[]; +}; + type KimiModeSwitchResult = | { ok: true; @@ -87,19 +114,85 @@ function normalizeOptionalString(value: unknown): string | null { 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; } - try { - const url = new URL(input); - url.hash = ""; - return url.toString(); - } catch { - return input; - } + return extractRedirectTargetUrl(input) ?? normalizeAbsoluteUrl(input); } function buildSourceItem(source: KimiDomLink): MonitoringSourceItem | null { @@ -152,6 +245,99 @@ function dedupeSourceItems(items: MonitoringSourceItem[]): MonitoringSourceItem[ return Array.from(keyed.values()); } +function dedupeKimiDomLinks(items: KimiDomLink[]): KimiDomLink[] { + const keyed = new Map(); + 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; @@ -405,6 +591,231 @@ async function ensureKimiCitationPanelOpen(page: PlaywrightPage, signal?: AbortS } } +async function ensureKimiSearchPanelOpen(page: PlaywrightPage, signal?: AbortSignal): Promise { + 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(); + 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 { + 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; @@ -979,7 +1390,7 @@ async function ensureKimiThinkingMode(page: PlaywrightPage): Promise { return page.evaluate( - ({ question }) => { + ({ question, redirectQueryKeys }) => { type CandidateKind = "answer" | "reasoning"; type Candidate = { element: HTMLElement; @@ -1062,6 +1473,27 @@ async function readKimiPageSnapshot(page: PlaywrightPage, questionText: string): 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 = [ "微信扫码登录", "手机号快捷登录", @@ -1143,19 +1575,84 @@ async function readKimiPageSnapshot(page: PlaywrightPage, questionText: string): 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; } - try { - const url = new URL(href, window.location.href); - url.hash = ""; - return url.toString(); - } catch { + if (/^(?:javascript|data|blob|mailto|tel):/i.test(href)) { return null; } + + return extractRedirectTargetHref(href) ?? normalizeAbsoluteHref(href); }; const buildSyntheticSourceUrl = (siteName: string | null): string | null => { @@ -1287,6 +1784,121 @@ async function readKimiPageSnapshot(page: PlaywrightPage, questionText: string): }; }; + 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; @@ -1344,6 +1956,31 @@ async function readKimiPageSnapshot(page: PlaywrightPage, questionText: string): return results; }; + const collectUniqueRootLinks = ( + roots: HTMLElement[], + selectors: string, + extractor: (element: Element) => KimiDomLink | null, + ): KimiDomLink[] => { + const results: KimiDomLink[] = []; + const seen = new Set(); + 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) { @@ -1614,6 +2251,9 @@ async function readKimiPageSnapshot(page: PlaywrightPage, questionText: string): if (!(node instanceof HTMLElement) || !isVisible(node)) { continue; } + if (isAuxiliaryPanelElement(node)) { + continue; + } const text = normalizeBlock(node.innerText); if (!text) { @@ -1726,6 +2366,9 @@ async function readKimiPageSnapshot(page: PlaywrightPage, questionText: string): if (!(element instanceof HTMLElement) || seen.has(element) || !isVisible(element)) { continue; } + if (isAuxiliaryPanelElement(element)) { + continue; + } seen.add(element); const order = getElementOrder(element); @@ -1887,30 +2530,61 @@ async function readKimiPageSnapshot(page: PlaywrightPage, questionText: string): .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 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, - externalAnchorCount, + searchLinkCount, area, className, }; }) .filter((candidate) => - candidate.externalAnchorCount >= 3 + candidate.searchLinkCount >= 1 && ( /^搜索网页(?:\s+\d+)?/m.test(candidate.text) || ( candidate.text.includes("搜索网页") - && (candidate.className.includes("ole") || candidate.className.includes("search")) + && ( + candidate.text.includes("结果") + || candidate.className.includes("ole") + || candidate.className.includes("search") + || candidate.className.includes("web") + ) ) )) - .sort((left, right) => left.area - right.area || right.externalAnchorCount - left.externalAnchorCount); + .sort((left, right) => left.area - right.area || right.searchLinkCount - left.searchLinkCount); const searchPanelRoots: HTMLElement[] = []; for (const candidate of searchPanelCandidates) { @@ -1922,8 +2596,32 @@ async function readKimiPageSnapshot(page: PlaywrightPage, questionText: string): const searchResultLinks = collectUniqueLinks( searchPanelRoots, - "a[href]", - extractExternalAnchorLink, + [ + "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) ?? ""; @@ -1996,6 +2694,7 @@ async function readKimiPageSnapshot(page: PlaywrightPage, questionText: string): }, { question: questionText, + redirectQueryKeys: KIMI_REDIRECT_QUERY_KEYS, }, ); } @@ -2065,8 +2764,18 @@ async function waitForKimiAnswer( && 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); - let finalSnapshot = pickBetterSnapshot(bestSnapshot, await readKimiPageSnapshot(page, questionText)); + sourceSnapshot = mergeKimiSnapshots(sourceSnapshot, await readKimiPageSnapshot(page, questionText)); + let finalSnapshot = mergeKimiSourceLinksIntoContentSnapshot(contentSnapshot, sourceSnapshot); if (!hasKimiObservedContent(finalSnapshot) && bestContentSnapshot) { finalSnapshot = pickBetterSnapshot(finalSnapshot, bestContentSnapshot); } @@ -2082,8 +2791,18 @@ async function waitForKimiAnswer( } 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); - let finalSnapshot = pickBetterSnapshot(bestSnapshot, await readKimiPageSnapshot(page, questionText)); + sourceSnapshot = mergeKimiSnapshots(sourceSnapshot, await readKimiPageSnapshot(page, questionText)); + let finalSnapshot = mergeKimiSourceLinksIntoContentSnapshot(contentSnapshot, sourceSnapshot); if (!hasKimiObservedContent(finalSnapshot) && bestContentSnapshot) { finalSnapshot = pickBetterSnapshot(finalSnapshot, bestContentSnapshot); } @@ -2173,16 +2892,11 @@ export const kimiAdapter: MonitorAdapter = { 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 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); @@ -2284,14 +2998,12 @@ export const kimiAdapter: MonitorAdapter = { 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) => ({ + 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, @@ -2312,3 +3024,11 @@ export const kimiAdapter: MonitorAdapter = { }; }, }; + +export const __kimiTestUtils = { + buildSourceItem, + classifyKimiSources, + dedupeSourceItems, + mergeKimiSourceLinksIntoContentSnapshot, + normalizeUrl, +}; diff --git a/server/internal/tenant/app/monitoring_callback_service.go b/server/internal/tenant/app/monitoring_callback_service.go index 59b6f02..aa6fcf0 100644 --- a/server/internal/tenant/app/monitoring_callback_service.go +++ b/server/internal/tenant/app/monitoring_callback_service.go @@ -2680,12 +2680,7 @@ func buildMonitoringCitationSourceInputs(platformID string, req MonitoringTaskRe } func monitoringPlatformUsesDedicatedCitationPanel(platformID string) bool { - switch normalizeMonitoringPlatformID(platformID) { - case "kimi": - return true - default: - return false - } + return false } func monitoringPlatformResolvesContentCitations(platformID string) bool { diff --git a/server/internal/tenant/app/monitoring_callback_service_test.go b/server/internal/tenant/app/monitoring_callback_service_test.go index df7ea08..6b4ff8a 100644 --- a/server/internal/tenant/app/monitoring_callback_service_test.go +++ b/server/internal/tenant/app/monitoring_callback_service_test.go @@ -283,7 +283,7 @@ func TestMonitoringHeartbeatSnapshotMetrics(t *testing.T) { } } -func TestBuildMonitoringRawPayloadKimiExcludesSearchResultsFromCitationInputs(t *testing.T) { +func TestBuildMonitoringRawPayloadKimiIncludesSearchResultsInCitationInputs(t *testing.T) { citationTitle := "真实引用文章" searchTitle := "搜索网页结果" @@ -304,13 +304,18 @@ func TestBuildMonitoringRawPayloadKimiExcludesSearchResultsFromCitationInputs(t } rawPayload, sourceInputs := buildMonitoringRawPayload("kimi", req) - if len(sourceInputs) != 1 { - t.Fatalf("buildMonitoringRawPayload() sourceInputs len = %d, want 1", len(sourceInputs)) + if len(sourceInputs) != 2 { + t.Fatalf("buildMonitoringRawPayload() sourceInputs len = %d, want 2", len(sourceInputs)) } + seen := make(map[string]bool) for _, item := range sourceInputs { - if item.URL != "https://example.com/citation" { - t.Fatalf("buildMonitoringRawPayload() source url = %q, want citation url", item.URL) - } + seen[item.URL] = true + } + if !seen["https://example.com/citation"] { + t.Fatalf("buildMonitoringRawPayload() missing citation url in source inputs: %#v", sourceInputs) + } + if !seen["https://example.com/search"] { + t.Fatalf("buildMonitoringRawPayload() missing search url in source inputs: %#v", sourceInputs) } var payload map[string]interface{}