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) <noreply@anthropic.com>
This commit is contained in:
@@ -138,10 +138,19 @@ type TrackingAnalyzedContentCitation = {
|
|||||||
citedURL: string | null;
|
citedURL: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type TrackingCitationSourceLookupItem = {
|
||||||
|
sourceGroupIndex: number;
|
||||||
|
citation: MonitoringQuestionDetailCitation;
|
||||||
|
};
|
||||||
|
|
||||||
const renderedAnswerContent = computed<string | null>(() => {
|
const renderedAnswerContent = computed<string | null>(() => {
|
||||||
return formatAnswerContent(activePlatform.value);
|
return formatAnswerContent(activePlatform.value);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const activeDeepSeekCitationReferenceLabels = computed<string[][]>(() => {
|
||||||
|
return collectDeepSeekCitationReferenceLabels(activePlatform.value);
|
||||||
|
});
|
||||||
|
|
||||||
const activeInlineContentCitations = computed<TrackingAnalyzedContentCitation[]>(() => {
|
const activeInlineContentCitations = computed<TrackingAnalyzedContentCitation[]>(() => {
|
||||||
return analyzeInlineContentCitations(activePlatform.value);
|
return analyzeInlineContentCitations(activePlatform.value);
|
||||||
});
|
});
|
||||||
@@ -208,6 +217,10 @@ function isKimiInlineCitationPlatform(platformId: string | null | undefined): bo
|
|||||||
return platformId === "kimi";
|
return platformId === "kimi";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isDeepSeekInlineCitationPlatform(platformId: string | null | undefined): boolean {
|
||||||
|
return platformId === "deepseek";
|
||||||
|
}
|
||||||
|
|
||||||
function createQwenSourceGroupPattern(): RegExp {
|
function createQwenSourceGroupPattern(): RegExp {
|
||||||
return /\[\[source_group_web_(\d+)\]\]/g;
|
return /\[\[source_group_web_(\d+)\]\]/g;
|
||||||
}
|
}
|
||||||
@@ -228,6 +241,21 @@ function createMarkdownLinkPattern(): RegExp {
|
|||||||
return /\[([^\]\n]+)\]\((https?:\/\/[^)\s]+)\)/g;
|
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(`<div>${content}</div>`, "text/html");
|
||||||
|
return document.body.firstElementChild instanceof HTMLElement ? document.body.firstElementChild : null;
|
||||||
|
}
|
||||||
|
|
||||||
function collectInlineCitationIndexes(answerText: string): number[] {
|
function collectInlineCitationIndexes(answerText: string): number[] {
|
||||||
const matches = [
|
const matches = [
|
||||||
...answerText.matchAll(createQwenSourceGroupPattern()),
|
...answerText.matchAll(createQwenSourceGroupPattern()),
|
||||||
@@ -316,6 +344,136 @@ function buildInlineCitationSourceLookup(platform: MonitoringQuestionDetailPlatf
|
|||||||
return lookup;
|
return lookup;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildCitationSourceLookup(platform: MonitoringQuestionDetailPlatform | null): Map<string, TrackingCitationSourceLookupItem> {
|
||||||
|
const lookup = new Map<string, TrackingCitationSourceLookupItem>();
|
||||||
|
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<string, string>,
|
||||||
|
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<string, string[]>();
|
||||||
|
const fallbackLabels = new Map<string, string>();
|
||||||
|
const fallbackState = { nextIndex: 1 };
|
||||||
|
|
||||||
|
for (const anchor of Array.from(root.querySelectorAll<HTMLAnchorElement>("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 {
|
function countHanCharacters(value: string): number {
|
||||||
return value.match(/[\u3400-\u9fff]/g)?.length ?? 0;
|
return value.match(/[\u3400-\u9fff]/g)?.length ?? 0;
|
||||||
}
|
}
|
||||||
@@ -339,6 +497,10 @@ function sanitizeAnswerBody(answerText: string | null | undefined): string | nul
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (looksLikeHtmlAnswer(normalized)) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
const lines = normalized.split(/\r?\n+/).map((line) => line.trim()).filter(Boolean);
|
const lines = normalized.split(/\r?\n+/).map((line) => line.trim()).filter(Boolean);
|
||||||
const keptLines = lines.filter((line) => !looksLikeNonAnswerNoise(line));
|
const keptLines = lines.filter((line) => !looksLikeNonAnswerNoise(line));
|
||||||
const sanitized = (keptLines.length ? keptLines.join("\n") : normalized)
|
const sanitized = (keptLines.length ? keptLines.join("\n") : normalized)
|
||||||
@@ -370,8 +532,80 @@ function buildInlineCitationMarker(
|
|||||||
return `<button type="button" class="${className}" data-citation-index="${sourceGroupIndex}">[${sourceGroupIndex}]</button>`;
|
return `<button type="button" class="${className}" data-citation-index="${sourceGroupIndex}">[${sourceGroupIndex}]</button>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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<string, string>();
|
||||||
|
const fallbackState = { nextIndex: 1 };
|
||||||
|
|
||||||
|
for (const anchor of Array.from(root.querySelectorAll<HTMLAnchorElement>("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 {
|
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) {
|
if (!sanitized) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -394,6 +628,54 @@ function formatAnswerContent(platform: MonitoringQuestionDetailPlatform | null):
|
|||||||
.trim();
|
.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<string, { label: string | null; siteName: string | null; citationCount: number }>();
|
||||||
|
for (const anchor of Array.from(root.querySelectorAll<HTMLAnchorElement>("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(
|
function analyzeInlineContentCitations(
|
||||||
platform: MonitoringQuestionDetailPlatform | null,
|
platform: MonitoringQuestionDetailPlatform | null,
|
||||||
): TrackingAnalyzedContentCitation[] {
|
): TrackingAnalyzedContentCitation[] {
|
||||||
@@ -402,6 +684,34 @@ function analyzeInlineContentCitations(
|
|||||||
return [];
|
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)) {
|
if (isKimiInlineCitationPlatform(platform?.ai_platform_id)) {
|
||||||
const occurrences = collectKimiInlineCitationOccurrences(answerText);
|
const occurrences = collectKimiInlineCitationOccurrences(answerText);
|
||||||
if (!occurrences.length) {
|
if (!occurrences.length) {
|
||||||
@@ -682,7 +992,21 @@ const contentCitationColumns = computed(() => [
|
|||||||
:data-citation-index="index + 1"
|
:data-citation-index="index + 1"
|
||||||
>
|
>
|
||||||
<div class="tracking-question-source-card__headline">
|
<div class="tracking-question-source-card__headline">
|
||||||
<span class="tracking-question-source-card__index">[{{ index + 1 }}]</span>
|
<div class="tracking-question-source-card__indexes">
|
||||||
|
<span
|
||||||
|
v-for="label in resolveCitationIndexDisplayLabels(index, activePlatform)"
|
||||||
|
:key="`${citation.cited_url}-${label}`"
|
||||||
|
class="tracking-question-source-card__reference-badge"
|
||||||
|
>
|
||||||
|
{{ label }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="tracking-question-source-card__index"
|
||||||
|
:class="{ 'tracking-question-source-card__index--secondary': resolveCitationIndexDisplayLabels(index, activePlatform).length > 0 }"
|
||||||
|
>
|
||||||
|
[{{ index + 1 }}]
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<strong>{{ citation.site_name }}</strong>
|
<strong>{{ citation.site_name }}</strong>
|
||||||
<span class="tracking-question-source-card__divider" v-if="resolveCitationTitle(citation)">·</span>
|
<span class="tracking-question-source-card__divider" v-if="resolveCitationTitle(citation)">·</span>
|
||||||
<span class="tracking-question-source-card__title" :title="resolveCitationTitle(citation)">{{ resolveCitationTitle(citation) }}</span>
|
<span class="tracking-question-source-card__title" :title="resolveCitationTitle(citation)">{{ resolveCitationTitle(citation) }}</span>
|
||||||
@@ -1076,6 +1400,13 @@ const contentCitationColumns = computed(() => [
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tracking-question-source-card__indexes {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.tracking-question-source-card__index {
|
.tracking-question-source-card__index {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1091,6 +1422,29 @@ const contentCitationColumns = computed(() => [
|
|||||||
flex-shrink: 0;
|
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 {
|
.tracking-question-source-card__headline strong {
|
||||||
display: block;
|
display: block;
|
||||||
color: #2563eb;
|
color: #2563eb;
|
||||||
@@ -1151,6 +1505,7 @@ const contentCitationColumns = computed(() => [
|
|||||||
line-height: 1;
|
line-height: 1;
|
||||||
vertical-align: baseline;
|
vertical-align: baseline;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
transition: all 0.18s ease;
|
transition: all 0.18s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,11 @@ import {
|
|||||||
isWindowReadyForDetection,
|
isWindowReadyForDetection,
|
||||||
loadWindowURLSafely,
|
loadWindowURLSafely,
|
||||||
} from "./external-window";
|
} from "./external-window";
|
||||||
|
import {
|
||||||
|
buildGenericAIPageFingerprintFallback,
|
||||||
|
genericAIPageLooksAuthenticated,
|
||||||
|
type GenericAIPageState,
|
||||||
|
} from "./generic-ai-auth";
|
||||||
import { upsertDesktopAccount } from "./transport/api-client";
|
import { upsertDesktopAccount } from "./transport/api-client";
|
||||||
import { STANDARD_USER_AGENT } from "./user-agent";
|
import { STANDARD_USER_AGENT } from "./user-agent";
|
||||||
import {
|
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(
|
async function readGenericAIPageState(
|
||||||
webContents: WebContents | null | undefined,
|
webContents: WebContents | null | undefined,
|
||||||
): Promise<GenericAIPageState | null> {
|
): Promise<GenericAIPageState | null> {
|
||||||
@@ -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 {
|
function isLikelyGenericAICredentialName(name: string): boolean {
|
||||||
const normalized = name.trim().toLowerCase();
|
const normalized = name.trim().toLowerCase();
|
||||||
if (/^(x[-_]?csrf[-_]?token|xsrf[-_]?token|csrf[-_]?token|csrf|_csrf)$/i.test(normalized)) {
|
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;
|
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(
|
async function buildGenericAISessionFingerprint(
|
||||||
platformId: string,
|
platformId: string,
|
||||||
session: Session,
|
session: Session,
|
||||||
@@ -1087,7 +1053,7 @@ async function buildGenericAISessionFingerprint(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (parts.length === 0) {
|
if (parts.length === 0) {
|
||||||
return null;
|
return buildGenericAIPageFingerprintFallback(platformId, pageState);
|
||||||
}
|
}
|
||||||
|
|
||||||
return boundedIdentity(`${platformId}-session`, parts.join("||"));
|
return boundedIdentity(`${platformId}-session`, parts.join("||"));
|
||||||
@@ -1115,6 +1081,9 @@ async function detectGenericAIPlatform(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const pageState = await readGenericAIPageState(context.webContents).catch(() => null);
|
const pageState = await readGenericAIPageState(context.webContents).catch(() => null);
|
||||||
|
if ((pageState?.challengeSignalCount ?? 0) > 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
if (!genericAIPageLooksAuthenticated(currentURL, pageState)) {
|
if (!genericAIPageLooksAuthenticated(currentURL, pageState)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -2692,6 +2661,7 @@ export async function bindPublishAccount(platformId: string): Promise<DesktopAcc
|
|||||||
);
|
);
|
||||||
|
|
||||||
const detectAndBind = async () => {
|
const detectAndBind = async () => {
|
||||||
|
syncDetectionState();
|
||||||
const currentURL = window.isDestroyed() ? "" : window.webContents.getURL();
|
const currentURL = window.isDestroyed() ? "" : window.webContents.getURL();
|
||||||
const canProbe = detectReady || /^https?:\/\//i.test(currentURL);
|
const canProbe = detectReady || /^https?:\/\//i.test(currentURL);
|
||||||
if (finished || checking || !canProbe || window.isDestroyed()) {
|
if (finished || checking || !canProbe || window.isDestroyed()) {
|
||||||
|
|||||||
@@ -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<string, unknown>).provider_model).toBeNull();
|
||||||
|
expect((payload.raw_response_json as Record<string, unknown>).provider_request_id).toBeNull();
|
||||||
|
expect((payload.raw_response_json as Record<string, unknown>).request_id).toBeNull();
|
||||||
|
expect((payload.raw_response_json as Record<string, unknown>).signature).toBe("sig-1");
|
||||||
|
expect((payload.raw_response_json as Record<string, unknown>).inline_citation_candidates).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("monitor adapter registry", () => {
|
||||||
|
it("registers deepseek as a monitor adapter", () => {
|
||||||
|
expect(getMonitorAdapter("deepseek")?.provider).toBe("deepseek");
|
||||||
|
});
|
||||||
|
});
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,5 @@
|
|||||||
export * from "./base";
|
export * from "./base";
|
||||||
|
export * from "./deepseek";
|
||||||
export * from "./doubao";
|
export * from "./doubao";
|
||||||
export * from "./kimi";
|
export * from "./kimi";
|
||||||
export * from "./qwen";
|
export * from "./qwen";
|
||||||
@@ -6,3 +7,24 @@ export * from "./toutiao";
|
|||||||
export * from "./wenxin";
|
export * from "./wenxin";
|
||||||
export * from "./yuanbao";
|
export * from "./yuanbao";
|
||||||
export * from "./zhihu";
|
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<string, MonitorAdapter>([
|
||||||
|
[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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -17,12 +17,8 @@ import type {
|
|||||||
import { getAIPlatformCatalogItem, isAIPlatformId } from "@geo/shared-types";
|
import { getAIPlatformCatalogItem, isAIPlatformId } from "@geo/shared-types";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
doubaoAdapter,
|
getMonitorAdapter,
|
||||||
kimiAdapter,
|
|
||||||
qwenAdapter,
|
|
||||||
toutiaoAdapter,
|
toutiaoAdapter,
|
||||||
wenxinAdapter,
|
|
||||||
yuanbaoAdapter,
|
|
||||||
zhihuAdapter,
|
zhihuAdapter,
|
||||||
type AdapterExecutionResult,
|
type AdapterExecutionResult,
|
||||||
type MonitorAdapter,
|
type MonitorAdapter,
|
||||||
@@ -2681,22 +2677,7 @@ function selectPublishAdapter(platform: string): PublishAdapter | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function selectMonitorAdapter(platform: string): MonitorAdapter | null {
|
function selectMonitorAdapter(platform: string): MonitorAdapter | null {
|
||||||
if (platform === yuanbaoAdapter.provider) {
|
return getMonitorAdapter(platform);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeSession(
|
function normalizeSession(
|
||||||
|
|||||||
@@ -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<void> {
|
||||||
|
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"
|
||||||
|
```
|
||||||
@@ -2375,8 +2375,8 @@ func extractMonitoringSourceItems(value interface{}) []MonitoringSourceItem {
|
|||||||
case map[string]interface{}:
|
case map[string]interface{}:
|
||||||
entry := MonitoringSourceItem{
|
entry := MonitoringSourceItem{
|
||||||
URL: firstString(typed, "url", "cited_url", "link"),
|
URL: firstString(typed, "url", "cited_url", "link"),
|
||||||
Title: optionalString(firstString(typed, "title", "cited_title", "name")),
|
Title: optionalString(firstString(typed, "title", "cited_title", "name", "text")),
|
||||||
SiteName: optionalString(firstString(typed, "site_name")),
|
SiteName: optionalString(firstString(typed, "site_name", "siteName")),
|
||||||
SiteKey: optionalString(firstString(typed, "site_key")),
|
SiteKey: optionalString(firstString(typed, "site_key")),
|
||||||
NormalizedURL: optionalString(firstString(typed, "normalized_url")),
|
NormalizedURL: optionalString(firstString(typed, "normalized_url")),
|
||||||
Host: optionalString(firstString(typed, "host")),
|
Host: optionalString(firstString(typed, "host")),
|
||||||
|
|||||||
@@ -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) {
|
func TestExtractMonitoringInlineCitationsFromRawResponseKimi(t *testing.T) {
|
||||||
raw := []byte(`{
|
raw := []byte(`{
|
||||||
"inline_citation_candidates": [
|
"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)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2581,6 +2581,12 @@ func extractMonitoringInlineCitationsFromRawResponse(raw []byte, platformID stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
items := extractMonitoringSourceItems(payload["inline_citation_candidates"])
|
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" {
|
if len(items) == 0 && normalizeMonitoringPlatformID(platformID) == "kimi" {
|
||||||
items = extractMonitoringSourceItems(payload["search_results"])
|
items = extractMonitoringSourceItems(payload["search_results"])
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user