fix(deepseek): fall back to reasoning "搜索到 XX 个网页" panel for citations

When the standard search-results panel never opens, click the reasoning-chain
"搜索到 N 个网页" affordance to reveal the citation source list, and prefer the
revealed panel links over stale snapshot links so citations point at the right
URLs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-26 10:18:44 +08:00
parent 05aa982f76
commit 2fd6267632
2 changed files with 401 additions and 6 deletions
@@ -1,4 +1,48 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
vi.mock("electron/main", () => ({
app: {
getPath: () => "/tmp/geo-rankly-deepseek-test",
getVersion: () => "0.0.0-test",
isPackaged: false,
on: vi.fn(),
whenReady: () => Promise.resolve(),
},
BrowserWindow: vi.fn(),
WebContentsView: vi.fn(),
ipcMain: {
handle: vi.fn(),
on: vi.fn(),
removeHandler: vi.fn(),
},
Menu: {
buildFromTemplate: vi.fn(() => ({ popup: vi.fn() })),
setApplicationMenu: vi.fn(),
},
Tray: vi.fn(),
nativeTheme: {
on: vi.fn(),
shouldUseDarkColors: false,
},
safeStorage: {
decryptString: vi.fn(),
encryptString: vi.fn(),
isEncryptionAvailable: () => false,
},
session: {
fromPartition: vi.fn(() => ({
cookies: {
get: vi.fn(),
remove: vi.fn(),
set: vi.fn(),
},
webRequest: {
onBeforeSendHeaders: vi.fn(),
onHeadersReceived: vi.fn(),
},
})),
},
}));
import { __deepseekTestUtils } from "./deepseek";
import { getMonitorAdapter } from "./index";
@@ -11,9 +55,11 @@ const {
dedupeSourceItems,
classifyDeepseekSources,
mergeDeepSeekRawSourceLinks,
resolveDeepSeekCitationPanelLinks,
isObservationComplete,
isDeepSeekToggleSelected,
isDeepSeekWebpagesTriggerText,
isDeepSeekReasoningSearchTriggerText,
} = __deepseekTestUtils;
describe("deepseek adapter helpers", () => {
@@ -67,6 +113,74 @@ describe("deepseek adapter helpers", () => {
expect(classified.searchResults[0]?.url).toBe("https://example.com/search");
});
it("uses revealed xx webpages panel links instead of stale snapshot links for citation sources", () => {
const citationPanelLinks = resolveDeepSeekCitationPanelLinks(
[
{
url: "https://stale.example/from-snapshot",
title: "Stale snapshot result",
text: "Wrong result from the page snapshot",
siteName: "Stale",
kind: "search",
},
],
[
{
url: "https://right.example/from-xx-webpages",
title: "Right side panel result",
text: "Correct result from DeepSeek search results",
siteName: "Right",
kind: "search",
},
],
);
const classified = classifyDeepseekSources({
inlineLinks: [
{
url: "https://inline.example/body-link",
title: "Inline body link",
text: "[1]",
siteName: "Inline",
kind: "inline",
},
],
sourcePanelLinks: [],
searchPanelLinks: citationPanelLinks,
});
expect(citationPanelLinks.map((item) => item.url)).toEqual([
"https://right.example/from-xx-webpages",
]);
expect(classified.citations.map((item) => item.url)).toEqual([
"https://right.example/from-xx-webpages",
]);
expect(classified.searchResults.map((item) => item.url)).toEqual([
"https://right.example/from-xx-webpages",
]);
expect(classified.inlineCitationCandidates.map((item) => item.url)).toEqual([
"https://inline.example/body-link",
]);
});
it("falls back to snapshot search links when the revealed xx webpages panel is unavailable", () => {
const citationPanelLinks = resolveDeepSeekCitationPanelLinks(
[
{
url: "https://snapshot.example/result",
title: "Snapshot result",
text: "Fallback result",
siteName: "Snapshot",
kind: "search",
},
],
[],
);
expect(citationPanelLinks.map((item) => item.url)).toEqual([
"https://snapshot.example/result",
]);
});
it("enriches numeric inline citations with source metadata from revealed search cards", () => {
const classified = classifyDeepseekSources({
inlineLinks: [
@@ -267,6 +381,12 @@ describe("deepseek adapter helpers", () => {
expect(isDeepSeekWebpagesTriggerText("搜索到 39 个网页")).toBe(false);
});
it("treats reasoning 搜索到 xx 个网页 as a separate citation-source fallback trigger", () => {
expect(isDeepSeekReasoningSearchTriggerText("搜索到 30 个网页")).toBe(true);
expect(isDeepSeekReasoningSearchTriggerText("57 个网页")).toBe(false);
expect(isDeepSeekReasoningSearchTriggerText("已阅读 57 个网页")).toBe(false);
});
it("normalizes DeepSeek model labels without mistaking search toggles for models", () => {
const snapshot = {
url: "https://chat.deepseek.com/",
@@ -18,6 +18,9 @@ const DEEPSEEK_SEARCH_RESULTS_HEADING_PATTERN = /^(?:搜索结果|search results
const DEEPSEEK_WEBPAGES_TRIGGER_ZH_PATTERN = /^(?:已阅读\s*)?\d+\s*个网页$/i
const DEEPSEEK_WEBPAGES_TRIGGER_EN_PATTERN =
/^(?:(?:read|viewed)\s*)?\d+\s*(?:web\s*pages?|webpages?)$/i
const DEEPSEEK_REASONING_SEARCH_TRIGGER_ZH_PATTERN = /^搜索到\s*\d+\s*个网页$/i
const DEEPSEEK_REASONING_SEARCH_TRIGGER_EN_PATTERN =
/^(?:found|searched)\s*\d+\s*(?:web\s*pages?|webpages?)$/i
const DEEPSEEK_BUSY_TEXT_PATTERN =
/(searching|thinking|generating|please wait until the message is generated|搜索中|思考中|生成中|停止生成|stop generating)/i
const DEEPSEEK_BUSY_DESCRIPTOR_PATTERN =
@@ -354,6 +357,18 @@ function isDeepSeekWebpagesTriggerText(value: string | null | undefined): boolea
)
}
function isDeepSeekReasoningSearchTriggerText(value: string | null | undefined): boolean {
const normalized = normalizeText(value)
if (!normalized) {
return false
}
return (
DEEPSEEK_REASONING_SEARCH_TRIGGER_ZH_PATTERN.test(normalized) ||
DEEPSEEK_REASONING_SEARCH_TRIGGER_EN_PATTERN.test(normalized)
)
}
function buildSourceItem(source: DeepSeekSourceInput): MonitoringSourceItem | null {
const url = normalizeUrl(getSourceNormalizedUrl(source) ?? source.url)
if (!url) {
@@ -466,6 +481,16 @@ function classifyDeepseekSources(input: {
}
}
function resolveDeepSeekCitationPanelLinks(
snapshotSearchPanelLinks: DeepSeekRawSourceLink[],
revealedSearchPanelLinks: DeepSeekRawSourceLink[],
): DeepSeekRawSourceLink[] {
if (revealedSearchPanelLinks.length > 0) {
return mergeDeepSeekRawSourceLinks(revealedSearchPanelLinks)
}
return mergeDeepSeekRawSourceLinks(snapshotSearchPanelLinks)
}
function extractQuestionText(payload: Record<string, unknown>): string {
const candidates = [
payload.question_text,
@@ -1127,6 +1152,247 @@ async function maybeRevealDeepSeekSources(page: PlaywrightPage): Promise<void> {
}
}
async function maybeRevealDeepSeekReasoningSearchSources(page: PlaywrightPage): Promise<void> {
if (await hasDeepSeekSearchResultsPanel(page)) {
return
}
const reasoningSearchTrigger = await page
.evaluate(() => {
const normalize = (value: unknown): string | null => {
if (typeof value !== 'string') {
return null
}
const trimmed = value.trim().replace(/\s+/g, ' ')
return trimmed || null
}
const isVisible = (node: Element | null): node is HTMLElement => {
if (!(node instanceof HTMLElement)) {
return false
}
const style = window.getComputedStyle(node)
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') {
return false
}
const rect = node.getBoundingClientRect()
return rect.width > 0 && rect.height > 0
}
const isTriggerText = (value: unknown): boolean => {
const text = normalize(value)
return Boolean(
text &&
(/^搜索到\s*\d+\s*个网页$/i.test(text) ||
/^(?:found|searched)\s*\d+\s*(?:web\s*pages?|webpages?)$/i.test(text)),
)
}
const isReasoningNode = (node: HTMLElement): boolean => {
if (
node.closest(
".ds-think-content, [class*='think-content'], [class*='reasoning-content']",
)
) {
return true
}
let current: HTMLElement | null = node
for (let depth = 0; current && depth < 8; depth += 1) {
const text = normalize(current.textContent || '')
const className = String(current.className || '')
if (
/think|reason|analysis|思考/i.test(className) ||
Boolean(text && /已思考|思考过程|搜索到\s*\d+\s*个网页/.test(text))
) {
return true
}
current = current.parentElement
}
return false
}
const isClickable = (node: HTMLElement): boolean => {
const tagName = node.tagName.toLowerCase()
const role = normalize(node.getAttribute('role'))
const style = window.getComputedStyle(node)
return (
tagName === 'button' ||
tagName === 'a' ||
role === 'button' ||
node.tabIndex >= 0 ||
style.cursor === 'pointer' ||
typeof (node as { onclick?: unknown }).onclick === 'function'
)
}
const findClickTarget = (node: HTMLElement): HTMLElement | null => {
let fallback: HTMLElement | null = null
const nextParent = (element: HTMLElement): HTMLElement | null =>
element.parentNode instanceof HTMLElement ? element.parentNode : null
let current: HTMLElement | null = node
for (let depth = 0; current !== null && depth < 10; depth += 1) {
const element: HTMLElement = current
if (!isVisible(element)) {
current = nextParent(element)
continue
}
const currentText = normalize(
element.textContent || element.getAttribute('aria-label') || '',
)
if (!fallback && currentText && isTriggerText(currentText)) {
fallback = element
}
if (isClickable(element)) {
return element
}
current = nextParent(element)
}
return fallback
}
let bestTarget: HTMLElement | null = null
let bestScore = -1
for (const node of Array.from(
document.querySelectorAll("span, div, button, a, [role='button']"),
).slice(0, 1600)) {
if (!(node instanceof HTMLElement) || !isVisible(node)) {
continue
}
const text = normalize(node.textContent || node.getAttribute('aria-label') || '')
if (!isTriggerText(text) || !isReasoningNode(node)) {
continue
}
const target = findClickTarget(node)
if (!(target instanceof HTMLElement)) {
continue
}
const rect = target.getBoundingClientRect()
let score = 0
if (isClickable(target)) {
score += 32
}
if (rect.left >= window.innerWidth * 0.18 && rect.left <= window.innerWidth * 0.75) {
score += 18
}
if (rect.top >= window.innerHeight * 0.2) {
score += 12
}
if (rect.width <= 360) {
score += 8
}
if (target.closest(".ds-think-content, [class*='think-content']")) {
score += 16
}
if (score > bestScore) {
bestScore = score
bestTarget = target
}
}
if (!bestTarget) {
return null
}
bestTarget.scrollIntoView({ block: 'center', inline: 'center' })
const rect = bestTarget.getBoundingClientRect()
return {
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2,
}
})
.catch(() => null)
if (!reasoningSearchTrigger || typeof reasoningSearchTrigger !== 'object') {
return
}
await page.mouse.click(reasoningSearchTrigger.x, reasoningSearchTrigger.y).catch(() => undefined)
await page.waitForTimeout(500).catch(() => undefined)
if (await waitForDeepSeekSearchResultsPanel(page, 5_000)) {
return
}
await page
.evaluate(() => {
const normalize = (value: unknown): string | null => {
if (typeof value !== 'string') {
return null
}
const trimmed = value.trim().replace(/\s+/g, ' ')
return trimmed || null
}
const isVisible = (node: Element | null): node is HTMLElement => {
if (!(node instanceof HTMLElement)) {
return false
}
const style = window.getComputedStyle(node)
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') {
return false
}
const rect = node.getBoundingClientRect()
return rect.width > 0 && rect.height > 0
}
const isTriggerText = (value: unknown): boolean => {
const text = normalize(value)
return Boolean(text && /^搜索到\s*\d+\s*个网页$/i.test(text))
}
const isClickable = (node: HTMLElement): boolean => {
const tagName = node.tagName.toLowerCase()
const role = normalize(node.getAttribute('role'))
const style = window.getComputedStyle(node)
return (
tagName === 'button' ||
tagName === 'a' ||
role === 'button' ||
node.tabIndex >= 0 ||
style.cursor === 'pointer' ||
typeof (node as { onclick?: unknown }).onclick === 'function'
)
}
const dispatchClick = (target: HTMLElement): void => {
target.dispatchEvent(
new PointerEvent('pointerdown', { bubbles: true, cancelable: true, pointerId: 1 }),
)
target.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }))
target.dispatchEvent(
new PointerEvent('pointerup', { bubbles: true, cancelable: true, pointerId: 1 }),
)
target.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true }))
target.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
}
for (const node of Array.from(
document.querySelectorAll("span, div, button, a, [role='button']"),
).slice(0, 1600)) {
if (!(node instanceof HTMLElement) || !isVisible(node)) {
continue
}
const text = normalize(node.textContent || node.getAttribute('aria-label') || '')
if (!isTriggerText(text)) {
continue
}
let target: HTMLElement | null = node
let fallback: HTMLElement | null = node
for (let depth = 0; target && depth < 10; depth += 1) {
if (isVisible(target) && isClickable(target)) {
dispatchClick(target)
return
}
if (isVisible(target)) {
fallback = target
}
target = target.parentElement
}
if (fallback) {
dispatchClick(fallback)
return
}
}
})
.catch(() => undefined)
await page.waitForTimeout(500).catch(() => undefined)
await waitForDeepSeekSearchResultsPanel(page).catch(() => undefined)
}
async function maybeExpandDeepSeekReasoning(page: PlaywrightPage): Promise<void> {
await page
.evaluate(() => {
@@ -2561,19 +2827,26 @@ export const deepseekAdapter: MonitorAdapter = {
context.reportProgress('deepseek.reveal_sources')
await maybeExpandDeepSeekReasoning(page).catch(() => undefined)
await maybeRevealDeepSeekSources(page).catch(() => undefined)
const revealedSearchPanelLinks = await collectDeepSeekSearchResultsPanelLinks(page).catch(
let revealedSearchPanelLinks = await collectDeepSeekSearchResultsPanelLinks(page).catch(
() => [],
)
if (revealedSearchPanelLinks.length === 0) {
await maybeRevealDeepSeekReasoningSearchSources(page).catch(() => undefined)
revealedSearchPanelLinks = await collectDeepSeekSearchResultsPanelLinks(page).catch(
() => [],
)
}
const finalSnapshot = await readDeepSeekPageSnapshot(page, questionText).catch(
() => waitResult.finalSnapshot,
)
const bestSnapshot = selectBetterSnapshot(waitResult.finalSnapshot, finalSnapshot)
const citationPanelLinks = resolveDeepSeekCitationPanelLinks(
bestSnapshot.searchPanelLinks,
revealedSearchPanelLinks,
)
const mergedSnapshot: DeepSeekPageSnapshot = {
...bestSnapshot,
searchPanelLinks: mergeDeepSeekRawSourceLinks(
bestSnapshot.searchPanelLinks,
revealedSearchPanelLinks,
),
searchPanelLinks: citationPanelLinks,
}
const classifiedSources = classifyDeepseekSources({
inlineLinks: mergedSnapshot.inlineLinks,
@@ -2639,7 +2912,9 @@ export const __deepseekTestUtils = {
dedupeSourceItems,
classifyDeepseekSources,
mergeDeepSeekRawSourceLinks,
resolveDeepSeekCitationPanelLinks,
isObservationComplete,
isDeepSeekToggleSelected,
isDeepSeekWebpagesTriggerText,
isDeepSeekReasoningSearchTriggerText,
}