From f6e553dcc3a11fe44811c7dfd3012cd11835a940 Mon Sep 17 00:00:00 2001 From: liangxu Date: Thu, 23 Apr 2026 22:54:56 +0800 Subject: [PATCH] feat(monitoring): add DeepSeek monitoring end-to-end - desktop: new DeepSeek page adapter and monitor registry - desktop: extract generic AI auth helpers into shared module and skip binding when challenge signals are present - admin-web: render DeepSeek HTML answers with inline citation anchors and surface reference-number badges on source cards - server: fall back to inline_links/source_panel_links and read text/siteName fields when extracting DeepSeek citations Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/views/TrackingQuestionDetailView.vue | 359 +++- .../desktop-client/src/main/account-binder.ts | 50 +- .../src/main/adapters/deepseek.test.ts | 263 +++ .../src/main/adapters/deepseek.ts | 1817 +++++++++++++++++ .../desktop-client/src/main/adapters/index.ts | 22 + .../src/main/generic-ai-auth.test.ts | 50 + .../src/main/generic-ai-auth.ts | 113 + .../src/main/runtime-controller.ts | 23 +- .../plans/2026-04-23-deepseek-monitoring.md | 274 +++ .../tenant/app/monitoring_callback_service.go | 4 +- .../app/monitoring_callback_service_test.go | 55 + .../internal/tenant/app/monitoring_service.go | 6 + 12 files changed, 2971 insertions(+), 65 deletions(-) create mode 100644 apps/desktop-client/src/main/adapters/deepseek.test.ts create mode 100644 apps/desktop-client/src/main/adapters/deepseek.ts create mode 100644 apps/desktop-client/src/main/generic-ai-auth.test.ts create mode 100644 apps/desktop-client/src/main/generic-ai-auth.ts create mode 100644 docs/superpowers/plans/2026-04-23-deepseek-monitoring.md diff --git a/apps/admin-web/src/views/TrackingQuestionDetailView.vue b/apps/admin-web/src/views/TrackingQuestionDetailView.vue index 1b68d49..ff22461 100644 --- a/apps/admin-web/src/views/TrackingQuestionDetailView.vue +++ b/apps/admin-web/src/views/TrackingQuestionDetailView.vue @@ -138,10 +138,19 @@ type TrackingAnalyzedContentCitation = { citedURL: string | null; }; +type TrackingCitationSourceLookupItem = { + sourceGroupIndex: number; + citation: MonitoringQuestionDetailCitation; +}; + const renderedAnswerContent = computed(() => { return formatAnswerContent(activePlatform.value); }); +const activeDeepSeekCitationReferenceLabels = computed(() => { + return collectDeepSeekCitationReferenceLabels(activePlatform.value); +}); + const activeInlineContentCitations = computed(() => { return analyzeInlineContentCitations(activePlatform.value); }); @@ -208,6 +217,10 @@ function isKimiInlineCitationPlatform(platformId: string | null | undefined): bo return platformId === "kimi"; } +function isDeepSeekInlineCitationPlatform(platformId: string | null | undefined): boolean { + return platformId === "deepseek"; +} + function createQwenSourceGroupPattern(): RegExp { return /\[\[source_group_web_(\d+)\]\]/g; } @@ -228,6 +241,21 @@ function createMarkdownLinkPattern(): RegExp { return /\[([^\]\n]+)\]\((https?:\/\/[^)\s]+)\)/g; } +function looksLikeHtmlAnswer(value: string | null | undefined): boolean { + const normalized = String(value ?? "").trim(); + return /<\/?[a-z][^>]*>/i.test(normalized); +} + +function parseHtmlFragment(content: string): HTMLElement | null { + if (!looksLikeHtmlAnswer(content)) { + return null; + } + + const parser = new DOMParser(); + const document = parser.parseFromString(`
${content}
`, "text/html"); + return document.body.firstElementChild instanceof HTMLElement ? document.body.firstElementChild : null; +} + function collectInlineCitationIndexes(answerText: string): number[] { const matches = [ ...answerText.matchAll(createQwenSourceGroupPattern()), @@ -316,6 +344,136 @@ function buildInlineCitationSourceLookup(platform: MonitoringQuestionDetailPlatf return lookup; } +function buildCitationSourceLookup(platform: MonitoringQuestionDetailPlatform | null): Map { + const lookup = new Map(); + for (const [index, citation] of (platform?.citations ?? []).entries()) { + const normalizedURL = normalizeInlineCitationUrl(citation.cited_url); + if (!normalizedURL || lookup.has(normalizedURL)) { + continue; + } + lookup.set(normalizedURL, { + sourceGroupIndex: index + 1, + citation, + }); + } + return lookup; +} + +function normalizeDeepSeekCitationLabel(value: string | null | undefined): string | null { + const normalized = String(value ?? "").trim().replace(/\s+/g, " "); + if (!normalized) { + return null; + } + + const compact = normalized.replace(/\s+/g, ""); + const numericCandidate = compact.match(/^\[?(-?\d+)\]?$/)?.[1] + ?? compact.match(/^[((【](-?\d+)[】))]$/)?.[1] + ?? compact.match(/^#?(-?\d+)$/)?.[1] + ?? null; + if (numericCandidate) { + return `[${numericCandidate.replace(/^-/, "")}]`; + } + + return null; +} + +function getOrCreateDeepSeekFallbackCitationLabel( + normalizedURL: string, + fallbackLabels: Map, + state: { nextIndex: number }, +): string { + const existing = fallbackLabels.get(normalizedURL); + if (existing) { + return existing; + } + + const nextLabel = `[${state.nextIndex}]`; + fallbackLabels.set(normalizedURL, nextLabel); + state.nextIndex += 1; + return nextLabel; +} + +function pushUniqueDeepSeekCitationLabel(labels: string[], label: string): void { + if (!labels.includes(label)) { + labels.push(label); + } +} + +function shouldStripDeepSeekOrphanAnchor(anchor: HTMLAnchorElement): boolean { + const text = String(anchor.textContent ?? "").trim().replace(/\s+/g, ""); + if (/[A-Za-z0-9\u3400-\u9fff]/.test(text)) { + return false; + } + if (anchor.querySelector("img, svg")) { + return true; + } + return text.length === 0 || /^[\[\](){}<>#.,:;!?+\-_/\\|~`'"“”‘’·•::,。!?;、🔗]+$/.test(text); +} + +function collectDeepSeekCitationReferenceLabels( + platform: MonitoringQuestionDetailPlatform | null, +): string[][] { + if (!isDeepSeekInlineCitationPlatform(platform?.ai_platform_id)) { + return []; + } + + const rawAnswer = String(platform?.answer_text ?? "").trim(); + const root = rawAnswer ? parseHtmlFragment(rawAnswer) : null; + if (!root) { + return (platform?.citations ?? []).map(() => []); + } + + const citationSourceLookup = buildCitationSourceLookup(platform); + const labelsByUrl = new Map(); + const fallbackLabels = new Map(); + const fallbackState = { nextIndex: 1 }; + + for (const anchor of Array.from(root.querySelectorAll("a[href]"))) { + const normalizedURL = normalizeInlineCitationUrl(anchor.getAttribute("href") || anchor.href || ""); + if (!normalizedURL || !citationSourceLookup.has(normalizedURL)) { + continue; + } + + const label = normalizeDeepSeekCitationLabel(anchor.textContent) + ?? getOrCreateDeepSeekFallbackCitationLabel(normalizedURL, fallbackLabels, fallbackState); + const labels = labelsByUrl.get(normalizedURL) ?? []; + pushUniqueDeepSeekCitationLabel(labels, label); + labelsByUrl.set(normalizedURL, labels); + } + + return (platform?.citations ?? []).map((citation) => { + const normalizedURL = normalizeInlineCitationUrl(citation.cited_url); + return normalizedURL ? (labelsByUrl.get(normalizedURL) ?? []) : []; + }); +} + +function resolveCitationReferenceLabels( + citationIndex: number, + platform: MonitoringQuestionDetailPlatform | null, +): string[] { + if (!isDeepSeekInlineCitationPlatform(platform?.ai_platform_id)) { + return []; + } + + return activeDeepSeekCitationReferenceLabels.value[citationIndex] ?? []; +} + +function resolveCitationIndexDisplayLabels( + citationIndex: number, + platform: MonitoringQuestionDetailPlatform | null, +): string[] { + const labels = resolveCitationReferenceLabels(citationIndex, platform); + if (!labels.length) { + return []; + } + + const fallbackLabel = `[${citationIndex + 1}]`; + if (labels.length === 1 && labels[0] === fallbackLabel) { + return []; + } + return labels; +} + function countHanCharacters(value: string): number { return value.match(/[\u3400-\u9fff]/g)?.length ?? 0; } @@ -339,6 +497,10 @@ function sanitizeAnswerBody(answerText: string | null | undefined): string | nul return null; } + if (looksLikeHtmlAnswer(normalized)) { + return normalized; + } + const lines = normalized.split(/\r?\n+/).map((line) => line.trim()).filter(Boolean); const keptLines = lines.filter((line) => !looksLikeNonAnswerNoise(line)); const sanitized = (keptLines.length ? keptLines.join("\n") : normalized) @@ -370,8 +532,80 @@ function buildInlineCitationMarker( return ``; } +function renderDeepSeekAnswerContent( + answerText: string, + platform: MonitoringQuestionDetailPlatform | null, +): string { + const root = parseHtmlFragment(answerText); + if (!root) { + return answerText; + } + + const inlineCitationLookup = buildInlineCitationSourceLookup(platform); + const citationSourceLookup = buildCitationSourceLookup(platform); + const fallbackLabels = new Map(); + const fallbackState = { nextIndex: 1 }; + + for (const anchor of Array.from(root.querySelectorAll("a[href], a"))) { + const rawHref = anchor.hasAttribute("href") + ? (anchor.getAttribute("href") || anchor.href || "") + : ""; + const normalizedURL = normalizeInlineCitationUrl(rawHref); + if (!normalizedURL && !shouldStripDeepSeekOrphanAnchor(anchor)) { + continue; + } + + const source = inlineCitationLookup.get(normalizedURL) ?? null; + const citationSource = citationSourceLookup.get(normalizedURL) ?? null; + if (!source && !citationSource) { + if (shouldStripDeepSeekOrphanAnchor(anchor)) { + anchor.remove(); + } + continue; + } + + anchor.setAttribute("href", normalizedURL); + anchor.setAttribute("target", "_blank"); + anchor.setAttribute("rel", "noreferrer"); + anchor.setAttribute("class", "tracking-inline-citation tracking-inline-citation--content"); + if (citationSource) { + anchor.setAttribute("data-citation-index", String(citationSource.sourceGroupIndex)); + } else { + anchor.removeAttribute("data-citation-index"); + } + + const title = citationSource + ? resolveCitationTitle(citationSource.citation) + : (source?.title?.trim() || ""); + if (title) { + anchor.setAttribute("title", title); + } else { + anchor.removeAttribute("title"); + } + + const displayLabel = normalizeDeepSeekCitationLabel(anchor.textContent) + ?? getOrCreateDeepSeekFallbackCitationLabel(normalizedURL, fallbackLabels, fallbackState); + anchor.textContent = displayLabel; + } + + return root.innerHTML.trim() || answerText; +} + function formatAnswerContent(platform: MonitoringQuestionDetailPlatform | null): string | null { - const sanitized = sanitizeAnswerBody(platform?.answer_text); + const rawAnswer = String(platform?.answer_text ?? "").trim(); + if (!rawAnswer) { + return null; + } + + if (isDeepSeekInlineCitationPlatform(platform?.ai_platform_id) && looksLikeHtmlAnswer(rawAnswer)) { + return renderDeepSeekAnswerContent(rawAnswer, platform); + } + + if (looksLikeHtmlAnswer(rawAnswer)) { + return rawAnswer; + } + + const sanitized = sanitizeAnswerBody(rawAnswer); if (!sanitized) { return null; } @@ -394,6 +628,54 @@ function formatAnswerContent(platform: MonitoringQuestionDetailPlatform | null): .trim(); } +function collectDeepSeekInlineCitationOccurrences( + answerText: string, + platform: MonitoringQuestionDetailPlatform | null, +): Array<{ + url: string; + label: string | null; + siteName: string | null; + citationCount: number; +}> { + const root = parseHtmlFragment(answerText); + if (!root) { + return []; + } + + const inlineCitationLookup = buildInlineCitationSourceLookup(platform); + if (!inlineCitationLookup.size) { + return []; + } + + const counts = new Map(); + for (const anchor of Array.from(root.querySelectorAll("a[href]"))) { + const normalizedURL = normalizeInlineCitationUrl(anchor.getAttribute("href") || anchor.href || ""); + if (!normalizedURL || !inlineCitationLookup.has(normalizedURL)) { + continue; + } + + const source = inlineCitationLookup.get(normalizedURL) ?? null; + const existing = counts.get(normalizedURL); + if (existing) { + existing.citationCount += 1; + continue; + } + + counts.set(normalizedURL, { + label: source?.title?.trim() || null, + siteName: source?.site_name?.trim() || null, + citationCount: 1, + }); + } + + return Array.from(counts.entries()).map(([url, value]) => ({ + url, + label: value.label, + siteName: value.siteName, + citationCount: value.citationCount, + })); +} + function analyzeInlineContentCitations( platform: MonitoringQuestionDetailPlatform | null, ): TrackingAnalyzedContentCitation[] { @@ -402,6 +684,34 @@ function analyzeInlineContentCitations( return []; } + if (isDeepSeekInlineCitationPlatform(platform?.ai_platform_id)) { + const occurrences = collectDeepSeekInlineCitationOccurrences(answerText, platform); + if (!occurrences.length) { + return []; + } + + const totalCitationCount = occurrences.reduce((sum, item) => sum + item.citationCount, 0); + if (!totalCitationCount) { + return []; + } + + return occurrences + .sort((left, right) => { + if (right.citationCount !== left.citationCount) { + return right.citationCount - left.citationCount; + } + return left.url.localeCompare(right.url, "zh-CN"); + }) + .map((item) => ({ + key: `deepseek-${item.url}`, + contentName: item.label || item.url, + channelName: item.siteName || deriveCitationChannelName(item.url), + citationCount: item.citationCount, + citationRate: item.citationCount / totalCitationCount, + citedURL: item.url, + })); + } + if (isKimiInlineCitationPlatform(platform?.ai_platform_id)) { const occurrences = collectKimiInlineCitationOccurrences(answerText); if (!occurrences.length) { @@ -682,7 +992,21 @@ const contentCitationColumns = computed(() => [ :data-citation-index="index + 1" >
- [{{ index + 1 }}] +
+ + {{ label }} + + + [{{ index + 1 }}] + +
{{ citation.site_name }} · {{ resolveCitationTitle(citation) }} @@ -1076,6 +1400,13 @@ const contentCitationColumns = computed(() => [ width: 100%; } +.tracking-question-source-card__indexes { + display: inline-flex; + align-items: center; + gap: 6px; + flex-shrink: 0; +} + .tracking-question-source-card__index { display: inline-flex; align-items: center; @@ -1091,6 +1422,29 @@ const contentCitationColumns = computed(() => [ flex-shrink: 0; } +.tracking-question-source-card__index--secondary { + background: #f8fafc; + color: #64748b; + border: 1px solid #e2e8f0; +} + +.tracking-question-source-card__reference-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 34px; + height: 24px; + padding: 0 8px; + border-radius: 999px; + border: 1px solid #bfdbfe; + background: #eff6ff; + color: #2563eb; + font-size: 12px; + font-weight: 700; + line-height: 1; + flex-shrink: 0; +} + .tracking-question-source-card__headline strong { display: block; color: #2563eb; @@ -1151,6 +1505,7 @@ const contentCitationColumns = computed(() => [ line-height: 1; vertical-align: baseline; cursor: pointer; + text-decoration: none; transition: all 0.18s ease; } diff --git a/apps/desktop-client/src/main/account-binder.ts b/apps/desktop-client/src/main/account-binder.ts index a288aaa..c6ea239 100644 --- a/apps/desktop-client/src/main/account-binder.ts +++ b/apps/desktop-client/src/main/account-binder.ts @@ -31,6 +31,11 @@ import { isWindowReadyForDetection, loadWindowURLSafely, } from "./external-window"; +import { + buildGenericAIPageFingerprintFallback, + genericAIPageLooksAuthenticated, + type GenericAIPageState, +} from "./generic-ai-auth"; import { upsertDesktopAccount } from "./transport/api-client"; import { STANDARD_USER_AGENT } from "./user-agent"; import { @@ -681,18 +686,6 @@ function sanitizeDetectedAccount(account: DetectedAccount): DetectedAccount { }; } -interface GenericAIPageState { - displayName: string | null; - avatarUrl: string | null; - fingerprint: string | null; - credentialFingerprint: string | null; - currentPath: string | null; - strongAuthSignalCount: number; - loginSignalCount: number; - loggedOutSignalCount: number; - challengeSignalCount: number; -} - async function readGenericAIPageState( webContents: WebContents | null | undefined, ): Promise { @@ -1004,10 +997,6 @@ function safeParseURL(input: string): URL | null { } } -function genericAIConversationPath(pathname: string): boolean { - return /^\/(?:chat|c|conversation)(?:\/|$)/i.test(pathname); -} - function isLikelyGenericAICredentialName(name: string): boolean { const normalized = name.trim().toLowerCase(); if (/^(x[-_]?csrf[-_]?token|xsrf[-_]?token|csrf[-_]?token|csrf|_csrf)$/i.test(normalized)) { @@ -1029,29 +1018,6 @@ function isLikelyGenericAICredentialValue(value: string | null | undefined): boo return normalized.length >= 8; } -function genericAIPageLooksAuthenticated(currentURL: string, pageState: GenericAIPageState | null): boolean { - const current = safeParseURL(currentURL); - const currentPath = normalizeURLPath(current?.pathname ?? pageState?.currentPath ?? ""); - const strongAuthSignalCount = pageState?.strongAuthSignalCount ?? 0; - const loginSignalCount = pageState?.loginSignalCount ?? 0; - const loggedOutSignalCount = pageState?.loggedOutSignalCount ?? 0; - const onConversationPath = genericAIConversationPath(currentPath); - - if (loggedOutSignalCount > 0) { - return false; - } - - if (loginSignalCount > 0 && strongAuthSignalCount === 0 && !onConversationPath) { - return false; - } - - if (loginSignalCount > 0) { - return strongAuthSignalCount >= 2 && !onConversationPath; - } - - return strongAuthSignalCount > 0 || onConversationPath; -} - async function buildGenericAISessionFingerprint( platformId: string, session: Session, @@ -1087,7 +1053,7 @@ async function buildGenericAISessionFingerprint( } if (parts.length === 0) { - return null; + return buildGenericAIPageFingerprintFallback(platformId, pageState); } return boundedIdentity(`${platformId}-session`, parts.join("||")); @@ -1115,6 +1081,9 @@ async function detectGenericAIPlatform( } const pageState = await readGenericAIPageState(context.webContents).catch(() => null); + if ((pageState?.challengeSignalCount ?? 0) > 0) { + return null; + } if (!genericAIPageLooksAuthenticated(currentURL, pageState)) { return null; } @@ -2692,6 +2661,7 @@ export async function bindPublishAccount(platformId: string): Promise { + syncDetectionState(); const currentURL = window.isDestroyed() ? "" : window.webContents.getURL(); const canProbe = detectReady || /^https?:\/\//i.test(currentURL); if (finished || checking || !canProbe || window.isDestroyed()) { diff --git a/apps/desktop-client/src/main/adapters/deepseek.test.ts b/apps/desktop-client/src/main/adapters/deepseek.test.ts new file mode 100644 index 0000000..b7bce49 --- /dev/null +++ b/apps/desktop-client/src/main/adapters/deepseek.test.ts @@ -0,0 +1,263 @@ +import { describe, expect, it } from "vitest"; + +import { __deepseekTestUtils } from "./deepseek"; +import { getMonitorAdapter } from "./index"; + +const { + extractQuestionText, + buildSourceItem, + buildDeepSeekPayload, + detectDeepSeekBusySignals, + dedupeSourceItems, + classifyDeepseekSources, + mergeDeepSeekRawSourceLinks, + isObservationComplete, + isDeepSeekToggleSelected, + isDeepSeekWebpagesTriggerText, +} = __deepseekTestUtils; + +describe("deepseek adapter helpers", () => { + it("extracts question text from monitoring payload", () => { + expect(extractQuestionText({ question_text: "DeepSeek 怎么看 GEO?" })).toBe("DeepSeek 怎么看 GEO?"); + }); + + it("dedupes sources by normalized url without hash fragments", () => { + const items = dedupeSourceItems([ + { + url: "https://example.com/a#top", + title: "A", + normalized_url: "https://example.com/a#top", + }, + { + url: "https://example.com/a#bottom", + title: "A later", + normalized_url: "https://example.com/a#bottom", + }, + ]); + + expect(items).toHaveLength(1); + expect(items[0]?.normalized_url).toBe("https://example.com/a"); + expect(items[0]?.title).toBe("A"); + }); + + it("treats revealed search cards as citation sources and inline links as content citation candidates", () => { + const classified = classifyDeepseekSources({ + inlineLinks: [ + { + url: "https://example.com/citation", + title: "Citation", + normalized_url: "https://example.com/citation", + }, + ], + sourcePanelLinks: [], + searchPanelLinks: [ + { + url: "https://example.com/search", + title: "Search", + normalized_url: "https://example.com/search", + }, + ], + }); + + expect(classified.citations).toHaveLength(1); + expect(classified.inlineCitationCandidates).toHaveLength(1); + expect(classified.searchResults).toHaveLength(1); + expect(classified.citations[0]?.url).toBe("https://example.com/search"); + expect(classified.inlineCitationCandidates[0]?.url).toBe("https://example.com/citation"); + expect(classified.searchResults[0]?.url).toBe("https://example.com/search"); + }); + + it("enriches numeric inline citations with source metadata from revealed search cards", () => { + const classified = classifyDeepseekSources({ + inlineLinks: [ + { + url: "https://example.com/citation", + title: null, + text: "-1", + siteName: null, + kind: "inline", + }, + ], + sourcePanelLinks: [], + searchPanelLinks: [ + { + url: "https://example.com/citation", + title: "DeepSeek source article", + text: "Snippet", + siteName: "Example", + kind: "search", + }, + ], + }); + + expect(classified.inlineCitationCandidates).toHaveLength(1); + expect(classified.inlineCitationCandidates[0]?.title).toBe("DeepSeek source article"); + expect(classified.inlineCitationCandidates[0]?.site_name).toBe("Example"); + expect(classified.citations).toHaveLength(1); + expect(classified.searchResults).toHaveLength(1); + }); + + it("retains all revealed DeepSeek search results instead of truncating after 32 items", () => { + const searchPanelLinks = Array.from({ length: 78 }, (_, index) => ({ + url: `https://example.com/search-${index + 1}`, + title: `Search ${index + 1}`, + text: `Snippet ${index + 1}`, + siteName: "Example", + kind: "search" as const, + })); + + const classified = classifyDeepseekSources({ + inlineLinks: [], + sourcePanelLinks: [], + searchPanelLinks, + }); + + expect(classified.citations).toHaveLength(78); + expect(classified.searchResults).toHaveLength(78); + }); + + it("merges revealed sidebar search links with the snapshot search links by normalized url", () => { + const merged = mergeDeepSeekRawSourceLinks( + [ + { + url: "https://example.com/report#top", + title: null, + text: "Visible title", + siteName: null, + kind: "search", + }, + ], + [ + { + url: "https://example.com/report#bottom", + title: "Merged title", + text: "Merged snippet", + siteName: "Example", + kind: "search", + }, + { + url: "https://example.com/extra", + title: "Extra result", + text: "Extra snippet", + siteName: "Example", + kind: "search", + }, + ], + ); + + expect(merged).toHaveLength(2); + expect(merged[0]).toEqual(expect.objectContaining({ + url: "https://example.com/report", + title: "Merged title", + text: "Visible title", + siteName: "Example", + })); + expect(merged[1]?.url).toBe("https://example.com/extra"); + }); + + it("normalizes deepseek raw source links into monitoring source items", () => { + const item = buildSourceItem({ + url: "https://example.com/report#cite-1", + title: null, + text: "DeepSeek source title", + siteName: "Example", + kind: "source", + }); + + expect(item).toEqual(expect.objectContaining({ + url: "https://example.com/report", + normalized_url: "https://example.com/report", + title: "DeepSeek source title", + site_name: "Example", + host: "example.com", + })); + }); + + it("does not use numeric citation markers as source titles", () => { + const item = buildSourceItem({ + url: "https://example.com/report#cite-1", + title: null, + text: "-1", + siteName: null, + kind: "inline", + }); + + expect(item?.title).toBeNull(); + expect(item?.site_name).toBe("example.com"); + }); + + it("treats a stable answer as complete even without citations", () => { + expect(isObservationComplete({ + answer: "这是最终答案", + busy: false, + loginRequired: false, + challengeRequired: false, + signature: "sig-1", + })).toBe(true); + }); + + it("ignores source-logo loading placeholders when computing busy signals", () => { + expect(detectDeepSeekBusySignals({ + bodyText: "已阅读 10 个网页", + descriptors: [ + "site_logo_loading site_logo_fallback", + "site_logo_loading site_logo_fallback", + ], + })).toEqual([]); + }); + + it("recognizes deepseek toggle selected state from class names", () => { + expect(isDeepSeekToggleSelected("ds-toggle-button ds-toggle-button--selected")).toBe(true); + expect(isDeepSeekToggleSelected("ds-toggle-button")).toBe(false); + }); + + it("only treats the bottom 网页 pill as the DeepSeek source trigger", () => { + expect(isDeepSeekWebpagesTriggerText("57 个网页")).toBe(true); + expect(isDeepSeekWebpagesTriggerText("已阅读 57 个网页")).toBe(true); + expect(isDeepSeekWebpagesTriggerText("搜索到 39 个网页")).toBe(false); + }); + + it("builds json-safe payloads with null placeholders instead of undefined", () => { + const payload = buildDeepSeekPayload({ + url: "https://chat.deepseek.com/", + title: "DeepSeek", + loginRequired: false, + loginReason: null, + challengeRequired: false, + challengeReason: null, + busy: false, + busySignals: [], + composerValue: null, + sendDisabled: false, + answer: "Final answer", + answerText: "Final answer", + reasoning: null, + signature: "sig-1", + currentModelLabel: null, + providerRequestID: null, + requestID: null, + inlineLinks: [], + sourcePanelLinks: [], + searchPanelLinks: [], + }, { + citations: [], + inlineCitationCandidates: [], + searchResults: [], + }, "Final answer"); + + expect(payload.provider_model).toBeNull(); + expect(payload.provider_request_id).toBeNull(); + expect(payload.request_id).toBeNull(); + expect((payload.raw_response_json as Record).provider_model).toBeNull(); + expect((payload.raw_response_json as Record).provider_request_id).toBeNull(); + expect((payload.raw_response_json as Record).request_id).toBeNull(); + expect((payload.raw_response_json as Record).signature).toBe("sig-1"); + expect((payload.raw_response_json as Record).inline_citation_candidates).toEqual([]); + }); +}); + +describe("monitor adapter registry", () => { + it("registers deepseek as a monitor adapter", () => { + expect(getMonitorAdapter("deepseek")?.provider).toBe("deepseek"); + }); +}); diff --git a/apps/desktop-client/src/main/adapters/deepseek.ts b/apps/desktop-client/src/main/adapters/deepseek.ts new file mode 100644 index 0000000..bfda460 --- /dev/null +++ b/apps/desktop-client/src/main/adapters/deepseek.ts @@ -0,0 +1,1817 @@ +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 DEEPSEEK_BOOTSTRAP_URL = "https://chat.deepseek.com/"; +const DEEPSEEK_PAGE_READY_TIMEOUT_MS = 20_000; +const DEEPSEEK_QUERY_TIMEOUT_MS = 120_000; +const DEEPSEEK_QUERY_POLL_INTERVAL_MS = 1_200; +const DEEPSEEK_STABLE_POLLS_REQUIRED = 4; +const DEEPSEEK_MAX_STATE_LINKS = 96; +const DEEPSEEK_MAX_SEARCH_RESULT_LINKS = 96; +const DEEPSEEK_MAX_CITATION_LINKS = 96; +const DEEPSEEK_MAX_INLINE_CITATION_LINKS = 32; +const DEEPSEEK_SEARCH_RESULTS_BUTTON_PATTERN = /view search results|search results|搜索结果|查看搜索结果/i; +const DEEPSEEK_SEARCH_RESULTS_HEADING_PATTERN = /^(?:搜索结果|search results)$/i; +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_LOGIN_PATTERN = /(log in|send code|scan with wechat to login|phone number|continue with deepseek|和 deepseek 继续聊|登录|发送验证码|手机号|验证码)/i; +const DEEPSEEK_CHALLENGE_PATTERN = /(verification required|captcha|security check|verify|challenge|人机验证|安全验证|请完成验证|验证码|风控|环境异常)/i; +const DEEPSEEK_BUSY_TEXT_PATTERN = /(searching|thinking|generating|please wait until the message is generated|搜索中|思考中|生成中|停止生成|stop generating)/i; +const DEEPSEEK_BUSY_DESCRIPTOR_PATTERN = /(searching|thinking|generating|stop generating|loading|spinner|搜索中|思考中|生成中|停止生成)/i; +const DEEPSEEK_BUSY_PLACEHOLDER_PATTERN = /(site_logo_loading|site_logo_fallback)/i; +const DEEPSEEK_TOGGLE_SELECTED_PATTERN = /(ds-toggle-button--selected|(^|[\s_-])(active|selected|checked|current|on)([\s_-]|$))/i; +const DEEPSEEK_DEEP_THINK_PATTERN = /深度思考|deep\s*think|reasoner/i; + +type DeepSeekRawSourceLink = { + url: string; + title: string | null; + text: string | null; + siteName: string | null; + kind: "inline" | "source" | "search"; +}; + +type DeepSeekSourceInput = DeepSeekRawSourceLink | MonitoringSourceItem; + +type DeepSeekLinkKind = DeepSeekRawSourceLink["kind"]; + +type DeepSeekWindowState = Window & typeof globalThis & { + __NEXT_DATA__?: unknown; + __INITIAL_STATE__?: unknown; + __APP_STATE__?: unknown; + __NUXT__?: unknown; + __STORE__?: { + getState?: () => unknown; + }; +}; + +type DeepSeekPageSnapshot = { + url: string; + title: string | null; + loginRequired: boolean; + loginReason: string | null; + challengeRequired: boolean; + challengeReason: string | null; + busy: boolean; + busySignals: string[]; + composerValue: string | null; + sendDisabled: boolean | null; + answer: string | null; + answerText: string | null; + reasoning: string | null; + signature: string | null; + currentModelLabel: string | null; + providerRequestID: string | null; + requestID: string | null; + inlineLinks: DeepSeekRawSourceLink[]; + sourcePanelLinks: DeepSeekRawSourceLink[]; + searchPanelLinks: DeepSeekRawSourceLink[]; +}; + +type DeepSeekPageSnapshotDraft = Omit & { + bodyText: string | null; + busyDescriptors: string[]; + signatureParts: string[]; +}; + +type DeepSeekClassifiedSources = { + inlineCitationCandidates: MonitoringSourceItem[]; + citations: MonitoringSourceItem[]; + searchResults: MonitoringSourceItem[]; +}; + +type DeepSeekWaitResult = + | { + ok: true; + finalSnapshot: DeepSeekPageSnapshot; + stablePollCount: number; + elapsedMs: number; + } + | { + ok: false; + error: string; + detail: string | null; + finalSnapshot: DeepSeekPageSnapshot; + stablePollCount: number; + elapsedMs: number; + }; + +function isRecord(value: unknown): value is Record { + 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 normalizeUrl(value: unknown): string | null { + const input = normalizeText(value); + if (!input) { + return null; + } + + try { + const url = new URL(input); + if (!/^https?:$/i.test(url.protocol)) { + return null; + } + url.hash = ""; + return url.toString(); + } catch { + return null; + } +} + +function normalizeSourceTitle(value: unknown): string | null { + const title = normalizeText(value); + if (!title) { + return null; + } + + const markerCandidate = title + .replace(/\s+/g, "") + .replace(/^[\[({(【]+/, "") + .replace(/[\])})】]+$/, ""); + if (/^[-–—]?\d+$/.test(markerCandidate)) { + return null; + } + + return title; +} + +function getSourceNormalizedUrl(source: DeepSeekSourceInput): string | null | undefined { + return "normalized_url" in source ? source.normalized_url : null; +} + +function getSourceText(source: DeepSeekSourceInput): string | null | undefined { + return "text" in source ? source.text : null; +} + +function getSourceSiteName(source: DeepSeekSourceInput): string | null | undefined { + if ("site_name" in source) { + return source.site_name; + } + if ("siteName" in source) { + return source.siteName; + } + return null; +} + +function mergeSourceItemMetadata( + item: MonitoringSourceItem, + metadata: MonitoringSourceItem | null | undefined, +): MonitoringSourceItem { + if (!metadata) { + return item; + } + + return { + ...item, + title: item.title ?? metadata.title ?? null, + site_name: + (item.site_name && item.site_name !== item.host ? item.site_name : null) + ?? metadata.site_name + ?? item.site_name + ?? null, + host: item.host ?? metadata.host ?? null, + }; +} + +function enrichSourceItemsWithMetadata( + items: MonitoringSourceItem[], + metadataItems: MonitoringSourceItem[], +): MonitoringSourceItem[] { + const metadataByUrl = new Map( + metadataItems + .map((item) => [(item.normalized_url ?? item.url), item] as const) + .filter(([key]) => Boolean(key)), + ); + + return items.map((item) => mergeSourceItemMetadata(item, metadataByUrl.get(item.normalized_url ?? item.url))); +} + +function detectDeepSeekBusySignals(input: { + bodyText: string | null | undefined; + descriptors: Array; +}): string[] { + const busySignals: string[] = []; + + if (input.bodyText && DEEPSEEK_BUSY_TEXT_PATTERN.test(input.bodyText)) { + busySignals.push("body_text"); + } + + for (const descriptor of input.descriptors) { + const normalized = normalizeText(descriptor); + if (!normalized || DEEPSEEK_BUSY_PLACEHOLDER_PATTERN.test(normalized)) { + continue; + } + if (!DEEPSEEK_BUSY_DESCRIPTOR_PATTERN.test(normalized)) { + continue; + } + busySignals.push(normalized); + if (busySignals.length >= 6) { + break; + } + } + + return busySignals; +} + +function isDeepSeekToggleSelected(value: string | null | undefined): boolean { + const normalized = normalizeText(value); + if (!normalized) { + return false; + } + + return DEEPSEEK_TOGGLE_SELECTED_PATTERN.test(normalized); +} + +function isDeepSeekWebpagesTriggerText(value: string | null | undefined): boolean { + const normalized = normalizeText(value); + if (!normalized) { + return false; + } + + return DEEPSEEK_WEBPAGES_TRIGGER_ZH_PATTERN.test(normalized) + || DEEPSEEK_WEBPAGES_TRIGGER_EN_PATTERN.test(normalized); +} + +function buildSourceItem(source: DeepSeekSourceInput): MonitoringSourceItem | null { + const url = normalizeUrl(getSourceNormalizedUrl(source) ?? source.url); + if (!url) { + return null; + } + + let host: string | null = null; + try { + host = new URL(url).hostname || null; + } catch { + host = null; + } + + const title = normalizeSourceTitle(source.title) ?? normalizeSourceTitle(getSourceText(source)); + const siteName = normalizeText(getSourceSiteName(source) ?? host); + return { + url, + title, + site_name: siteName, + normalized_url: url, + host, + }; +} + +function dedupeSourceItems(items: Array): MonitoringSourceItem[] { + const keyed = new Map(); + + for (const item of items) { + const built = buildSourceItem(item); + if (!built) { + continue; + } + + const key = built.normalized_url ?? built.url; + if (!key) { + continue; + } + + const existing = keyed.get(key); + if (!existing) { + keyed.set(key, built); + continue; + } + + keyed.set(key, { + ...existing, + title: existing.title ?? built.title ?? null, + site_name: existing.site_name ?? built.site_name ?? null, + host: existing.host ?? built.host ?? null, + }); + } + + return Array.from(keyed.values()); +} + +function mergeDeepSeekRawSourceLinks(...groups: DeepSeekRawSourceLink[][]): DeepSeekRawSourceLink[] { + const keyed = new Map(); + + for (const group of groups) { + for (const item of group) { + const url = normalizeUrl(item.url); + if (!url) { + continue; + } + + const key = `${item.kind}:${url}`; + const existing = keyed.get(key); + const normalizedItem: DeepSeekRawSourceLink = { + ...item, + url, + }; + if (!existing) { + keyed.set(key, normalizedItem); + continue; + } + + keyed.set(key, { + ...existing, + title: existing.title ?? normalizedItem.title ?? null, + text: existing.text ?? normalizedItem.text ?? null, + siteName: existing.siteName ?? normalizedItem.siteName ?? null, + }); + } + } + + return Array.from(keyed.values()); +} + +function classifyDeepseekSources(input: { + inlineLinks: Array; + sourcePanelLinks: Array; + searchPanelLinks: Array; +}): DeepSeekClassifiedSources { + const searchResultsWithMetadata = dedupeSourceItems(input.searchPanelLinks); + const inlineCitationCandidates = enrichSourceItemsWithMetadata(dedupeSourceItems([ + ...input.inlineLinks, + ...input.sourcePanelLinks, + ]), searchResultsWithMetadata).slice(0, DEEPSEEK_MAX_INLINE_CITATION_LINKS); + const citations = searchResultsWithMetadata.slice(0, DEEPSEEK_MAX_CITATION_LINKS); + const searchResults = searchResultsWithMetadata.slice(0, DEEPSEEK_MAX_SEARCH_RESULT_LINKS); + + return { + inlineCitationCandidates, + citations, + searchResults, + }; +} + +function extractQuestionText(payload: Record): 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("deepseek_question_text_missing"); +} + +function buildAdapterError( + code: string, + message: string, + extras: Record = {}, +): Record { + return { + code, + message, + ...extras, + }; +} + +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 = {}; + 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 buildDeepSeekRawResponse( + snapshot: DeepSeekPageSnapshot, + classifiedSources: DeepSeekClassifiedSources, + answer: string | null, +): Record { + return { + platform: "deepseek", + page_url: snapshot.url, + page_title: snapshot.title ?? null, + provider_model: snapshot.currentModelLabel ?? null, + provider_request_id: snapshot.providerRequestID ?? null, + request_id: snapshot.requestID ?? snapshot.providerRequestID ?? null, + answer, + answer_text: snapshot.answerText ?? null, + reasoning: snapshot.reasoning ?? null, + busy: snapshot.busy, + busy_signals: snapshot.busySignals, + send_disabled: snapshot.sendDisabled, + signature: snapshot.signature ?? null, + citation_count: classifiedSources.citations.length, + inline_citation_candidate_count: classifiedSources.inlineCitationCandidates.length, + search_result_count: classifiedSources.searchResults.length, + inline_citation_candidates: toJsonSources(classifiedSources.inlineCitationCandidates), + citations: toJsonSources(classifiedSources.citations), + search_results: toJsonSources(classifiedSources.searchResults), + inline_links: toJsonValue(snapshot.inlineLinks), + source_panel_links: toJsonValue(snapshot.sourcePanelLinks), + search_panel_links: toJsonValue(snapshot.searchPanelLinks), + }; +} + +function buildDeepSeekPayload( + snapshot: DeepSeekPageSnapshot, + classifiedSources: DeepSeekClassifiedSources, + answer: string | null, +): Record { + return { + platform: "deepseek", + provider_model: snapshot.currentModelLabel ?? null, + provider_request_id: snapshot.providerRequestID ?? null, + request_id: snapshot.requestID ?? snapshot.providerRequestID ?? null, + answer, + citations: toJsonSources(classifiedSources.citations), + search_results: toJsonSources(classifiedSources.searchResults), + raw_response_json: buildDeepSeekRawResponse(snapshot, classifiedSources, answer), + }; +} + +function answerScore(snapshot: DeepSeekPageSnapshot): number { + const answerLength = snapshot.answerText?.length ?? snapshot.answer?.length ?? 0; + const linkCount = + snapshot.inlineLinks.length + + snapshot.sourcePanelLinks.length + + snapshot.searchPanelLinks.length; + return answerLength * 8 + linkCount * 13 + (snapshot.reasoning?.length ?? 0); +} + +function isObservationComplete(snapshot: { + answer: string | null; + busy: boolean; + loginRequired: boolean; + challengeRequired: boolean; + signature: string | null; +}): boolean { + return Boolean( + snapshot.answer + && snapshot.signature + && !snapshot.busy + && !snapshot.loginRequired + && !snapshot.challengeRequired, + ); +} + +function safePageURL(page: PlaywrightPage): string { + try { + return page.url(); + } catch { + return ""; + } +} + +async function sleep(ms: number, signal?: AbortSignal): Promise { + if (signal?.aborted) { + throw new Error("adapter_aborted"); + } + + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + signal?.removeEventListener("abort", onAbort); + resolve(); + }, ms); + + const onAbort = () => { + clearTimeout(timer); + reject(new Error("adapter_aborted")); + }; + + signal?.addEventListener("abort", onAbort, { once: true }); + }); +} + +async function ensurePageOnDeepSeekChat(page: PlaywrightPage, signal: AbortSignal): Promise { + if (signal.aborted) { + throw new Error("adapter_aborted"); + } + + const currentURL = safePageURL(page); + if (!currentURL || !currentURL.startsWith("https://chat.deepseek.com/")) { + await page.goto(DEEPSEEK_BOOTSTRAP_URL, { + waitUntil: "domcontentloaded", + timeout: DEEPSEEK_PAGE_READY_TIMEOUT_MS, + }); + } + + await page.waitForLoadState("domcontentloaded", { + timeout: DEEPSEEK_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 resolveDeepSeekEditor( + page: PlaywrightPage, +): Promise<{ locator: ReturnType; mode: "textarea" | "contenteditable" }> { + const candidates: Array<{ selector: string; mode: "textarea" | "contenteditable" }> = [ + { selector: "textarea.ds-textarea", mode: "textarea" }, + { selector: ".ds-textarea textarea", mode: "textarea" }, + { selector: "textarea[placeholder*='DeepSeek']", mode: "textarea" }, + { selector: "textarea[placeholder*='消息']", mode: "textarea" }, + { selector: "textarea", mode: "textarea" }, + { selector: "[role='textbox'][contenteditable='true']", mode: "contenteditable" }, + { selector: "[contenteditable='true'][data-lexical-editor='true']", mode: "contenteditable" }, + { selector: "div[contenteditable='true']", mode: "contenteditable" }, + ]; + + for (const candidate of candidates) { + const locator = page.locator(candidate.selector).first(); + const count = await locator.count().catch(() => 0); + if (!count) { + continue; + } + const visible = await locator.isVisible().catch(() => false); + if (!visible) { + continue; + } + + return { + locator, + mode: candidate.mode, + }; + } + + throw new Error("deepseek_editor_not_found"); +} + +async function tryClickDeepSeekSendButton( + page: PlaywrightPage, + editor: ReturnType, +): Promise { + return await editor.evaluate((node) => { + const isVisible = (value: Element | null): value is HTMLElement => { + if (!(value instanceof HTMLElement)) { + return false; + } + const style = window.getComputedStyle(value); + if (style.display === "none" || style.visibility === "hidden" || style.opacity === "0") { + return false; + } + const rect = value.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + }; + + const root = + node.closest("form, [class*='textarea'], [class*='input'], [class*='editor'], [class*='compose']") + ?? node.parentElement + ?? document.body; + + const buttons = Array.from(root.querySelectorAll("button, [role='button']")); + for (const button of buttons.reverse()) { + if (!isVisible(button)) { + continue; + } + + const htmlButton = button as HTMLButtonElement; + if ("disabled" in htmlButton && htmlButton.disabled) { + continue; + } + + htmlButton.click(); + return true; + } + + return false; + }).catch(() => false); +} + +async function ensureDeepSeekToggleEnabled( + page: PlaywrightPage, + labelPattern: RegExp, + signal: AbortSignal, +): Promise { + if (signal.aborted) { + throw new Error("adapter_aborted"); + } + + const clicked = await page.evaluate(({ source, flags }) => { + const pattern = new RegExp(source, flags); + 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 isSelected = (element: HTMLElement | null): boolean => { + if (!element) { + return false; + } + + const attrs = [ + element.getAttribute("aria-pressed"), + element.getAttribute("aria-checked"), + element.getAttribute("data-state"), + element.getAttribute("data-selected"), + element.getAttribute("data-active"), + ] + .map((value) => normalize(value)?.toLowerCase() ?? null) + .filter((value): value is string => value !== null); + if (attrs.some((value) => ["true", "checked", "selected", "active", "on"].includes(value))) { + return true; + } + if (attrs.some((value) => ["false", "unchecked", "off"].includes(value))) { + return false; + } + + const className = normalize(String(element.className || "")) ?? ""; + return /(ds-toggle-button--selected|(^|[\s_-])(active|selected|checked|current|on)([\s_-]|$))/i.test(className); + }; + + const candidates = Array.from(document.querySelectorAll( + "button, [role='button'], [class*='toggle-button'], [class*='toggle']", + )).filter(isVisible); + const target = candidates.find((node) => { + const text = normalize(node.innerText || node.textContent || node.getAttribute("aria-label") || ""); + return Boolean(text && pattern.test(text)); + }); + if (!target || isSelected(target)) { + return false; + } + + target.click(); + return true; + }, { + source: labelPattern.source, + flags: labelPattern.flags, + }).catch(() => false); + + if (clicked) { + await page.waitForTimeout(180); + } +} + +async function submitDeepSeekQuestion( + page: PlaywrightPage, + questionText: string, + signal: AbortSignal, +): Promise { + if (signal.aborted) { + throw new Error("adapter_aborted"); + } + + const { locator, mode } = await resolveDeepSeekEditor(page); + await ensureDeepSeekToggleEnabled(page, DEEPSEEK_DEEP_THINK_PATTERN, signal).catch(() => undefined); + await locator.click({ timeout: 5_000 }); + + if (mode === "textarea") { + await locator.fill(questionText); + } else { + await page.keyboard.press("Meta+A").catch(() => undefined); + await page.keyboard.press("Control+A").catch(() => undefined); + await page.keyboard.press("Backspace").catch(() => undefined); + await page.keyboard.insertText(questionText); + } + + await page.keyboard.press("Enter").catch(() => undefined); + await sleep(300, signal); + + const snapshot = await readDeepSeekPageSnapshot(page, questionText).catch(() => null); + const composerStillFilled = normalizeText(snapshot?.composerValue); + if (composerStillFilled && composerStillFilled.includes(questionText)) { + const clicked = await tryClickDeepSeekSendButton(page, locator); + if (!clicked) { + throw new Error("deepseek_send_button_unavailable"); + } + } +} + +async function maybeRevealDeepSeekSources(page: PlaywrightPage): Promise { + if (await hasDeepSeekSearchResultsPanel(page)) { + return; + } + + const clickedWebpagesTrigger = 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); + if (!text) { + return false; + } + return /^(?:已阅读\s*)?\d+\s*个网页$/i.test(text) + || /^(?:(?:read|viewed)\s*)?\d+\s*(?:web\s*pages?|webpages?)$/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 findClickTarget = (node: HTMLElement): HTMLElement | null => { + let current: HTMLElement | null = node; + for (let depth = 0; current && depth < 6; depth += 1) { + if (isVisible(current) && (isClickable(current) || isTriggerText(current.textContent || current.getAttribute("aria-label") || ""))) { + return current; + } + current = current.parentElement; + } + return null; + }; + + let bestTarget: HTMLElement | null = null; + let bestScore = -1; + for (const node of Array.from(document.querySelectorAll("span, div, button, a")).slice(0, 800)) { + if (!(node instanceof HTMLElement) || !isVisible(node)) { + continue; + } + const text = normalize(node.textContent || node.getAttribute("aria-label") || ""); + if (!isTriggerText(text)) { + continue; + } + + const target = findClickTarget(node); + if (!(target instanceof HTMLElement)) { + continue; + } + + const rect = target.getBoundingClientRect(); + let score = 0; + if (isClickable(target)) { + score += 16; + } + if (normalize(target.textContent || target.getAttribute("aria-label") || "") === text) { + score += 12; + } + if (rect.top >= window.innerHeight * 0.35) { + score += 8; + } + if (rect.width <= 320) { + score += 4; + } + if (rect.left >= window.innerWidth * 0.2) { + score += 2; + } + if (score > bestScore) { + bestScore = score; + bestTarget = target; + } + } + + bestTarget?.click(); + return bestTarget !== null; + }).catch(() => false); + + if (clickedWebpagesTrigger) { + await page.waitForTimeout(300); + if (await waitForDeepSeekSearchResultsPanel(page)) { + return; + } + } + + const searchResultsButton = page.getByRole("button", { + name: DEEPSEEK_SEARCH_RESULTS_BUTTON_PATTERN, + }).first(); + + if (await searchResultsButton.isVisible().catch(() => false)) { + await searchResultsButton.click().catch(() => undefined); + await page.waitForTimeout(300); + await waitForDeepSeekSearchResultsPanel(page).catch(() => undefined); + } +} + +async function hasDeepSeekSearchResultsPanel(page: PlaywrightPage): Promise { + const heading = page.getByRole("heading", { + name: DEEPSEEK_SEARCH_RESULTS_HEADING_PATTERN, + }).first(); + if (await heading.isVisible().catch(() => false)) { + return true; + } + + const cardTitle = page.locator("[class*='search-view-card__title'], .search-view-card__title").first(); + return await cardTitle.isVisible().catch(() => false); +} + +async function waitForDeepSeekSearchResultsPanel( + page: PlaywrightPage, + timeoutMs = 3_000, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (await hasDeepSeekSearchResultsPanel(page)) { + return true; + } + await page.waitForTimeout(150); + } + return await hasDeepSeekSearchResultsPanel(page); +} + +async function collectDeepSeekSearchResultsPanelLinks(page: PlaywrightPage): Promise { + return await page.evaluate(async () => { + type EvalSourceLink = { + url: string; + title: string | null; + text: string | null; + siteName: string | null; + kind: "search"; + }; + + const sleep = (ms: number) => new Promise((resolve) => { + window.setTimeout(resolve, ms); + }); + 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 isExternalUrl = (value: unknown): boolean => { + const text = normalize(value); + if (!text) { + return false; + } + try { + const url = new URL(text, window.location.href); + if (!/^https?:$/i.test(url.protocol)) { + return false; + } + return url.hostname !== window.location.hostname; + } catch { + return false; + } + }; + const normalizeUrl = (value: unknown): string | null => { + const text = normalize(value); + if (!text) { + return null; + } + try { + const url = new URL(text, window.location.href); + if (!/^https?:$/i.test(url.protocol)) { + return null; + } + url.hash = ""; + return url.toString(); + } catch { + return null; + } + }; + const queryText = (root: ParentNode, selectors: string): string | null => { + for (const node of Array.from(root.querySelectorAll(selectors))) { + const text = normalize(node.textContent || ""); + if (text) { + return text; + } + } + return null; + }; + const extractAnchorMetadata = ( + anchor: HTMLAnchorElement, + ): Pick => ({ + title: + normalize(anchor.getAttribute("title")) + || queryText(anchor, "[class*='search-view-card__title'], [class*='source-card__title'], [class*='reference-card__title'], [class*='card__title'], h1, h2, h3, h4") + || null, + text: + queryText(anchor, "[class*='search-view-card__snippet'], [class*='source-card__snippet'], [class*='reference-card__snippet'], [class*='card__snippet'], [class*='snippet'], [class*='summary'], p") + || normalize(anchor.textContent || "") + || null, + siteName: + normalize(anchor.getAttribute("data-site-name")) + || queryText(anchor, "[class*='site'], [class*='domain'], [class*='host'], [class*='origin'], [class*='source-card__source']") + || normalize(anchor.getAttribute("aria-label")) + || null, + }); + + const findSearchResultsPanel = (): HTMLElement | null => { + const headingPattern = /^(?:搜索结果|search results)$/i; + const seen = new Set(); + const candidates: Array<{ node: HTMLElement; score: number }> = []; + const addCandidate = (node: Element | null, baseScore: number): void => { + let current: HTMLElement | null = node instanceof HTMLElement ? node : null; + for (let depth = 0; current && depth < 7; depth += 1) { + if (seen.has(current) || !isVisible(current)) { + current = current.parentElement; + continue; + } + const rect = current.getBoundingClientRect(); + if (rect.width < 220 || rect.height < 220 || rect.left < window.innerWidth * 0.45) { + current = current.parentElement; + continue; + } + const style = window.getComputedStyle(current); + const headingText = queryText(current, "[role='heading'], h1, h2, h3, h4"); + const hasHeading = Boolean(headingText && headingPattern.test(headingText)); + const hasSearchCards = Boolean(current.querySelector("[class*='search-view-card__title'], .search-view-card__title")); + if (!hasHeading && !hasSearchCards) { + current = current.parentElement; + continue; + } + + let score = baseScore; + if (hasHeading) { + score += 24; + } + if (hasSearchCards) { + score += 24; + } + if (["auto", "scroll", "overlay"].includes(style.overflowY) && current.scrollHeight > current.clientHeight + 80) { + score += 18; + } + if (rect.left >= window.innerWidth * 0.55) { + score += 18; + } + if (rect.width <= 540) { + score += 8; + } + if (rect.height >= 360) { + score += 8; + } + score += Math.min(current.querySelectorAll("a[href]").length, 12); + seen.add(current); + candidates.push({ node: current, score }); + current = current.parentElement; + } + }; + + for (const heading of Array.from(document.querySelectorAll("[role='heading'], h1, h2, h3, h4"))) { + const text = normalize(heading.textContent || ""); + if (text && headingPattern.test(text)) { + addCandidate(heading, 40); + } + } + for (const titleNode of Array.from(document.querySelectorAll("[class*='search-view-card__title'], .search-view-card__title"))) { + addCandidate(titleNode, 30); + } + + return candidates.sort((left, right) => right.score - left.score).at(0)?.node ?? null; + }; + + const searchPanel = findSearchResultsPanel(); + + if (!(searchPanel instanceof HTMLElement)) { + return []; + } + + const originalScrollTop = searchPanel.scrollTop; + const seen = new Set(); + const results: EvalSourceLink[] = []; + const collect = () => { + for (const anchor of Array.from(searchPanel.querySelectorAll("a[href]"))) { + const href = normalizeUrl(anchor.getAttribute("href") || anchor.href || ""); + if (!href || !isExternalUrl(href) || seen.has(href)) { + continue; + } + + const metadata = extractAnchorMetadata(anchor); + seen.add(href); + results.push({ + url: href, + title: metadata.title, + text: metadata.text, + siteName: metadata.siteName, + kind: "search", + }); + } + }; + + collect(); + let previousScrollTop = -1; + let stableScrollCount = 0; + for (let attempt = 0; attempt < 48; attempt += 1) { + const nextTop = Math.min( + searchPanel.scrollTop + Math.max(searchPanel.clientHeight - 96, 420), + Math.max(searchPanel.scrollHeight - searchPanel.clientHeight, 0), + ); + searchPanel.scrollTop = nextTop; + await sleep(140); + collect(); + + if (searchPanel.scrollTop === previousScrollTop) { + stableScrollCount += 1; + } else { + previousScrollTop = searchPanel.scrollTop; + stableScrollCount = 0; + } + + if ( + searchPanel.scrollTop + searchPanel.clientHeight >= searchPanel.scrollHeight - 2 + && stableScrollCount >= 1 + ) { + break; + } + if (stableScrollCount >= 2) { + break; + } + } + + searchPanel.scrollTop = originalScrollTop; + return results; + }).catch(() => []); +} + +async function readDeepSeekPageSnapshot( + page: PlaywrightPage, + questionText?: string, +): Promise { + const draft = await page.evaluate((input) => { + type EvalRecord = Record; + type EvalSourceLink = { + url: string; + title: string | null; + text: string | null; + siteName: string | null; + kind: DeepSeekLinkKind; + }; + + const globalWindow = window as DeepSeekWindowState; + const questionText = typeof input === "string" ? input.trim().replace(/\s+/g, " ") : ""; + const normalize = (value: unknown): string | null => { + if (typeof value !== "string") { + return null; + } + const trimmed = value.trim().replace(/\s+/g, " "); + return trimmed || null; + }; + const normalizeComparable = (value: unknown): string => (normalize(value) || "").toLowerCase(); + 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 isExternalUrl = (value: unknown): boolean => { + const text = normalize(value); + if (!text) { + return false; + } + try { + const url = new URL(text, window.location.href); + if (!/^https?:$/i.test(url.protocol)) { + return false; + } + return url.hostname !== window.location.hostname; + } catch { + return false; + } + }; + const normalizeUrl = (value: unknown): string | null => { + const text = normalize(value); + if (!text) { + return null; + } + try { + const url = new URL(text, window.location.href); + if (!/^https?:$/i.test(url.protocol)) { + return null; + } + url.hash = ""; + return url.toString(); + } catch { + return null; + } + }; + const linkKey = (url: string, kind: DeepSeekLinkKind): string => `${kind}:${url}`; + const queryText = (root: ParentNode, selectors: string): string | null => { + for (const node of Array.from(root.querySelectorAll(selectors))) { + const text = normalize(node.textContent || ""); + if (text) { + return text; + } + } + return null; + }; + const renderStructuredHtml = (root: Element | null): string | null => { + if (!(root instanceof Element)) { + return null; + } + const clone = root.cloneNode(true); + if (!(clone instanceof HTMLElement)) { + return null; + } + + for (const anchor of Array.from(clone.querySelectorAll("a[href]"))) { + const href = normalizeUrl(anchor.getAttribute("href") || anchor.href || ""); + if (!href || !isExternalUrl(href)) { + continue; + } + anchor.setAttribute("href", href); + anchor.setAttribute("target", "_blank"); + anchor.setAttribute("rel", "noreferrer"); + } + + const html = clone.innerHTML.trim(); + return html || null; + }; + const extractAnchorMetadata = ( + anchor: HTMLAnchorElement, + ): Pick => ({ + title: + normalize(anchor.getAttribute("title")) + || queryText(anchor, "[class*='search-view-card__title'], [class*='source-card__title'], [class*='reference-card__title'], [class*='card__title'], h1, h2, h3, h4") + || null, + text: + queryText(anchor, "[class*='search-view-card__snippet'], [class*='source-card__snippet'], [class*='reference-card__snippet'], [class*='card__snippet'], [class*='snippet'], [class*='summary'], p") + || normalize(anchor.textContent || "") + || null, + siteName: + normalize(anchor.getAttribute("data-site-name")) + || queryText(anchor, "[class*='site'], [class*='domain'], [class*='host'], [class*='origin'], [class*='source-card__source']") + || normalize(anchor.getAttribute("aria-label")) + || null, + }); + const addLinksFromRoot = ( + root: Element | null, + kind: DeepSeekLinkKind, + bucket: EvalSourceLink[], + seen: Set, + ): void => { + if (!(root instanceof Element)) { + return; + } + const anchors: HTMLAnchorElement[] = root instanceof HTMLAnchorElement && root.matches("a[href]") + ? [root] + : Array.from(root.querySelectorAll("a[href]")); + for (const anchor of anchors) { + if (!isVisible(anchor)) { + continue; + } + const href = normalizeUrl(anchor.getAttribute("href") || anchor.href || ""); + if (!href || !isExternalUrl(href)) { + continue; + } + const key = linkKey(href, kind); + if (seen.has(key)) { + continue; + } + const metadata = extractAnchorMetadata(anchor); + seen.add(key); + bucket.push({ + url: href, + title: metadata.title, + text: metadata.text, + siteName: metadata.siteName, + kind, + }); + } + }; + const collectWindowStateLinks = (): EvalSourceLink[] => { + const results: EvalSourceLink[] = []; + const seen = new Set(); + const roots: unknown[] = []; + const candidates = [ + globalWindow.__NEXT_DATA__, + globalWindow.__INITIAL_STATE__, + globalWindow.__APP_STATE__, + globalWindow.__NUXT__, + typeof globalWindow.__STORE__?.getState === "function" ? globalWindow.__STORE__.getState() : null, + ]; + for (const candidate of candidates) { + if (candidate && typeof candidate === "object") { + roots.push(candidate); + } + } + const queue: Array<{ value: unknown; path: string }> = roots.map((root) => ({ value: root, path: "" })); + const visited = new WeakSet(); + while (queue.length && results.length < 96) { + const current = queue.shift(); + if (!current) { + continue; + } + const { value, path } = current; + if (!value || typeof value !== "object") { + continue; + } + if (visited.has(value)) { + continue; + } + visited.add(value); + if (Array.isArray(value)) { + for (const item of value.slice(0, 32)) { + queue.push({ value: item, path }); + } + continue; + } + const record = value as EvalRecord; + const rawUrl = record.url ?? record.href ?? record.link ?? record.uri; + const url = normalizeUrl(rawUrl); + if (url && isExternalUrl(url)) { + const kind: DeepSeekLinkKind = /search|result/i.test(path) ? "search" : "source"; + const key = linkKey(url, kind); + if (!seen.has(key)) { + seen.add(key); + results.push({ + url, + title: normalize(record.title ?? record.name ?? record.label), + text: normalize(record.text ?? record.snippet ?? record.description), + siteName: normalize(record.site_name ?? record.siteName ?? record.domain ?? record.host), + kind, + }); + } + } + for (const [key, item] of Object.entries(record).slice(0, 64)) { + if (item && typeof item === "object") { + queue.push({ + value: item, + path: path ? `${path}.${key}` : key, + }); + } + } + } + return results; + }; + const findSearchResultsPanel = (): HTMLElement | null => { + const headingPattern = /^(?:搜索结果|search results)$/i; + const seen = new Set(); + const candidates: Array<{ node: HTMLElement; score: number }> = []; + const addCandidate = (node: Element | null, baseScore: number): void => { + let current: HTMLElement | null = node instanceof HTMLElement ? node : null; + for (let depth = 0; current && depth < 7; depth += 1) { + if (seen.has(current) || !isVisible(current)) { + current = current.parentElement; + continue; + } + const rect = current.getBoundingClientRect(); + if (rect.width < 220 || rect.height < 220 || rect.left < window.innerWidth * 0.45) { + current = current.parentElement; + continue; + } + const style = window.getComputedStyle(current); + const headingText = queryText(current, "[role='heading'], h1, h2, h3, h4"); + const hasHeading = Boolean(headingText && headingPattern.test(headingText)); + const hasSearchCards = Boolean(current.querySelector("[class*='search-view-card__title'], .search-view-card__title")); + if (!hasHeading && !hasSearchCards) { + current = current.parentElement; + continue; + } + + let score = baseScore; + if (hasHeading) { + score += 24; + } + if (hasSearchCards) { + score += 24; + } + if (["auto", "scroll", "overlay"].includes(style.overflowY) && current.scrollHeight > current.clientHeight + 80) { + score += 18; + } + if (rect.left >= window.innerWidth * 0.55) { + score += 18; + } + if (rect.width <= 540) { + score += 8; + } + if (rect.height >= 360) { + score += 8; + } + score += Math.min(current.querySelectorAll("a[href]").length, 12); + seen.add(current); + candidates.push({ node: current, score }); + current = current.parentElement; + } + }; + + for (const heading of Array.from(document.querySelectorAll("[role='heading'], h1, h2, h3, h4"))) { + const text = normalize(heading.textContent || ""); + if (text && headingPattern.test(text)) { + addCandidate(heading, 40); + } + } + for (const titleNode of Array.from(document.querySelectorAll("[class*='search-view-card__title'], .search-view-card__title"))) { + addCandidate(titleNode, 30); + } + + return candidates.sort((left, right) => right.score - left.score).at(0)?.node ?? null; + }; + + const bodyText = normalize(document.body?.innerText || ""); + const loginRequired = + window.location.pathname.includes("/sign_in") + || Boolean(bodyText && /(log in|send code|scan with wechat to login|phone number|continue with deepseek|和 deepseek 继续聊|登录|发送验证码|手机号|验证码)/i.test(bodyText)); + const challengeRequired = Boolean( + bodyText + && /(verification required|captcha|security check|verify|challenge|人机验证|安全验证|请完成验证|验证码|风控|环境异常)/i.test(bodyText), + ); + + const busyDescriptors: string[] = []; + for (const node of Array.from(document.querySelectorAll("button, [role='button'], [class*='loading'], [class*='spinner'], [class*='think']")).slice(0, 60)) { + if (!isVisible(node)) { + continue; + } + const text = normalize(node.textContent || node.getAttribute("aria-label") || ""); + const descriptor = `${node.className || ""} ${text || ""}`; + if (descriptor) { + busyDescriptors.push(descriptor); + } + if (busyDescriptors.length >= 24) { + break; + } + } + + const editor = + document.querySelector("textarea.ds-textarea") + || document.querySelector(".ds-textarea textarea") + || document.querySelector("textarea") + || document.querySelector("[role='textbox'][contenteditable='true']") + || document.querySelector("[contenteditable='true']"); + const composerValue = editor instanceof HTMLTextAreaElement || editor instanceof HTMLInputElement + ? normalize(editor.value) + : normalize(editor?.textContent || ""); + + const sendButton = + Array.from(document.querySelectorAll("button, [role='button']")).find((node) => { + if (!isVisible(node)) { + return false; + } + const text = normalize(node.textContent || node.getAttribute("aria-label") || ""); + if (text && /(send|发送|提交)/i.test(text)) { + return true; + } + return /sendButton/i.test(String(node.className || "")); + }) || null; + const sendDisabled = + sendButton && "disabled" in sendButton ? Boolean(sendButton.disabled) : null; + + const markdownNodes = Array.from(document.querySelectorAll(".ds-markdown")) + .filter((node) => isVisible(node)) + .map((node) => { + const text = normalize(node.textContent || ""); + const comparableText = normalizeComparable(text); + const html = renderStructuredHtml(node); + const rect = node.getBoundingClientRect(); + const inThink = Boolean(node.closest(".ds-think-content")); + const container = + node.closest("[data-message-id], [class*='message'], article, li, section") + || node.parentElement; + const links: EvalSourceLink[] = []; + const seen = new Set(); + addLinksFromRoot(node, "inline", links, seen); + const containerLinks: EvalSourceLink[] = []; + if (container) { + addLinksFromRoot(container, "source", containerLinks, seen); + } + return { + node, + html, + text, + comparableText, + top: Math.round(rect.top), + inThink, + links, + containerLinks, + container, + }; + }); + + const answerCandidates = markdownNodes.filter((candidate) => { + if (candidate.inThink) { + return false; + } + if (!candidate.text && !candidate.links.length && !candidate.containerLinks.length) { + return false; + } + if (questionText && candidate.comparableText === questionText.toLowerCase()) { + return false; + } + return true; + }); + const answerCandidate = answerCandidates.at(-1) || null; + + const thinkCandidates = markdownNodes.filter((candidate) => candidate.inThink && candidate.text); + const thinkCandidate = thinkCandidates.at(-1) || null; + + const inlineLinks = answerCandidate?.links ?? []; + const sourcePanelLinks = answerCandidate?.containerLinks ?? []; + const searchPanelLinks: EvalSourceLink[] = []; + const searchSeen = new Set(); + const searchResultsPanel = findSearchResultsPanel(); + if (searchResultsPanel) { + addLinksFromRoot(searchResultsPanel, "search", searchPanelLinks, searchSeen); + } + + for (const item of collectWindowStateLinks()) { + if (item.kind === "search") { + if (!searchResultsPanel) { + continue; + } + const key = linkKey(item.url, "search"); + if (!searchSeen.has(key)) { + searchSeen.add(key); + searchPanelLinks.push(item); + } + continue; + } + + const key = linkKey(item.url, "source"); + if (!searchSeen.has(key)) { + searchSeen.add(key); + sourcePanelLinks.push(item); + } + } + + const currentModelLabel = + Array.from(document.querySelectorAll("button, [role='button'], [class*='model']")).map((node) => normalize(node.textContent || "")).find((text) => { + if (!text) { + return false; + } + return /deepseek|think|search/i.test(text) && text.length <= 40; + }) || null; + + const providerRequestID = + normalize(document.documentElement.getAttribute("data-request-id")) + || normalize(document.body?.getAttribute("data-request-id")) + || null; + const requestID = + normalize(document.documentElement.getAttribute("data-message-id")) + || normalize(document.body?.getAttribute("data-message-id")) + || providerRequestID + || null; + + const signatureParts = [ + normalize(answerCandidate?.text), + normalize(thinkCandidate?.text), + ...inlineLinks.map((item) => item.url), + ...sourcePanelLinks.map((item) => item.url), + ...searchPanelLinks.map((item) => item.url), + ].filter(Boolean); + + return { + url: window.location.href, + title: normalize(document.title), + loginRequired, + loginReason: loginRequired ? (normalize(bodyText) || "login_required") : null, + challengeRequired, + challengeReason: challengeRequired ? (normalize(bodyText) || "challenge_required") : null, + bodyText, + busyDescriptors, + composerValue, + sendDisabled, + answer: answerCandidate?.html ?? answerCandidate?.text ?? null, + answerText: answerCandidate?.text ?? null, + reasoning: thinkCandidate?.text ?? null, + signatureParts, + currentModelLabel, + providerRequestID, + requestID, + inlineLinks, + sourcePanelLinks, + searchPanelLinks, + }; + }, questionText ?? "") as DeepSeekPageSnapshotDraft; + + const busySignals = detectDeepSeekBusySignals({ + bodyText: draft.bodyText, + descriptors: draft.busyDescriptors, + }); + const { + bodyText: _bodyText, + busyDescriptors: _busyDescriptors, + signatureParts, + ...rest + } = draft; + + return { + ...rest, + busy: busySignals.length > 0, + busySignals, + signature: [...signatureParts, String(busySignals.length)].join("|") || null, + }; +} + +function selectBetterSnapshot( + current: DeepSeekPageSnapshot, + candidate: DeepSeekPageSnapshot, +): DeepSeekPageSnapshot { + if (answerScore(candidate) > answerScore(current)) { + return candidate; + } + + if (isObservationComplete(candidate) && !isObservationComplete(current)) { + return candidate; + } + + return current; +} + +async function waitForDeepSeekAnswer( + page: PlaywrightPage, + questionText: string, + signal: AbortSignal, +): Promise { + const startedAt = Date.now(); + let stablePollCount = 0; + let previousSignature: string | null = null; + let bestSnapshot = await readDeepSeekPageSnapshot(page, questionText); + + while (Date.now() - startedAt < DEEPSEEK_QUERY_TIMEOUT_MS) { + if (signal.aborted) { + throw new Error("adapter_aborted"); + } + + const snapshot = await readDeepSeekPageSnapshot(page, questionText); + bestSnapshot = selectBetterSnapshot(bestSnapshot, snapshot); + + if (snapshot.loginRequired) { + return { + ok: false, + error: "deepseek_login_required", + detail: snapshot.loginReason ?? "login_required", + finalSnapshot: bestSnapshot, + stablePollCount, + elapsedMs: Date.now() - startedAt, + }; + } + + if (snapshot.challengeRequired && !snapshot.answer) { + return { + ok: false, + error: "deepseek_challenge_required", + detail: snapshot.challengeReason ?? "challenge_required", + finalSnapshot: bestSnapshot, + stablePollCount, + elapsedMs: Date.now() - startedAt, + }; + } + + if (isObservationComplete(snapshot)) { + if (snapshot.signature && snapshot.signature === previousSignature) { + stablePollCount += 1; + } else { + stablePollCount = 1; + } + + previousSignature = snapshot.signature; + if (stablePollCount >= DEEPSEEK_STABLE_POLLS_REQUIRED) { + return { + ok: true, + finalSnapshot: snapshot, + stablePollCount, + elapsedMs: Date.now() - startedAt, + }; + } + } else { + stablePollCount = 0; + previousSignature = snapshot.signature; + } + + await sleep(DEEPSEEK_QUERY_POLL_INTERVAL_MS, signal); + } + + return { + ok: false, + error: "deepseek_query_timeout", + detail: "timed out while waiting for DeepSeek answer", + finalSnapshot: bestSnapshot, + stablePollCount, + elapsedMs: Date.now() - startedAt, + }; +} + +export const deepseekAdapter: MonitorAdapter = { + provider: "deepseek", + executionMode: "playwright", + async query(context, payload) { + if (!context.playwright?.page) { + return { + status: "failed", + summary: "DeepSeek 监测缺少 Playwright 页面上下文。", + error: buildAdapterError("deepseek_playwright_required", "deepseek_playwright_required"), + }; + } + + let questionText: string; + try { + questionText = extractQuestionText(payload); + } catch (error) { + return { + status: "failed", + summary: "DeepSeek 监测缺少问题文本,无法执行。", + error: buildAdapterError( + "deepseek_question_text_missing", + error instanceof Error ? error.message : "deepseek_question_text_missing", + ), + }; + } + + const page = context.playwright.page; + + context.reportProgress("deepseek.page_ready"); + await ensurePageOnDeepSeekChat(page, context.signal); + + const initialSnapshot = await readDeepSeekPageSnapshot(page, questionText); + if (initialSnapshot.loginRequired) { + return { + status: "failed", + summary: "DeepSeek 账号未登录或登录态已失效,请先在 desktop client 中重新授权。", + error: buildAdapterError( + "deepseek_login_required", + initialSnapshot.loginReason ?? "login_required", + { + page_url: initialSnapshot.url, + }, + ), + }; + } + + if (initialSnapshot.challengeRequired) { + return { + status: "failed", + summary: "DeepSeek 当前需要人工验证,相关监控任务已暂停。", + error: buildAdapterError( + "deepseek_challenge_required", + initialSnapshot.challengeReason ?? "challenge_required", + { + page_url: initialSnapshot.url, + }, + ), + }; + } + + context.reportProgress("deepseek.query"); + try { + await submitDeepSeekQuestion(page, questionText, context.signal); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + status: "failed", + summary: `DeepSeek 提问失败:${message}`, + error: buildAdapterError("deepseek_submit_failed", message, { + page_url: safePageURL(page), + }), + }; + } + + context.reportProgress("deepseek.wait_answer"); + const waitResult = await waitForDeepSeekAnswer(page, questionText, context.signal); + context.reportProgress("deepseek.reveal_sources"); + await maybeRevealDeepSeekSources(page).catch(() => undefined); + const revealedSearchPanelLinks = await collectDeepSeekSearchResultsPanelLinks(page).catch(() => []); + const finalSnapshot = await readDeepSeekPageSnapshot(page, questionText).catch(() => waitResult.finalSnapshot); + const bestSnapshot = selectBetterSnapshot(waitResult.finalSnapshot, finalSnapshot); + const mergedSnapshot: DeepSeekPageSnapshot = { + ...bestSnapshot, + searchPanelLinks: mergeDeepSeekRawSourceLinks(bestSnapshot.searchPanelLinks, revealedSearchPanelLinks), + }; + const classifiedSources = classifyDeepseekSources({ + inlineLinks: mergedSnapshot.inlineLinks, + sourcePanelLinks: mergedSnapshot.sourcePanelLinks, + searchPanelLinks: mergedSnapshot.searchPanelLinks, + }); + + if (!waitResult.ok) { + const detail = waitResult.detail ?? waitResult.error; + if (waitResult.error === "deepseek_login_required") { + return { + status: "failed", + summary: "DeepSeek 账号未登录或登录态已失效,请先在 desktop client 中重新授权。", + error: buildAdapterError("deepseek_login_required", detail, { + page_url: mergedSnapshot.url, + }), + }; + } + + if (waitResult.error === "deepseek_challenge_required") { + return { + status: "failed", + summary: "DeepSeek 当前需要人工验证,相关监控任务已暂停。", + error: buildAdapterError("deepseek_challenge_required", detail, { + page_url: mergedSnapshot.url, + }), + }; + } + + return { + status: "unknown", + summary: "DeepSeek 回答等待超时,已回写 unknown 等待后续对账。", + error: buildAdapterError(waitResult.error, detail, { + page_url: mergedSnapshot.url, + }), + payload: buildDeepSeekPayload(mergedSnapshot, classifiedSources, mergedSnapshot.answer), + }; + } + + const answer = normalizeText(mergedSnapshot.answer); + if (!answer) { + return { + status: "unknown", + summary: "DeepSeek 未返回可用答案,已回写 unknown 等待后续对账。", + error: buildAdapterError("deepseek_empty_answer", "deepseek returned no final answer"), + payload: buildDeepSeekPayload(mergedSnapshot, classifiedSources, mergedSnapshot.answer), + }; + } + + return { + status: "succeeded", + summary: "DeepSeek 监控任务执行成功。", + payload: buildDeepSeekPayload(mergedSnapshot, classifiedSources, answer), + }; + }, +}; + +export const __deepseekTestUtils = { + extractQuestionText, + buildSourceItem, + buildDeepSeekPayload, + detectDeepSeekBusySignals, + dedupeSourceItems, + classifyDeepseekSources, + mergeDeepSeekRawSourceLinks, + isObservationComplete, + isDeepSeekToggleSelected, + isDeepSeekWebpagesTriggerText, +}; diff --git a/apps/desktop-client/src/main/adapters/index.ts b/apps/desktop-client/src/main/adapters/index.ts index 7ecfc1f..f40a671 100644 --- a/apps/desktop-client/src/main/adapters/index.ts +++ b/apps/desktop-client/src/main/adapters/index.ts @@ -1,4 +1,5 @@ export * from "./base"; +export * from "./deepseek"; export * from "./doubao"; export * from "./kimi"; export * from "./qwen"; @@ -6,3 +7,24 @@ export * from "./toutiao"; export * from "./wenxin"; export * from "./yuanbao"; export * from "./zhihu"; + +import { deepseekAdapter } from "./deepseek"; +import { doubaoAdapter } from "./doubao"; +import { kimiAdapter } from "./kimi"; +import { qwenAdapter } from "./qwen"; +import { wenxinAdapter } from "./wenxin"; +import { yuanbaoAdapter } from "./yuanbao"; +import type { MonitorAdapter } from "./base"; + +const monitorAdapterRegistry = new Map([ + [deepseekAdapter.provider, deepseekAdapter], + [doubaoAdapter.provider, doubaoAdapter], + [kimiAdapter.provider, kimiAdapter], + [qwenAdapter.provider, qwenAdapter], + [wenxinAdapter.provider, wenxinAdapter], + [yuanbaoAdapter.provider, yuanbaoAdapter], +]); + +export function getMonitorAdapter(platform: string): MonitorAdapter | null { + return monitorAdapterRegistry.get(platform) ?? null; +} diff --git a/apps/desktop-client/src/main/generic-ai-auth.test.ts b/apps/desktop-client/src/main/generic-ai-auth.test.ts new file mode 100644 index 0000000..e1ba0fe --- /dev/null +++ b/apps/desktop-client/src/main/generic-ai-auth.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; + +import { + buildGenericAIPageFingerprintFallback, + genericAIPageLooksAuthenticated, +} from "./generic-ai-auth"; + +describe("generic ai auth detection", () => { + it("treats deepseek home as authenticated when credential storage exists", () => { + expect(genericAIPageLooksAuthenticated("https://chat.deepseek.com/", { + displayName: null, + avatarUrl: null, + fingerprint: "local:userToken=masked", + credentialFingerprint: "local:userToken=masked", + currentPath: "/", + strongAuthSignalCount: 0, + loginSignalCount: 0, + loggedOutSignalCount: 0, + challengeSignalCount: 0, + })).toBe(true); + }); + + it("does not use a non-auth fingerprint as a fallback without strong ui signals", () => { + expect(buildGenericAIPageFingerprintFallback("deepseek", { + displayName: null, + avatarUrl: null, + fingerprint: "local:user_unique_id=123", + credentialFingerprint: null, + currentPath: "/", + strongAuthSignalCount: 0, + loginSignalCount: 0, + loggedOutSignalCount: 0, + challengeSignalCount: 0, + })).toBeNull(); + }); + + it("builds a stable fingerprint from credential storage when cookies are absent", () => { + expect(buildGenericAIPageFingerprintFallback("deepseek", { + displayName: null, + avatarUrl: null, + fingerprint: "local:user_unique_id=123", + credentialFingerprint: "local:userToken=masked", + currentPath: "/", + strongAuthSignalCount: 0, + loginSignalCount: 0, + loggedOutSignalCount: 0, + challengeSignalCount: 0, + })).toBe("deepseek-session:local:userToken=masked"); + }); +}); diff --git a/apps/desktop-client/src/main/generic-ai-auth.ts b/apps/desktop-client/src/main/generic-ai-auth.ts new file mode 100644 index 0000000..0303d30 --- /dev/null +++ b/apps/desktop-client/src/main/generic-ai-auth.ts @@ -0,0 +1,113 @@ +function normalizeText(value: unknown): string | null { + if (typeof value !== "string") { + return null; + } + const trimmed = value.trim(); + return trimmed ? trimmed : null; +} + +function hashText(value: string): string { + let hash = 0; + for (let index = 0; index < value.length; index += 1) { + hash = ((hash << 5) - hash + value.charCodeAt(index)) | 0; + } + return Math.abs(hash).toString(16).padStart(8, "0"); +} + +function boundedIdentity(prefix: string, raw: string): string { + const normalizedPrefix = prefix.trim().replace(/:+$/, ""); + const normalizedRaw = raw.trim(); + if (!normalizedRaw) { + return `${normalizedPrefix}:${hashText(prefix)}`; + } + + const candidate = `${normalizedPrefix}:${normalizedRaw}`; + if (candidate.length <= 120) { + return candidate; + } + + return `${normalizedPrefix}:${hashText(normalizedRaw)}`; +} + +function normalizeURLPath(pathname: string): string { + const normalized = pathname.replace(/\/+$/, ""); + return normalized === "/" ? "" : normalized; +} + +function safeParseURL(input: string): URL | null { + try { + return new URL(input); + } catch { + return null; + } +} + +function genericAIConversationPath(pathname: string): boolean { + return /^\/(?:chat|c|conversation)(?:\/|$)/i.test(pathname); +} + +export interface GenericAIPageState { + displayName: string | null; + avatarUrl: string | null; + fingerprint: string | null; + credentialFingerprint: string | null; + currentPath: string | null; + strongAuthSignalCount: number; + loginSignalCount: number; + loggedOutSignalCount: number; + challengeSignalCount: number; +} + +export function genericAIPageLooksAuthenticated( + currentURL: string, + pageState: GenericAIPageState | null, +): boolean { + const current = safeParseURL(currentURL); + const currentPath = normalizeURLPath(current?.pathname ?? pageState?.currentPath ?? ""); + const strongAuthSignalCount = pageState?.strongAuthSignalCount ?? 0; + const loginSignalCount = pageState?.loginSignalCount ?? 0; + const loggedOutSignalCount = pageState?.loggedOutSignalCount ?? 0; + const challengeSignalCount = pageState?.challengeSignalCount ?? 0; + const hasCredentialFingerprint = Boolean(normalizeText(pageState?.credentialFingerprint)); + const onConversationPath = genericAIConversationPath(currentPath); + + if (challengeSignalCount > 0) { + return false; + } + + if (loggedOutSignalCount > 0) { + return false; + } + + if (loginSignalCount > 0 && strongAuthSignalCount === 0 && !onConversationPath && !hasCredentialFingerprint) { + return false; + } + + if (loginSignalCount > 0) { + return (strongAuthSignalCount >= 2 || hasCredentialFingerprint) && !onConversationPath; + } + + return strongAuthSignalCount > 0 || onConversationPath || hasCredentialFingerprint; +} + +export function buildGenericAIPageFingerprintFallback( + platformId: string, + pageState?: GenericAIPageState | null, +): string | null { + const credentialFingerprint = normalizeText(pageState?.credentialFingerprint); + if (credentialFingerprint) { + return boundedIdentity(`${platformId}-session`, credentialFingerprint); + } + + const fallbackFingerprint = normalizeText(pageState?.fingerprint); + if (!fallbackFingerprint) { + return null; + } + + const strongAuthSignalCount = pageState?.strongAuthSignalCount ?? 0; + if (strongAuthSignalCount <= 0) { + return null; + } + + return boundedIdentity(`${platformId}-page`, fallbackFingerprint); +} diff --git a/apps/desktop-client/src/main/runtime-controller.ts b/apps/desktop-client/src/main/runtime-controller.ts index 73693d9..2cf8eac 100644 --- a/apps/desktop-client/src/main/runtime-controller.ts +++ b/apps/desktop-client/src/main/runtime-controller.ts @@ -17,12 +17,8 @@ import type { import { getAIPlatformCatalogItem, isAIPlatformId } from "@geo/shared-types"; import { - doubaoAdapter, - kimiAdapter, - qwenAdapter, + getMonitorAdapter, toutiaoAdapter, - wenxinAdapter, - yuanbaoAdapter, zhihuAdapter, type AdapterExecutionResult, type MonitorAdapter, @@ -2681,22 +2677,7 @@ function selectPublishAdapter(platform: string): PublishAdapter | null { } function selectMonitorAdapter(platform: string): MonitorAdapter | null { - if (platform === yuanbaoAdapter.provider) { - return yuanbaoAdapter; - } - if (platform === kimiAdapter.provider) { - return kimiAdapter; - } - if (platform === doubaoAdapter.provider) { - return doubaoAdapter; - } - if (platform === qwenAdapter.provider) { - return qwenAdapter; - } - if (platform === wenxinAdapter.provider) { - return wenxinAdapter; - } - return null; + return getMonitorAdapter(platform); } function normalizeSession( diff --git a/docs/superpowers/plans/2026-04-23-deepseek-monitoring.md b/docs/superpowers/plans/2026-04-23-deepseek-monitoring.md new file mode 100644 index 0000000..04c7cd5 --- /dev/null +++ b/docs/superpowers/plans/2026-04-23-deepseek-monitoring.md @@ -0,0 +1,274 @@ +# DeepSeek Monitoring Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add production-usable `deepseek` desktop monitoring support with answer capture, citation extraction, runtime routing, and regression coverage. + +**Architecture:** Reuse the existing generic AI auth flow, add a new Playwright-based desktop monitor adapter for DeepSeek, register it in the desktop runtime, and keep server-side result ingestion unchanged. The adapter should favor structured page observation plus network-response capture so answer capture and citation extraction can degrade independently. + +**Tech Stack:** TypeScript + Vitest in `apps/desktop-client`, Go + `testing` in `server/internal/tenant/app`, Playwright hidden-page execution, pnpm workspace, existing shared monitoring payload contracts. + +--- + +### Task 1: Lock In Failing Tests First + +**Files:** +- Create: `apps/desktop-client/src/main/adapters/deepseek.test.ts` +- Modify: `server/internal/tenant/app/monitoring_callback_service_test.go` + +- [ ] **Step 1: Write the failing desktop adapter helper tests** + +```ts +import { describe, expect, it } from "vitest"; + +import { __deepseekTestUtils } from "./deepseek"; + +const { + extractQuestionText, + dedupeSourceItems, + classifyDeepseekSources, + isObservationComplete, +} = __deepseekTestUtils; + +describe("deepseek adapter helpers", () => { + it("extracts question text from monitoring payload", () => { + expect(extractQuestionText({ question_text: "DeepSeek 怎么看 GEO?" })).toBe("DeepSeek 怎么看 GEO?"); + }); + + it("dedupes sources by normalized url", () => { + const items = dedupeSourceItems([ + { url: "https://example.com/a#top", normalized_url: "https://example.com/a#top" }, + { url: "https://example.com/a#bottom", normalized_url: "https://example.com/a#bottom" }, + ]); + + expect(items).toHaveLength(1); + expect(items[0]?.normalized_url).toBe("https://example.com/a"); + }); + + it("separates citations from search-style sources", () => { + const classified = classifyDeepseekSources({ + inlineLinks: [{ url: "https://example.com/citation", normalized_url: "https://example.com/citation" }], + sourcePanelLinks: [], + searchPanelLinks: [{ url: "https://example.com/search", normalized_url: "https://example.com/search" }], + }); + + expect(classified.citations).toHaveLength(1); + expect(classified.searchResults).toHaveLength(1); + }); + + it("treats a stable answer as complete even without citations", () => { + expect(isObservationComplete({ + answer: "这是最终答案", + busy: false, + loginRequired: false, + challengeRequired: false, + signature: "sig-1", + })).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Run the new desktop test to verify it fails** + +Run: + +```bash +pnpm --filter @geo/desktop-client test apps/desktop-client/src/main/adapters/deepseek.test.ts +``` + +Expected: + +- FAIL because `./deepseek` does not exist yet + +- [ ] **Step 3: Write the failing server regression test** + +```go +func TestBuildMonitoringRawPayloadDeepSeekIncludesSearchResultsInCitationInputs(t *testing.T) { + searchTitle := "搜索网页结果" + + req := MonitoringTaskResultRequest{ + Status: "succeeded", + SearchResults: []MonitoringSourceItem{ + { + URL: "https://example.com/search", + Title: &searchTitle, + }, + }, + } + + _, sourceInputs := buildMonitoringRawPayload("deepseek", req) + if len(sourceInputs) != 1 { + t.Fatalf("buildMonitoringRawPayload() sourceInputs len = %d, want 1", len(sourceInputs)) + } + if sourceInputs[0].URL != "https://example.com/search" { + t.Fatalf("buildMonitoringRawPayload() source url = %q, want search url", sourceInputs[0].URL) + } +} +``` + +- [ ] **Step 4: Run the targeted Go test to verify the new assertion state** + +Run: + +```bash +cd server && go test ./internal/tenant/app -run TestBuildMonitoringRawPayloadDeepSeekIncludesSearchResultsInCitationInputs -count=1 +``` + +Expected: + +- PASS or FAIL is acceptable here, but the test must compile and pin the intended DeepSeek ingestion behavior before adapter code lands + +### Task 2: Implement the DeepSeek Desktop Adapter + +**Files:** +- Create: `apps/desktop-client/src/main/adapters/deepseek.ts` +- Modify: `apps/desktop-client/src/main/adapters/index.ts` +- Modify: `apps/desktop-client/src/main/runtime-controller.ts` + +- [ ] **Step 1: Add the adapter skeleton and helper exports** + +```ts +export const deepseekAdapter: MonitorAdapter = { + provider: "deepseek", + executionMode: "playwright", + async query(context, payload) { + // implement after tests are in place + }, +}; + +export const __deepseekTestUtils = { + extractQuestionText, + dedupeSourceItems, + classifyDeepseekSources, + isObservationComplete, +}; +``` + +- [ ] **Step 2: Implement page bootstrap and observation helpers** + +```ts +async function ensurePageOnDeepSeekChat(page: PlaywrightPage, signal: AbortSignal): Promise { + if (signal.aborted) { + throw new Error("adapter_aborted"); + } + + if (!page.url() || !page.url().startsWith("https://chat.deepseek.com/")) { + await page.goto("https://chat.deepseek.com/", { + waitUntil: "domcontentloaded", + timeout: 20_000, + }); + } + + await page.waitForLoadState("domcontentloaded", { timeout: 20_000 }).catch(() => undefined); + await page.waitForLoadState("networkidle", { timeout: 3_000 }).catch(() => undefined); +} +``` + +- [ ] **Step 3: Implement answer submission, wait loop, and citation extraction** + +```ts +const result = await waitForDeepSeekAnswer(page, questionText, context.signal); +const classified = classifyDeepseekSources({ + inlineLinks: result.inlineLinks, + sourcePanelLinks: result.sourcePanelLinks, + searchPanelLinks: result.searchPanelLinks, +}); + +return { + status: "succeeded", + summary: "DeepSeek 监控任务执行成功。", + payload: { + platform: "deepseek", + provider_model: result.providerModel ?? "deepseek-chat", + provider_request_id: result.providerRequestID, + request_id: result.requestID, + answer: result.answer, + citations: toJsonSources(classified.citations), + search_results: toJsonSources(classified.searchResults), + raw_response_json: { + platform: "deepseek", + page_url: result.url, + answer: result.answer, + citations: toJsonSources(classified.citations), + search_results: toJsonSources(classified.searchResults), + }, + }, +}; +``` + +- [ ] **Step 4: Register the adapter in exports and runtime routing** + +```ts +export * from "./deepseek"; +``` + +```ts +import { deepseekAdapter } from "./adapters"; + +if (platform === deepseekAdapter.provider) { + return deepseekAdapter; +} +``` + +- [ ] **Step 5: Run the desktop DeepSeek tests to verify they now pass** + +Run: + +```bash +pnpm --filter @geo/desktop-client test apps/desktop-client/src/main/adapters/deepseek.test.ts +``` + +Expected: + +- PASS + +### Task 3: Verify Integration Boundaries + +**Files:** +- Modify: `apps/desktop-client/src/main/platform-auth-adapters.ts` only if DeepSeek-specific auth/risk strings are discovered during implementation +- Modify: `server/internal/tenant/app/monitoring_callback_service_test.go` + +- [ ] **Step 1: Keep server ingestion behavior explicit for DeepSeek** + +```go +func TestBuildMonitoringRawPayloadDeepSeekIncludesSearchResultsInCitationInputs(t *testing.T) { + // keep DeepSeek aligned with the default non-Kimi ingestion path +} +``` + +- [ ] **Step 2: Run the combined targeted verification suite** + +Run: + +```bash +pnpm --filter @geo/desktop-client test apps/desktop-client/src/main/adapters/deepseek.test.ts apps/desktop-client/src/main/platform-auth-adapters.test.ts +cd server && go test ./internal/tenant/app -run 'TestBuildMonitoringRawPayload(DeepSeek|Qwen|Kimi)|TestExtractMonitoringInlineCitationsFromRawResponseKimi' -count=1 +``` + +Expected: + +- all targeted tests pass + +- [ ] **Step 3: Run desktop typecheck** + +Run: + +```bash +pnpm typecheck:desktop +``` + +Expected: + +- PASS with no new type errors from the DeepSeek adapter wiring + +- [ ] **Step 4: Commit the implementation** + +```bash +git add apps/desktop-client/src/main/adapters/deepseek.ts \ + apps/desktop-client/src/main/adapters/deepseek.test.ts \ + apps/desktop-client/src/main/adapters/index.ts \ + apps/desktop-client/src/main/runtime-controller.ts \ + server/internal/tenant/app/monitoring_callback_service_test.go \ + docs/superpowers/plans/2026-04-23-deepseek-monitoring.md +git commit -m "feat(desktop): add DeepSeek monitoring adapter" +``` diff --git a/server/internal/tenant/app/monitoring_callback_service.go b/server/internal/tenant/app/monitoring_callback_service.go index 3ed4285..190ec77 100644 --- a/server/internal/tenant/app/monitoring_callback_service.go +++ b/server/internal/tenant/app/monitoring_callback_service.go @@ -2375,8 +2375,8 @@ func extractMonitoringSourceItems(value interface{}) []MonitoringSourceItem { case map[string]interface{}: entry := MonitoringSourceItem{ URL: firstString(typed, "url", "cited_url", "link"), - Title: optionalString(firstString(typed, "title", "cited_title", "name")), - SiteName: optionalString(firstString(typed, "site_name")), + Title: optionalString(firstString(typed, "title", "cited_title", "name", "text")), + SiteName: optionalString(firstString(typed, "site_name", "siteName")), SiteKey: optionalString(firstString(typed, "site_key")), NormalizedURL: optionalString(firstString(typed, "normalized_url")), Host: optionalString(firstString(typed, "host")), diff --git a/server/internal/tenant/app/monitoring_callback_service_test.go b/server/internal/tenant/app/monitoring_callback_service_test.go index a0bf368..3a9409b 100644 --- a/server/internal/tenant/app/monitoring_callback_service_test.go +++ b/server/internal/tenant/app/monitoring_callback_service_test.go @@ -68,6 +68,30 @@ func TestBuildMonitoringRawPayloadQwenIncludesSearchResultsInCitationInputs(t *t } } +func TestBuildMonitoringRawPayloadDeepSeekIncludesSearchResultsInCitationInputs(t *testing.T) { + searchTitle := "搜索网页结果" + + req := MonitoringTaskResultRequest{ + Status: "succeeded", + SearchResults: []MonitoringSourceItem{ + { + URL: "https://example.com/search", + Title: &searchTitle, + }, + }, + } + + _, sourceInputs := buildMonitoringRawPayload("deepseek", req) + if len(sourceInputs) != 1 { + t.Fatalf("buildMonitoringRawPayload() sourceInputs len = %d, want 1", len(sourceInputs)) + } + for _, item := range sourceInputs { + if item.URL != "https://example.com/search" { + t.Fatalf("buildMonitoringRawPayload() source url = %q, want search url", item.URL) + } + } +} + func TestExtractMonitoringInlineCitationsFromRawResponseKimi(t *testing.T) { raw := []byte(`{ "inline_citation_candidates": [ @@ -100,3 +124,34 @@ func TestExtractMonitoringInlineCitationsFromRawResponseKimi(t *testing.T) { t.Fatalf("extractMonitoringInlineCitationsFromRawResponse() second url = %q, want https://example.com/b", items[1].URL) } } + +func TestExtractMonitoringInlineCitationsFromRawResponseDeepSeekFallsBackToInlineLinks(t *testing.T) { + raw := []byte(`{ + "inline_links": [ + { + "url": "https://example.com/a#cite-1", + "text": "-1", + "siteName": "示例站点" + }, + { + "url": "https://example.com/b", + "title": "文章 B", + "siteName": "另一个站点" + } + ] + }`) + + items := extractMonitoringInlineCitationsFromRawResponse(raw, "deepseek") + if len(items) != 2 { + t.Fatalf("extractMonitoringInlineCitationsFromRawResponse() len = %d, want 2", len(items)) + } + if items[0].URL != "https://example.com/a" { + t.Fatalf("extractMonitoringInlineCitationsFromRawResponse() first url = %q, want https://example.com/a", items[0].URL) + } + if items[0].SiteName == nil || *items[0].SiteName != "示例站点" { + t.Fatalf("extractMonitoringInlineCitationsFromRawResponse() first site = %v, want 示例站点", items[0].SiteName) + } + if items[1].Title == nil || *items[1].Title != "文章 B" { + t.Fatalf("extractMonitoringInlineCitationsFromRawResponse() second title = %v, want 文章 B", items[1].Title) + } +} diff --git a/server/internal/tenant/app/monitoring_service.go b/server/internal/tenant/app/monitoring_service.go index 31de636..5963d70 100644 --- a/server/internal/tenant/app/monitoring_service.go +++ b/server/internal/tenant/app/monitoring_service.go @@ -2581,6 +2581,12 @@ func extractMonitoringInlineCitationsFromRawResponse(raw []byte, platformID stri } items := extractMonitoringSourceItems(payload["inline_citation_candidates"]) + if len(items) == 0 && normalizeMonitoringPlatformID(platformID) == "deepseek" { + items = extractMonitoringSourceItems(payload["inline_links"]) + if len(items) == 0 { + items = extractMonitoringSourceItems(payload["source_panel_links"]) + } + } if len(items) == 0 && normalizeMonitoringPlatformID(platformID) == "kimi" { items = extractMonitoringSourceItems(payload["search_results"]) }