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:
@@ -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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user