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:
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user