diff --git a/apps/desktop-client/src/main/adapters/deepseek.test.ts b/apps/desktop-client/src/main/adapters/deepseek.test.ts index f0d8e99..d263310 100644 --- a/apps/desktop-client/src/main/adapters/deepseek.test.ts +++ b/apps/desktop-client/src/main/adapters/deepseek.test.ts @@ -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/", diff --git a/apps/desktop-client/src/main/adapters/deepseek.ts b/apps/desktop-client/src/main/adapters/deepseek.ts index 62ece6b..94569bf 100644 --- a/apps/desktop-client/src/main/adapters/deepseek.ts +++ b/apps/desktop-client/src/main/adapters/deepseek.ts @@ -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 { const candidates = [ payload.question_text, @@ -1127,6 +1152,247 @@ async function maybeRevealDeepSeekSources(page: PlaywrightPage): Promise { } } +async function maybeRevealDeepSeekReasoningSearchSources(page: PlaywrightPage): Promise { + 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 { 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, }