feat(monitoring): dispatch monitoring tasks to desktop via AMQP outbox

Replace SSE /desktop/events with priority AMQP dispatch for monitoring
runs, and add phase1/phase2 infrastructure so desktop workers can lease,
resume, report, skip, and cancel monitoring tasks over the existing
dispatch WebSocket.

- Add monitoring collect outbox worker and phase2 desktop task fields
- Add /desktop/monitoring/tasks/{lease,resume,result,skip,cancel} routes
- Introduce execution-devtools and network-observer on desktop runtime
- Refactor runtime-controller, LoginView, and doubao adapter for the new flow
- Add channelName column to tracking views

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-22 00:24:21 +08:00
parent 749b6b99cd
commit 4142c53fa6
57 changed files with 7897 additions and 985 deletions
@@ -413,6 +413,7 @@ const enUS = {
citationRate: "Citation Rate",
citations: "Citations",
sourcePlatform: "Source",
channelName: "Channel",
domain: "Domain",
share: "Share",
contentName: "Content",
@@ -413,6 +413,7 @@ const zhCN = {
citationRate: "引用率",
citations: "引用次数",
sourcePlatform: "来源平台",
channelName: "渠道名称",
domain: "来源域名",
share: "占比",
contentName: "内容名称",
+8 -5
View File
@@ -941,11 +941,14 @@ export const monitoringApi = {
{ params },
);
},
collectNow(brandId: number, payload?: { keyword_id?: number | null }) {
return apiClient.post<MonitoringCollectNowResponse, { keyword_id?: number | null }>(
`/api/tenant/monitoring/brands/${brandId}/collect-now`,
payload ?? {},
);
collectNow(
brandId: number,
payload?: { keyword_id?: number | null; platform_ids?: string[] },
) {
return apiClient.post<
MonitoringCollectNowResponse,
{ keyword_id?: number | null; platform_ids?: string[] }
>(`/api/tenant/monitoring/brands/${brandId}/collect-now`, payload ?? {});
},
};
@@ -4,7 +4,6 @@ import { useQuery } from "@tanstack/vue-query";
import dayjs from "dayjs";
import type {
MonitoringQuestionCitationAnalysisItem,
MonitoringQuestionContentCitation,
MonitoringQuestionDetailCitation,
MonitoringQuestionDetailPlatform,
} from "@geo/shared-types";
@@ -98,11 +97,6 @@ const activeCitationAnalysis = computed<MonitoringQuestionCitationAnalysisItem[]
return items.filter((item) => item.ai_platform_id === activePlatformId.value);
});
const activeContentCitations = computed<MonitoringQuestionContentCitation[]>(() => {
const items = detailQuery.data.value?.content_citations ?? [];
return items.filter((item) => item.ai_platform_id === activePlatformId.value);
});
const latestSampledAt = computed<string | null>(() => {
const platforms = detailQuery.data.value?.platforms ?? [];
const sampledAtValues = platforms
@@ -126,6 +120,27 @@ const collectedAtDisplay = computed(() => {
return sampledAt ? formatDateTime(sampledAt) : "--";
});
type TrackingAnalyzedContentCitation = {
key: string;
contentName: string;
channelName: string;
citationCount: number;
citationRate: number | null;
citedURL: string | null;
};
const sanitizedAnswerText = computed<string | null>(() => {
return stripAnswerCitationMarkers(activePlatform.value?.answer_text);
});
const activeInlineContentCitations = computed<TrackingAnalyzedContentCitation[]>(() => {
return analyzeInlineContentCitations(activePlatform.value);
});
const shouldShowInlineContentCitations = computed(() => {
return activeInlineContentCitations.value.length > 0;
});
function goBack(): void {
void router.push({
name: "tracking",
@@ -168,6 +183,74 @@ function resolveCitationTitle(citation: MonitoringQuestionDetailCitation): strin
return citation.cited_title || citation.article_title || citation.cited_url;
}
function createQwenSourceGroupPattern(): RegExp {
return /\[\[source_group_web_(\d+)\]\]/g;
}
function stripAnswerCitationMarkers(answerText: string | null | undefined): string | null {
const normalized = String(answerText ?? "").trim();
if (!normalized) {
return null;
}
const cleaned = normalized
.replace(createQwenSourceGroupPattern(), "")
.replace(/[ \t]+([,.;:!?,。!?;:])/g, "$1")
.replace(/[ \t]{2,}/g, " ")
.replace(/\n{3,}/g, "\n\n")
.trim();
return cleaned || null;
}
function analyzeInlineContentCitations(
platform: MonitoringQuestionDetailPlatform | null,
): TrackingAnalyzedContentCitation[] {
const answerText = String(platform?.answer_text ?? "");
if (!answerText) {
return [];
}
const matches = Array.from(answerText.matchAll(createQwenSourceGroupPattern()));
if (!matches.length) {
return [];
}
const citationCounts = new Map<number, number>();
for (const match of matches) {
const sourceGroupIndex = Number(match[1]);
if (!Number.isFinite(sourceGroupIndex) || sourceGroupIndex <= 0) {
continue;
}
citationCounts.set(sourceGroupIndex, (citationCounts.get(sourceGroupIndex) ?? 0) + 1);
}
const totalCitationCount = Array.from(citationCounts.values()).reduce((sum, count) => sum + count, 0);
if (!totalCitationCount) {
return [];
}
return Array.from(citationCounts.entries())
.sort((left, right) => {
if (right[1] !== left[1]) {
return right[1] - left[1];
}
return left[0] - right[0];
})
.map(([sourceGroupIndex, citationCount]) => {
const citation = platform?.citations[sourceGroupIndex - 1] ?? null;
return {
key: `${sourceGroupIndex}-${citation?.cited_url ?? `source_group_web_${sourceGroupIndex}`}`,
contentName: citation ? resolveCitationTitle(citation) : `引用内容 #${sourceGroupIndex}`,
channelName: citation?.site_name ?? "--",
citationCount,
citationRate: citationCount / totalCitationCount,
citedURL: citation?.cited_url ?? null,
};
});
}
function normalizeQueryValue(value: unknown): string {
const raw = Array.isArray(value) ? value[0] : value;
return String(raw ?? "").trim();
@@ -268,8 +351,8 @@ function parsePositiveNumber(value: unknown): number | null {
<div class="tracking-question-answer__content custom-scrollbar">
<MarkdownPreview
v-if="activePlatform?.answer_text"
:content="activePlatform.answer_text"
v-if="sanitizedAnswerText"
:content="sanitizedAnswerText"
/>
<a-empty v-else :description="t('tracking.noAnswer')" />
</div>
@@ -314,7 +397,12 @@ function parsePositiveNumber(value: unknown): number | null {
</div>
</section>
<section class="tracking-question-card">
<section
:class="[
'tracking-question-card',
!shouldShowInlineContentCitations ? 'tracking-question-card--span-2' : '',
]"
>
<div class="tracking-question-card__header">
<div class="tracking-question-card__section-title">
<span class="tracking-question-card__accent" />
@@ -351,7 +439,7 @@ function parsePositiveNumber(value: unknown): number | null {
</div>
</section>
<section class="tracking-question-card">
<section v-if="shouldShowInlineContentCitations" class="tracking-question-card">
<div class="tracking-question-card__header">
<div class="tracking-question-card__section-title">
<span class="tracking-question-card__accent" />
@@ -360,34 +448,40 @@ function parsePositiveNumber(value: unknown): number | null {
</div>
<div class="tracking-question-card__scroll-area custom-scrollbar">
<div v-if="activeContentCitations.length" class="tracking-question-table">
<div
class="tracking-question-table__head"
>
<div class="tracking-question-table">
<div class="tracking-question-table__head">
<span>{{ t("tracking.columns.contentName") }}</span>
<span>{{ t("tracking.columns.publishPlatform") }}</span>
<span>{{ t("tracking.columns.channelName") }}</span>
<span>{{ t("tracking.columns.citations") }}</span>
<span>{{ t("tracking.columns.share") }}</span>
</div>
<div
v-for="item in activeContentCitations"
:key="`${item.ai_platform_id}-${item.article_id}`"
v-for="item in activeInlineContentCitations"
:key="item.key"
class="tracking-question-table__row"
>
<div class="tracking-question-table__primary">
<router-link :to="`/articles/${item.article_id}/edit`">{{ item.article_title }}</router-link>
<a
v-if="item.citedURL"
:href="item.citedURL"
target="_blank"
rel="noreferrer"
:title="item.contentName"
>
{{ item.contentName }}
</a>
<strong v-else>{{ item.contentName }}</strong>
</div>
<span class="tracking-question-table__secondary">{{ item.publish_platform }}</span>
<span class="tracking-question-table__secondary">{{ item.channelName }}</span>
<div class="tracking-question-table__metric">
<span>{{ item.citation_count }}</span>
<span>{{ item.citationCount }}</span>
</div>
<div class="tracking-question-table__metric">
<strong>{{ formatPercent(item.citation_rate) }}</strong>
<strong>{{ formatPercent(item.citationRate) }}</strong>
</div>
</div>
</div>
<a-empty v-else :description="t('tracking.noContentCitations')" />
</div>
</section>
</div>
@@ -528,6 +622,10 @@ function parsePositiveNumber(value: unknown): number | null {
/* removed min-height, grid-auto-rows handles this universally */
}
.tracking-question-card--span-2 {
grid-column: 1 / -1;
}
.tracking-question-card__header {
margin-bottom: 20px;
flex-shrink: 0;
@@ -261,6 +261,8 @@ const collectNowMutation = useMutation({
return monitoringApi.collectNow(selectedBrandId.value, {
keyword_id: selectedKeywordId.value,
platform_ids:
selectedPlatformId.value !== "all" ? [selectedPlatformId.value] : undefined,
});
},
onSuccess: (collectNowResult) => {