feat: Implement Qwen adapter and enhance AI platform detection
- Added a new Qwen adapter to facilitate monitoring through Playwright, leveraging internal text/chat managers. - Updated account detection logic to recognize Qwen sessions based on persisted cookies, improving binding reliability. - Relaxed authentication requirements for monitor tasks, allowing anonymous execution for certain AI platforms. - Enhanced the generic AI platform detection to include Qwen-specific logic, ensuring accurate session identification. - Modified the monitoring dashboard to include runtime state indicating if the current user's desktop client is online. - Updated various files including `account-binder.ts`, `runtime-controller.ts`, and `monitoring_service.go` to support new features and improvements.
This commit is contained in:
@@ -346,7 +346,10 @@ const enUS = {
|
|||||||
brandPlaceholder: "Select brand",
|
brandPlaceholder: "Select brand",
|
||||||
keywordPlaceholder: "Select keyword",
|
keywordPlaceholder: "Select keyword",
|
||||||
collectNow: "Collect Now",
|
collectNow: "Collect Now",
|
||||||
|
collectBrandRequired: "Select a brand before collecting.",
|
||||||
collectKeywordRequired: "Select a keyword before collecting the current question set.",
|
collectKeywordRequired: "Select a keyword before collecting the current question set.",
|
||||||
|
collectClientChecking: "Checking whether the current account's desktop client is online.",
|
||||||
|
collectClientOnlineRequired: "The current account's desktop client must be online before collecting.",
|
||||||
pluginMode: "Plugin sampling",
|
pluginMode: "Plugin sampling",
|
||||||
pluginHint: "Plugin-mode data depends on device uptime. Low coverage should be treated as directional only.",
|
pluginHint: "Plugin-mode data depends on device uptime. Low coverage should be treated as directional only.",
|
||||||
awaitingSnapshot: "Waiting for the next valid snapshot",
|
awaitingSnapshot: "Waiting for the next valid snapshot",
|
||||||
|
|||||||
@@ -346,7 +346,10 @@ const zhCN = {
|
|||||||
brandPlaceholder: "选择品牌",
|
brandPlaceholder: "选择品牌",
|
||||||
keywordPlaceholder: "选择关键词",
|
keywordPlaceholder: "选择关键词",
|
||||||
collectNow: "立即采集",
|
collectNow: "立即采集",
|
||||||
|
collectBrandRequired: "请先选择品牌,再发起采集",
|
||||||
collectKeywordRequired: "请先选择关键词,再立即采集当前问题",
|
collectKeywordRequired: "请先选择关键词,再立即采集当前问题",
|
||||||
|
collectClientChecking: "正在检查当前账号的桌面客户端状态",
|
||||||
|
collectClientOnlineRequired: "当前登录账号的 desktop-client 未在线,暂时不能立即采集",
|
||||||
pluginMode: "插件采样",
|
pluginMode: "插件采样",
|
||||||
pluginHint: "插件模式下结果受设备在线率影响,低覆盖样本仅供趋势参考。",
|
pluginHint: "插件模式下结果受设备在线率影响,低覆盖样本仅供趋势参考。",
|
||||||
awaitingSnapshot: "等待下一次有效快照",
|
awaitingSnapshot: "等待下一次有效快照",
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ const errorMessageMap: Record<string, string> = {
|
|||||||
publish_cover_required: "已选择百家号,请先上传封面图",
|
publish_cover_required: "已选择百家号,请先上传封面图",
|
||||||
desktop_account_client_missing: "所选账号还没有绑定桌面客户端,暂时无法发布",
|
desktop_account_client_missing: "所选账号还没有绑定桌面客户端,暂时无法发布",
|
||||||
desktop_account_not_found: "所选桌面账号不存在,请刷新后重试",
|
desktop_account_not_found: "所选桌面账号不存在,请刷新后重试",
|
||||||
|
desktop_client_offline: "当前登录账号的桌面客户端未在线,请先打开 desktop-client",
|
||||||
desktop_publish_job_store_unavailable: "发布队列暂时不可用,请稍后重试",
|
desktop_publish_job_store_unavailable: "发布队列暂时不可用,请稍后重试",
|
||||||
desktop_media_api_removed: "旧版媒体接口已下线,请刷新页面后重试",
|
desktop_media_api_removed: "旧版媒体接口已下线,请刷新页面后重试",
|
||||||
publisher_plugin_timeout: "浏览器插件响应超时,请刷新当前页面后重试",
|
publisher_plugin_timeout: "浏览器插件响应超时,请刷新当前页面后重试",
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import type {
|
|||||||
MonitoringTimeBucket,
|
MonitoringTimeBucket,
|
||||||
} from "@geo/shared-types";
|
} from "@geo/shared-types";
|
||||||
import { aiPlatformCatalog } from "@geo/shared-types";
|
import { aiPlatformCatalog } from "@geo/shared-types";
|
||||||
import { computed, ref, watch } from "vue";
|
import { computed, watch } from "vue";
|
||||||
import { useI18n } from "vue-i18n";
|
import { useI18n } from "vue-i18n";
|
||||||
import { useRoute, useRouter } from "vue-router";
|
import { useRoute, useRouter } from "vue-router";
|
||||||
|
|
||||||
@@ -21,8 +21,6 @@ import PageHero from "@/components/PageHero.vue";
|
|||||||
import { brandsApi, monitoringApi, tenantAccountsApi } from "@/lib/api";
|
import { brandsApi, monitoringApi, tenantAccountsApi } from "@/lib/api";
|
||||||
import { formatDateTime } from "@/lib/display";
|
import { formatDateTime } from "@/lib/display";
|
||||||
import { formatError } from "@/lib/errors";
|
import { formatError } from "@/lib/errors";
|
||||||
import { kickoffMonitoringCycle } from "@/lib/monitoring-plugin";
|
|
||||||
import { loadPublisherRuntimeState } from "@/lib/publisher-runtime";
|
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
@@ -102,7 +100,6 @@ const selectedBrandId = useStorage<number | null>("tracking_selected_brand", nul
|
|||||||
const selectedKeywordId = useStorage<number | null>("tracking_selected_keyword", null);
|
const selectedKeywordId = useStorage<number | null>("tracking_selected_keyword", null);
|
||||||
const selectedPlatformId = useStorage<string>("tracking_selected_ai_platform_id", "all");
|
const selectedPlatformId = useStorage<string>("tracking_selected_ai_platform_id", "all");
|
||||||
const selectedBusinessDate = useStorage<string>("tracking_selected_business_date", trackingToday);
|
const selectedBusinessDate = useStorage<string>("tracking_selected_business_date", trackingToday);
|
||||||
const pluginPreparing = ref(false);
|
|
||||||
|
|
||||||
selectedPlatformId.value = normalizeTrackingPlatformId(selectedPlatformId.value);
|
selectedPlatformId.value = normalizeTrackingPlatformId(selectedPlatformId.value);
|
||||||
selectedBusinessDate.value = normalizeTrackingBusinessDate(selectedBusinessDate.value) ?? trackingToday;
|
selectedBusinessDate.value = normalizeTrackingBusinessDate(selectedBusinessDate.value) ?? trackingToday;
|
||||||
@@ -228,25 +225,6 @@ const trackingWindow = computed(() => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
async function ensureMonitoringPluginReady(options?: { silent?: boolean }): Promise<void> {
|
|
||||||
pluginPreparing.value = true;
|
|
||||||
try {
|
|
||||||
const runtime = await loadPublisherRuntimeState();
|
|
||||||
if (!runtime.ping.installed) {
|
|
||||||
throw new Error("monitoring_plugin_required");
|
|
||||||
}
|
|
||||||
if (!runtime.pluginInstallationId) {
|
|
||||||
throw new Error("monitoring_plugin_register_required");
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (!options?.silent) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
pluginPreparing.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const dashboardQuery = useQuery({
|
const dashboardQuery = useQuery({
|
||||||
queryKey: computed(() => [
|
queryKey: computed(() => [
|
||||||
"tracking",
|
"tracking",
|
||||||
@@ -274,52 +252,28 @@ const aiAccountsQuery = useQuery({
|
|||||||
|
|
||||||
const collectNowMutation = useMutation({
|
const collectNowMutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
|
if (!selectedBrandId.value) {
|
||||||
|
throw new Error("tracking_brand_required");
|
||||||
|
}
|
||||||
if (!selectedKeywordId.value) {
|
if (!selectedKeywordId.value) {
|
||||||
throw new Error("tracking_keyword_required");
|
throw new Error("tracking_keyword_required");
|
||||||
}
|
}
|
||||||
await ensureMonitoringPluginReady();
|
|
||||||
const collectNowResult = await monitoringApi.collectNow(selectedBrandId.value as number, {
|
return monitoringApi.collectNow(selectedBrandId.value, {
|
||||||
keyword_id: selectedKeywordId.value,
|
keyword_id: selectedKeywordId.value,
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
|
||||||
const kickoffResult = await kickoffMonitoringCycle();
|
|
||||||
return {
|
|
||||||
collectNowResult,
|
|
||||||
kickoffResult,
|
|
||||||
kickoffError: null,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
collectNowResult,
|
|
||||||
kickoffResult: null,
|
|
||||||
kickoffError: formatError(error),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
onSuccess: ({ collectNowResult, kickoffResult, kickoffError }) => {
|
onSuccess: (collectNowResult) => {
|
||||||
if (kickoffError) {
|
|
||||||
message.warning(`${collectNowResult.message},但后台采集启动失败:${kickoffError}`);
|
|
||||||
void dashboardQuery.refetch();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (kickoffResult?.status === "already_running") {
|
|
||||||
message.success(`${collectNowResult.message},插件后台已继续执行`);
|
|
||||||
void dashboardQuery.refetch();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
message.success(collectNowResult.message);
|
message.success(collectNowResult.message);
|
||||||
void dashboardQuery.refetch();
|
void dashboardQuery.refetch();
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
if (error instanceof Error && error.message === "tracking_keyword_required") {
|
if (error instanceof Error && error.message === "tracking_brand_required") {
|
||||||
message.warning(t("tracking.collectKeywordRequired"));
|
message.warning(t("tracking.collectBrandRequired"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (error instanceof Error && (error.message === "monitoring_plugin_required" || error.message === "monitoring_plugin_register_required")) {
|
if (error instanceof Error && error.message === "tracking_keyword_required") {
|
||||||
message.warning(formatError(error));
|
message.warning(t("tracking.collectKeywordRequired"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
message.error(formatError(error));
|
message.error(formatError(error));
|
||||||
@@ -330,6 +284,26 @@ const selectedBrand = computed<Brand | null>(() => {
|
|||||||
return brandsQuery.data.value?.find((item) => item.id === selectedBrandId.value) ?? null;
|
return brandsQuery.data.value?.find((item) => item.id === selectedBrandId.value) ?? null;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const currentUserClientOnline = computed(() => {
|
||||||
|
return dashboardQuery.data.value?.runtime.current_user_client_online === true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const collectNowDisabledReason = computed(() => {
|
||||||
|
if (!selectedBrandId.value) {
|
||||||
|
return t("tracking.collectBrandRequired");
|
||||||
|
}
|
||||||
|
if (!selectedKeywordId.value) {
|
||||||
|
return t("tracking.collectKeywordRequired");
|
||||||
|
}
|
||||||
|
if (dashboardQuery.isLoading.value && !dashboardQuery.data.value) {
|
||||||
|
return t("tracking.collectClientChecking");
|
||||||
|
}
|
||||||
|
if (!currentUserClientOnline.value) {
|
||||||
|
return t("tracking.collectClientOnlineRequired");
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
const selectedDateHasData = computed(() => {
|
const selectedDateHasData = computed(() => {
|
||||||
return (dashboardQuery.data.value?.overview.actual_sample_count ?? 0) > 0;
|
return (dashboardQuery.data.value?.overview.actual_sample_count ?? 0) > 0;
|
||||||
});
|
});
|
||||||
@@ -514,8 +488,6 @@ const citationChartStyle = computed(() => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
void ensureMonitoringPluginReady({ silent: true });
|
|
||||||
|
|
||||||
const heroDescription = computed(() => {
|
const heroDescription = computed(() => {
|
||||||
const overview = dashboardQuery.data.value?.overview;
|
const overview = dashboardQuery.data.value?.overview;
|
||||||
if (!overview?.last_sampled_at) {
|
if (!overview?.last_sampled_at) {
|
||||||
@@ -767,15 +739,19 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
|
|||||||
:disabled-date="disableTrackingDate"
|
:disabled-date="disableTrackingDate"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<a-button
|
<a-tooltip :title="collectNowDisabledReason ?? undefined">
|
||||||
type="primary"
|
<span>
|
||||||
:disabled="!selectedBrandId || !selectedKeywordId"
|
<a-button
|
||||||
:loading="collectNowMutation.isPending.value || pluginPreparing"
|
type="primary"
|
||||||
@click="collectNowMutation.mutate()"
|
:disabled="Boolean(collectNowDisabledReason)"
|
||||||
>
|
:loading="collectNowMutation.isPending.value"
|
||||||
<template #icon><ReloadOutlined /></template>
|
@click="collectNowMutation.mutate()"
|
||||||
{{ t("tracking.collectNow") }}
|
>
|
||||||
</a-button>
|
<template #icon><ReloadOutlined /></template>
|
||||||
|
{{ t("tracking.collectNow") }}
|
||||||
|
</a-button>
|
||||||
|
</span>
|
||||||
|
</a-tooltip>
|
||||||
</a-space>
|
</a-space>
|
||||||
</template>
|
</template>
|
||||||
</PageHero>
|
</PageHero>
|
||||||
|
|||||||
@@ -1009,6 +1009,10 @@ function genericAIConversationPath(pathname: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isLikelyGenericAICredentialName(name: string): boolean {
|
function isLikelyGenericAICredentialName(name: string): boolean {
|
||||||
|
const normalized = name.trim().toLowerCase();
|
||||||
|
if (/^(x[-_]?csrf[-_]?token|xsrf[-_]?token|csrf[-_]?token|csrf|_csrf)$/i.test(normalized)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
return /(token|session|auth|login|passport|ticket|jwt|refresh|access)/i.test(name);
|
return /(token|session|auth|login|passport|ticket|jwt|refresh|access)/i.test(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1126,6 +1130,103 @@ async function detectGenericAIPlatform(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function detectQwenFromSession(
|
||||||
|
session: Session,
|
||||||
|
hints: {
|
||||||
|
displayName?: string | null;
|
||||||
|
avatarUrl?: string | null;
|
||||||
|
} = {},
|
||||||
|
): Promise<DetectedAccount | null> {
|
||||||
|
const cookies = await session.cookies.get({ url: "https://www.qianwen.com/" }).catch(() => []);
|
||||||
|
if (!cookies.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cookieMap = new Map<string, string>();
|
||||||
|
for (const cookie of cookies) {
|
||||||
|
if (!cookieMap.has(cookie.name)) {
|
||||||
|
cookieMap.set(cookie.name, cookie.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ticketHash = normalizeText(cookieMap.get("tongyi_sso_ticket_hash"));
|
||||||
|
const ticket = normalizeText(cookieMap.get("tongyi_sso_ticket"));
|
||||||
|
const platformUid = ticketHash ?? ticket;
|
||||||
|
if (!platformUid) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return sanitizeDetectedAccount({
|
||||||
|
platformUid,
|
||||||
|
displayName: hints.displayName ?? "通义千问账号",
|
||||||
|
avatarUrl: hints.avatarUrl ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function detectQwenPlatform(
|
||||||
|
platformId: string,
|
||||||
|
label: string,
|
||||||
|
context: DetectContext,
|
||||||
|
): Promise<DetectedAccount | null> {
|
||||||
|
const genericDetected = await detectGenericAIPlatform(platformId, label, context);
|
||||||
|
if (genericDetected) {
|
||||||
|
return genericDetected;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!context.webContents || context.webContents.isDestroyed()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentURL = context.webContents.getURL();
|
||||||
|
const platformMeta = aiPlatformCatalog.find((item) => item.id === platformId) ?? null;
|
||||||
|
if (!platformMeta) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentURL && looksLikeLoginRedirect(currentURL, platformMeta.loginUrl)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageState = await readGenericAIPageState(context.webContents).catch(() => null);
|
||||||
|
if ((pageState?.challengeSignalCount ?? 0) > 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if ((pageState?.loggedOutSignalCount ?? 0) > 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fromSession = await detectQwenFromSession(context.session, {
|
||||||
|
displayName: pageState?.displayName ?? `${label} 会话`,
|
||||||
|
avatarUrl: pageState?.avatarUrl ?? null,
|
||||||
|
});
|
||||||
|
if (fromSession) {
|
||||||
|
return fromSession;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fingerprint = await buildGenericAISessionFingerprint(platformId, context.session, pageState);
|
||||||
|
if (!fingerprint) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return sanitizeDetectedAccount({
|
||||||
|
platformUid: fingerprint,
|
||||||
|
displayName: pageState?.displayName ?? `${label} 会话`,
|
||||||
|
avatarUrl: pageState?.avatarUrl ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function detectAIPlatformAccount(
|
||||||
|
platformId: string,
|
||||||
|
label: string,
|
||||||
|
context: DetectContext,
|
||||||
|
): Promise<DetectedAccount | null> {
|
||||||
|
if (platformId === "qwen") {
|
||||||
|
return await detectQwenPlatform(platformId, label, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await detectGenericAIPlatform(platformId, label, context);
|
||||||
|
}
|
||||||
|
|
||||||
async function detectToutiao({ session, webContents }: DetectContext): Promise<DetectedAccount | null> {
|
async function detectToutiao({ session, webContents }: DetectContext): Promise<DetectedAccount | null> {
|
||||||
const pageResponse = await pageFetchJson<ToutiaoMediaInfoResponse>(
|
const pageResponse = await pageFetchJson<ToutiaoMediaInfoResponse>(
|
||||||
webContents,
|
webContents,
|
||||||
@@ -1537,7 +1638,7 @@ export async function probePublishAccountSession(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const detected = await detectGenericAIPlatform(account.platform, definition.label, {
|
const detected = await detectAIPlatformAccount(account.platform, definition.label, {
|
||||||
session: handle.session,
|
session: handle.session,
|
||||||
webContents: window.webContents,
|
webContents: window.webContents,
|
||||||
});
|
});
|
||||||
@@ -2329,7 +2430,7 @@ const aiBindingDefinitions: Record<string, PublishPlatformBindingDefinition> = O
|
|||||||
label: platform.label,
|
label: platform.label,
|
||||||
loginUrl: platform.loginUrl,
|
loginUrl: platform.loginUrl,
|
||||||
consoleUrl: platform.consoleUrl,
|
consoleUrl: platform.consoleUrl,
|
||||||
detect: (context: DetectContext) => detectGenericAIPlatform(platform.id, platform.label, context),
|
detect: (context: DetectContext) => detectAIPlatformAccount(platform.id, platform.label, context),
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
) as Record<string, PublishPlatformBindingDefinition>;
|
) as Record<string, PublishPlatformBindingDefinition>;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export * from "./base";
|
export * from "./base";
|
||||||
export * from "./doubao";
|
export * from "./doubao";
|
||||||
|
export * from "./qwen";
|
||||||
export * from "./toutiao";
|
export * from "./toutiao";
|
||||||
export * from "./zhihu";
|
export * from "./zhihu";
|
||||||
|
|||||||
@@ -0,0 +1,671 @@
|
|||||||
|
import type { JsonValue, MonitoringSourceItem } from "@geo/shared-types";
|
||||||
|
|
||||||
|
import { normalizeText } from "./common";
|
||||||
|
import type { MonitorAdapter } from "./base";
|
||||||
|
|
||||||
|
const QWEN_BOOTSTRAP_URL = "https://www.qianwen.com/";
|
||||||
|
const QWEN_PAGE_READY_TIMEOUT_MS = 20_000;
|
||||||
|
const QWEN_QUERY_TIMEOUT_MS = 90_000;
|
||||||
|
|
||||||
|
type QwenPageQuestionSnapshot = {
|
||||||
|
model: string | null;
|
||||||
|
deepSearch: string | null;
|
||||||
|
enableSearch: boolean | null;
|
||||||
|
cardText: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type QwenPageAnswerSnapshot = {
|
||||||
|
reqId: string | null;
|
||||||
|
status: string | null;
|
||||||
|
content: Record<string, unknown> | null;
|
||||||
|
extraInfo: Record<string, unknown> | null;
|
||||||
|
communication: Record<string, unknown> | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type QwenPageQuerySuccessResult = {
|
||||||
|
ok: true;
|
||||||
|
url: string;
|
||||||
|
question: QwenPageQuestionSnapshot | null;
|
||||||
|
answer: QwenPageAnswerSnapshot | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type QwenPageQueryFailureResult = {
|
||||||
|
ok: false;
|
||||||
|
error: string;
|
||||||
|
detail: string | null;
|
||||||
|
url: string | null;
|
||||||
|
question: QwenPageQuestionSnapshot | null;
|
||||||
|
answer: QwenPageAnswerSnapshot | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type QwenPageQueryResult = QwenPageQuerySuccessResult | QwenPageQueryFailureResult;
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
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 readNestedString(source: unknown, path: string[]): string | null {
|
||||||
|
let current: unknown = source;
|
||||||
|
for (const segment of path) {
|
||||||
|
if (!isRecord(current) || !(segment in current)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
current = current[segment];
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizeOptionalString(current);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeUrl(value: unknown): string | null {
|
||||||
|
const input = normalizeText(value);
|
||||||
|
if (!input) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = new URL(input);
|
||||||
|
url.hash = "";
|
||||||
|
return url.toString();
|
||||||
|
} catch {
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildQwenSourceItem(source: unknown): MonitoringSourceItem | null {
|
||||||
|
if (!isRecord(source)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = normalizeUrl(source.url ?? source.href ?? source.link);
|
||||||
|
if (!url) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let host: string | null = null;
|
||||||
|
try {
|
||||||
|
host = new URL(url).hostname || null;
|
||||||
|
} catch {
|
||||||
|
host = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
url,
|
||||||
|
title: normalizeText(source.title),
|
||||||
|
site_name: normalizeText(source.name ?? source.site_name ?? source.authority),
|
||||||
|
normalized_url: url,
|
||||||
|
host,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectQwenSources(payload: unknown, result: MonitoringSourceItem[], depth = 0): void {
|
||||||
|
if (depth > 12 || payload == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(payload)) {
|
||||||
|
for (const item of payload) {
|
||||||
|
collectQwenSources(item, result, depth + 1);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isRecord(payload)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceItem = buildQwenSourceItem(payload);
|
||||||
|
if (sourceItem) {
|
||||||
|
result.push(sourceItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const value of Object.values(payload)) {
|
||||||
|
if (Array.isArray(value) || isRecord(value)) {
|
||||||
|
collectQwenSources(value, result, depth + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dedupeSourceItems(items: MonitoringSourceItem[]): MonitoringSourceItem[] {
|
||||||
|
const keyed = new Map<string, MonitoringSourceItem>();
|
||||||
|
for (const item of items) {
|
||||||
|
const key = normalizeUrl(item.normalized_url ?? item.url);
|
||||||
|
if (!key) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = keyed.get(key);
|
||||||
|
if (!existing) {
|
||||||
|
keyed.set(key, {
|
||||||
|
...item,
|
||||||
|
normalized_url: key,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
keyed.set(key, {
|
||||||
|
...existing,
|
||||||
|
title: existing.title ?? item.title ?? null,
|
||||||
|
site_name: existing.site_name ?? item.site_name ?? null,
|
||||||
|
host: existing.host ?? item.host ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(keyed.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveQwenProviderModel(pageResult: QwenPageQuerySuccessResult): string | null {
|
||||||
|
return (
|
||||||
|
readNestedString(pageResult.answer?.extraInfo, ["chat_odps", "model_info", "model"])
|
||||||
|
?? normalizeText(pageResult.question?.model)
|
||||||
|
?? (pageResult.question?.deepSearch === "1" ? "Qwen-deepThink" : "Qwen")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatQwenQueryError(result: QwenPageQueryResult): string {
|
||||||
|
if (!result.ok) {
|
||||||
|
const detail = normalizeText(result.detail);
|
||||||
|
return detail ? `${result.error}: ${detail}` : result.error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = normalizeText(result.answer?.status);
|
||||||
|
return status ? `qwen query ended with status ${status}` : "qwen query failed";
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractQuestionText(payload: Record<string, unknown>): 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("qwen_question_text_missing");
|
||||||
|
}
|
||||||
|
|
||||||
|
function toJsonValue(value: unknown, depth = 0): JsonValue {
|
||||||
|
if (value == null || depth > 16) {
|
||||||
|
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<string, JsonValue> = {};
|
||||||
|
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 buildAdapterError(
|
||||||
|
code: string,
|
||||||
|
message: string,
|
||||||
|
extras: Record<string, JsonValue> = {},
|
||||||
|
): Record<string, JsonValue> {
|
||||||
|
return {
|
||||||
|
code,
|
||||||
|
message,
|
||||||
|
...extras,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function qwenQueryInPage(parameters: {
|
||||||
|
questionText: string;
|
||||||
|
timeoutMs: number;
|
||||||
|
readyTimeoutMs: number;
|
||||||
|
deepThink: boolean;
|
||||||
|
}): Promise<QwenPageQueryResult> {
|
||||||
|
const questionText = parameters.questionText;
|
||||||
|
const queryTimeoutMs = parameters.timeoutMs;
|
||||||
|
const readyTimeoutMs = parameters.readyTimeoutMs;
|
||||||
|
const deepThinkEnabled = parameters.deepThink;
|
||||||
|
|
||||||
|
const wait = (timeMs: number) =>
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
|
window.setTimeout(resolve, timeMs);
|
||||||
|
});
|
||||||
|
|
||||||
|
const isObjectRecord = (value: unknown): value is Record<string, unknown> =>
|
||||||
|
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
|
||||||
|
const normalizeInlineText = (value: unknown): string | null => {
|
||||||
|
if (typeof value !== "string") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed ? trimmed : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const toSerializable = (value: unknown): Record<string, unknown> | null => {
|
||||||
|
if (value == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(JSON.stringify(value)) as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildPageDetail = () => {
|
||||||
|
const title = normalizeInlineText(document.title);
|
||||||
|
const url = normalizeInlineText(window.location.href);
|
||||||
|
const bodyText = normalizeInlineText(document.body?.innerText)?.slice(0, 120) ?? null;
|
||||||
|
return [title, url, bodyText].filter(Boolean).join(" | ") || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const locateManagers = () => {
|
||||||
|
const bindingMap = (
|
||||||
|
window as typeof window & {
|
||||||
|
__TONGYI_PORTSL_GLOBAL_CONTAINER__?: {
|
||||||
|
container?: {
|
||||||
|
_bindingDictionary?: {
|
||||||
|
_map?: Map<unknown, unknown[]>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
).__TONGYI_PORTSL_GLOBAL_CONTAINER__?.container?._bindingDictionary?._map;
|
||||||
|
|
||||||
|
if (!(bindingMap instanceof Map)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let textAreaManager: Record<string, unknown> | null = null;
|
||||||
|
let chatManager: Record<string, unknown> | null = null;
|
||||||
|
|
||||||
|
for (const bindings of bindingMap.values()) {
|
||||||
|
if (!Array.isArray(bindings)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const binding of bindings) {
|
||||||
|
const candidate = isObjectRecord(binding) && "cache" in binding ? binding.cache : null;
|
||||||
|
if (!isObjectRecord(candidate)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const prototype = Object.getPrototypeOf(candidate);
|
||||||
|
const protoKeys = Object.getOwnPropertyNames(prototype ?? {});
|
||||||
|
if (!textAreaManager && protoKeys.includes("setText") && protoKeys.includes("getWidget") && protoKeys.includes("enterMode")) {
|
||||||
|
textAreaManager = candidate;
|
||||||
|
}
|
||||||
|
if (!chatManager && protoKeys.includes("textAreaSubmit") && protoKeys.includes("sendWithParams") && protoKeys.includes("submit")) {
|
||||||
|
chatManager = candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = (
|
||||||
|
window as typeof window & {
|
||||||
|
__qianwenChatAPI?: {
|
||||||
|
sharedValues?: {
|
||||||
|
chatAPI?: {
|
||||||
|
state?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
).__qianwenChatAPI?.sharedValues?.chatAPI?.state;
|
||||||
|
|
||||||
|
if (!textAreaManager || !chatManager || !isObjectRecord(state)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
textAreaManager,
|
||||||
|
chatManager,
|
||||||
|
state,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const waitForReadyManagers = async () => {
|
||||||
|
const deadline = Date.now() + readyTimeoutMs;
|
||||||
|
while (Date.now() <= deadline) {
|
||||||
|
const managers = locateManagers();
|
||||||
|
if (managers) {
|
||||||
|
return managers;
|
||||||
|
}
|
||||||
|
await wait(250);
|
||||||
|
}
|
||||||
|
|
||||||
|
return locateManagers();
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLatestSnapshot = (minimumRoundCount: number) => {
|
||||||
|
const state = (
|
||||||
|
window as typeof window & {
|
||||||
|
__qianwenChatAPI?: {
|
||||||
|
sharedValues?: {
|
||||||
|
chatAPI?: {
|
||||||
|
state?: {
|
||||||
|
chatRounds?: Array<Record<string, unknown>>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
).__qianwenChatAPI?.sharedValues?.chatAPI?.state;
|
||||||
|
|
||||||
|
const chatRounds = Array.isArray(state?.chatRounds) ? state.chatRounds : [];
|
||||||
|
const candidateRounds = chatRounds.length > minimumRoundCount ? chatRounds.slice(minimumRoundCount) : chatRounds;
|
||||||
|
|
||||||
|
for (let index = candidateRounds.length - 1; index >= 0; index -= 1) {
|
||||||
|
const round = candidateRounds[index];
|
||||||
|
if (!isObjectRecord(round)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const questions = Array.isArray(round.questions) ? round.questions : [];
|
||||||
|
const currentQuestionIndex = typeof round.currentQuestionIndex === "number" ? round.currentQuestionIndex : 0;
|
||||||
|
const question = questions[currentQuestionIndex];
|
||||||
|
if (!isObjectRecord(question)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cards = Array.isArray(question.cards) ? question.cards : [];
|
||||||
|
const firstCard = cards[0];
|
||||||
|
const cardText =
|
||||||
|
isObjectRecord(firstCard) && "content" in firstCard ? normalizeInlineText(firstCard.content) : null;
|
||||||
|
if (cardText !== questionText) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const answers = Array.isArray(question.answers) ? question.answers : [];
|
||||||
|
const currentAnswerIndex = typeof question.currentAnswerIndex === "number" ? question.currentAnswerIndex : 0;
|
||||||
|
const answer = answers[currentAnswerIndex];
|
||||||
|
|
||||||
|
return {
|
||||||
|
question: {
|
||||||
|
model: normalizeInlineText(question.model),
|
||||||
|
deepSearch: normalizeInlineText(question.deepSearch),
|
||||||
|
enableSearch: typeof question.enableSearch === "boolean" ? question.enableSearch : null,
|
||||||
|
cardText,
|
||||||
|
},
|
||||||
|
answer: isObjectRecord(answer)
|
||||||
|
? {
|
||||||
|
reqId: normalizeInlineText(answer.reqId),
|
||||||
|
status: normalizeInlineText(answer.status),
|
||||||
|
content: toSerializable(answer.content),
|
||||||
|
extraInfo: toSerializable(answer.extraInfo),
|
||||||
|
communication: toSerializable(answer.communication),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
question: null,
|
||||||
|
answer: null,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
return (async (): Promise<QwenPageQueryResult> => {
|
||||||
|
const managers = await waitForReadyManagers();
|
||||||
|
if (!managers) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: "qwen_page_not_ready",
|
||||||
|
detail: buildPageDetail(),
|
||||||
|
url: window.location.href || null,
|
||||||
|
question: null,
|
||||||
|
answer: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const { textAreaManager, chatManager, state } = managers;
|
||||||
|
const initialRoundCount = Array.isArray(state.chatRounds) ? state.chatRounds.length : 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (typeof textAreaManager.reset === "function") {
|
||||||
|
textAreaManager.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
const widgetContainer = isObjectRecord(textAreaManager.inputWidgetContainer) ? textAreaManager.inputWidgetContainer : null;
|
||||||
|
if (deepThinkEnabled && typeof widgetContainer?.activeWidget === "function") {
|
||||||
|
await widgetContainer.activeWidget("deepThink");
|
||||||
|
} else if (typeof widgetContainer?.inactiveAllWidget === "function") {
|
||||||
|
await widgetContainer.inactiveAllWidget();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof textAreaManager.setText === "function") {
|
||||||
|
textAreaManager.setText(questionText);
|
||||||
|
}
|
||||||
|
if (normalizeInlineText(textAreaManager.text) !== questionText && typeof textAreaManager.setTextImmediately === "function") {
|
||||||
|
textAreaManager.setTextImmediately(questionText);
|
||||||
|
}
|
||||||
|
|
||||||
|
await wait(50);
|
||||||
|
|
||||||
|
if (textAreaManager.isSubmitDisabled) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: "qwen_submit_disabled",
|
||||||
|
detail: normalizeInlineText(textAreaManager.text) ?? buildPageDetail(),
|
||||||
|
url: window.location.href || null,
|
||||||
|
question: null,
|
||||||
|
answer: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof chatManager.textAreaSubmit !== "function") {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: "qwen_submit_method_missing",
|
||||||
|
detail: buildPageDetail(),
|
||||||
|
url: window.location.href || null,
|
||||||
|
question: null,
|
||||||
|
answer: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await chatManager.textAreaSubmit();
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: "qwen_submit_failed",
|
||||||
|
detail: error instanceof Error ? error.message : String(error ?? ""),
|
||||||
|
url: window.location.href || null,
|
||||||
|
question: null,
|
||||||
|
answer: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const deadline = Date.now() + queryTimeoutMs;
|
||||||
|
while (Date.now() <= deadline) {
|
||||||
|
const snapshot = getLatestSnapshot(initialRoundCount);
|
||||||
|
const answerStatus = normalizeInlineText(snapshot.answer?.status);
|
||||||
|
if (answerStatus && ["finish", "failed", "interrupted"].includes(answerStatus)) {
|
||||||
|
if (answerStatus === "finish") {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
url: window.location.href,
|
||||||
|
question: snapshot.question,
|
||||||
|
answer: snapshot.answer,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: "qwen_query_finished_without_success",
|
||||||
|
detail: answerStatus,
|
||||||
|
url: window.location.href,
|
||||||
|
question: snapshot.question,
|
||||||
|
answer: snapshot.answer,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await wait(500);
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeoutSnapshot = getLatestSnapshot(initialRoundCount);
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: "qwen_query_timeout",
|
||||||
|
detail: buildPageDetail(),
|
||||||
|
url: window.location.href || null,
|
||||||
|
question: timeoutSnapshot.question,
|
||||||
|
answer: timeoutSnapshot.answer,
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const qwenAdapter: MonitorAdapter = {
|
||||||
|
provider: "qwen",
|
||||||
|
executionMode: "playwright",
|
||||||
|
async query(context, payload) {
|
||||||
|
if (!context.playwright?.page) {
|
||||||
|
return {
|
||||||
|
status: "failed",
|
||||||
|
summary: "千问监测缺少 Playwright 页面上下文。",
|
||||||
|
error: buildAdapterError("qwen_playwright_required", "qwen_playwright_required"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const questionText = extractQuestionText(payload);
|
||||||
|
const page = context.playwright.page;
|
||||||
|
|
||||||
|
context.reportProgress("qwen.page_ready");
|
||||||
|
await page.waitForLoadState("domcontentloaded", {
|
||||||
|
timeout: QWEN_PAGE_READY_TIMEOUT_MS,
|
||||||
|
}).catch(() => undefined);
|
||||||
|
|
||||||
|
if (!page.url() || page.url() === "about:blank") {
|
||||||
|
await page.goto(QWEN_BOOTSTRAP_URL, {
|
||||||
|
waitUntil: "domcontentloaded",
|
||||||
|
timeout: QWEN_PAGE_READY_TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
context.reportProgress("qwen.query");
|
||||||
|
const pageResult = await page.evaluate(qwenQueryInPage, {
|
||||||
|
questionText,
|
||||||
|
timeoutMs: QWEN_QUERY_TIMEOUT_MS,
|
||||||
|
readyTimeoutMs: QWEN_PAGE_READY_TIMEOUT_MS,
|
||||||
|
deepThink: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!pageResult?.ok || !pageResult.answer) {
|
||||||
|
const message = formatQwenQueryError(pageResult);
|
||||||
|
return {
|
||||||
|
status: "failed",
|
||||||
|
summary: `千问请求失败:${message}`,
|
||||||
|
error: buildAdapterError(
|
||||||
|
"qwen_query_failed",
|
||||||
|
message,
|
||||||
|
{
|
||||||
|
page_url: normalizeText(pageResult?.url) ?? page.url(),
|
||||||
|
reason_code: pageResult?.ok ? "unknown" : pageResult.error,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const answerText =
|
||||||
|
readNestedString(pageResult.answer.content, ["multiLoadIframe", "content"])
|
||||||
|
?? readNestedString(pageResult.answer.content, ["barIframe", "content"])
|
||||||
|
?? null;
|
||||||
|
const requestId =
|
||||||
|
normalizeText(pageResult.answer.reqId)
|
||||||
|
?? readNestedString(pageResult.answer.communication, ["reqid"])
|
||||||
|
?? null;
|
||||||
|
const sessionId = readNestedString(pageResult.answer.communication, ["sessionid"]);
|
||||||
|
const providerModel = resolveQwenProviderModel(pageResult);
|
||||||
|
|
||||||
|
const searchResults: MonitoringSourceItem[] = [];
|
||||||
|
collectQwenSources(pageResult.answer.content, searchResults);
|
||||||
|
const dedupedSearchResults = dedupeSourceItems(searchResults);
|
||||||
|
|
||||||
|
if (!answerText && !dedupedSearchResults.length) {
|
||||||
|
return {
|
||||||
|
status: "unknown",
|
||||||
|
summary: "千问返回为空,已回写 unknown 等待后续对账。",
|
||||||
|
error: buildAdapterError("qwen_empty_response", "qwen returned no answer or sources"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: "succeeded",
|
||||||
|
summary: "千问监控任务执行成功。",
|
||||||
|
payload: {
|
||||||
|
platform: "qwen",
|
||||||
|
provider_model: providerModel,
|
||||||
|
provider_request_id: requestId,
|
||||||
|
request_id: requestId ?? sessionId,
|
||||||
|
session_id: sessionId,
|
||||||
|
answer: answerText,
|
||||||
|
source_count: dedupedSearchResults.length,
|
||||||
|
search_results: toJsonSources(dedupedSearchResults),
|
||||||
|
citations: [],
|
||||||
|
raw_response_json: {
|
||||||
|
platform: "qwen",
|
||||||
|
mode: pageResult.question?.deepSearch === "1" ? "deep_think" : "standard",
|
||||||
|
page_url: pageResult.url,
|
||||||
|
session_id: sessionId,
|
||||||
|
provider_request_id: requestId,
|
||||||
|
provider_model: providerModel,
|
||||||
|
answer: answerText,
|
||||||
|
question: toJsonValue(pageResult.question),
|
||||||
|
source_count: dedupedSearchResults.length,
|
||||||
|
search_results: toJsonSources(dedupedSearchResults),
|
||||||
|
content: toJsonValue(pageResult.answer.content),
|
||||||
|
extra_info: toJsonValue(pageResult.answer.extraInfo),
|
||||||
|
communication: toJsonValue(pageResult.answer.communication),
|
||||||
|
status: "succeeded",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -15,6 +15,7 @@ import { getAIPlatformCatalogItem, isAIPlatformId } from "@geo/shared-types";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
doubaoAdapter,
|
doubaoAdapter,
|
||||||
|
qwenAdapter,
|
||||||
toutiaoAdapter,
|
toutiaoAdapter,
|
||||||
zhihuAdapter,
|
zhihuAdapter,
|
||||||
type AdapterExecutionResult,
|
type AdapterExecutionResult,
|
||||||
@@ -104,6 +105,7 @@ const pullIntervalMs = 60_000;
|
|||||||
const leaseExtendIntervalMs = 60_000;
|
const leaseExtendIntervalMs = 60_000;
|
||||||
const maxActivityItems = 60;
|
const maxActivityItems = 60;
|
||||||
const monitorBusinessTimeZone = "Asia/Shanghai";
|
const monitorBusinessTimeZone = "Asia/Shanghai";
|
||||||
|
const authRequiredMonitorPlatforms = new Set(["yuanbao", "kimi", "deepseek"]);
|
||||||
|
|
||||||
interface RuntimeTaskRecord {
|
interface RuntimeTaskRecord {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -1131,7 +1133,7 @@ async function executeTaskAdapter(
|
|||||||
signal: AbortSignal,
|
signal: AbortSignal,
|
||||||
): Promise<AdapterExecutionResult> {
|
): Promise<AdapterExecutionResult> {
|
||||||
const accountIdentity = accountIdentityFromTask(task);
|
const accountIdentity = accountIdentityFromTask(task);
|
||||||
if (accountIdentity) {
|
if (accountIdentity && shouldEnforceActiveAccount(task)) {
|
||||||
const readiness = await ensureAccountReady(accountIdentity);
|
const readiness = await ensureAccountReady(accountIdentity);
|
||||||
if (readiness.authState !== "active") {
|
if (readiness.authState !== "active") {
|
||||||
return buildAccountBlockedResult(task, readiness);
|
return buildAccountBlockedResult(task, readiness);
|
||||||
@@ -1261,6 +1263,18 @@ async function executeTaskAdapter(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shouldEnforceActiveAccount(task: RuntimeTaskRecord): boolean {
|
||||||
|
if (task.kind === "publish") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAIPlatformId(task.platform)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return authRequiredMonitorPlatforms.has(task.platform);
|
||||||
|
}
|
||||||
|
|
||||||
function buildAccountBlockedResult(
|
function buildAccountBlockedResult(
|
||||||
task: RuntimeTaskRecord,
|
task: RuntimeTaskRecord,
|
||||||
readiness: Awaited<ReturnType<typeof ensureAccountReady>>,
|
readiness: Awaited<ReturnType<typeof ensureAccountReady>>,
|
||||||
@@ -1645,6 +1659,9 @@ function selectMonitorAdapter(platform: string): MonitorAdapter | null {
|
|||||||
if (platform === doubaoAdapter.provider) {
|
if (platform === doubaoAdapter.provider) {
|
||||||
return doubaoAdapter;
|
return doubaoAdapter;
|
||||||
}
|
}
|
||||||
|
if (platform === qwenAdapter.provider) {
|
||||||
|
return qwenAdapter;
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+13
@@ -79,6 +79,14 @@
|
|||||||
- Electron process metrics are sufficient to drive a two-tier adaptive concurrency policy: default to `1`, raise to `2` only when the machine looks healthy enough and multiple live AI accounts exist.
|
- Electron process metrics are sufficient to drive a two-tier adaptive concurrency policy: default to `1`, raise to `2` only when the machine looks healthy enough and multiple live AI accounts exist.
|
||||||
- Desktop task SSE events now carry optional `business_date`, `scheduler_group_key`, `question_text`, and `title` metadata derived from the task payload, allowing the local scheduler to defer same-question fan-out before leasing.
|
- Desktop task SSE events now carry optional `business_date`, `scheduler_group_key`, `question_text`, and `title` metadata derived from the task payload, allowing the local scheduler to defer same-question fan-out before leasing.
|
||||||
- A new hidden Playwright manager now connects to Electron Chromium through a local CDP endpoint and leases hidden pages bound to the existing account session partition, while leaving the legacy hidden-view path available for current adapters.
|
- A new hidden Playwright manager now connects to Electron Chromium through a local CDP endpoint and leases hidden pages bound to the existing account session partition, while leaving the legacy hidden-view path available for current adapters.
|
||||||
|
- The browser-extension `qwen` adapter does not rely on stable DOM selectors; it drives Tongyi's internal text/chat managers from `__TONGYI_PORTSL_GLOBAL_CONTAINER__` and reads results from `__qianwenChatAPI`, which makes Playwright page evaluation a better desktop port than raw HTTP.
|
||||||
|
- Qwen binding was previously too strict for desktop because the generic AI bind flow waited for obvious post-login UI signals like avatar/name; in practice Qwen can already have a durable session footprint before those signals render, so the bind window may fail to auto-close.
|
||||||
|
- Anonymous `curl` inspection of `https://www.qianwen.com/` currently returns an `XSRF-TOKEN` cookie even before login, so CSRF-only cookies should not count as authenticated AI session evidence.
|
||||||
|
- On this machine's real `pending-qwen-*` partition, a logged-in Qwen session persists cookies including `tongyi_sso_ticket`, `tongyi_sso_ticket_hash`, and `b-user-id`; these are materially stronger auth signals than generic DOM nickname/avatar detection.
|
||||||
|
- The user clarified that only `yuanbao`, `kimi`, and `deepseek` require login for monitor execution; other AI platforms may still ask questions and collect responses anonymously, so preflight auth gating must not block those monitor tasks.
|
||||||
|
- The `admin-web` tracking page still used the removed browser-plugin monitoring bridge for `立即采集`, even though the current monitoring architecture is desktop-client driven and runs silently in the background.
|
||||||
|
- The user clarified that `立即采集` must only be available when the current logged-in user's own desktop client is online; another user's online client in the same workspace must not make the action available.
|
||||||
|
- `MonitoringService.CollectNow` previously fell back to the most recently online workspace client regardless of `user_id`, so the backend rule did not match the desired UI rule until this turn.
|
||||||
|
|
||||||
## Technical Decisions
|
## Technical Decisions
|
||||||
| Decision | Rationale |
|
| Decision | Rationale |
|
||||||
@@ -98,6 +106,11 @@
|
|||||||
| Add the local scheduler before wiring more monitor adapters | This aligns with the user's requirement that the client, not the server, decides actual execution timing |
|
| Add the local scheduler before wiring more monitor adapters | This aligns with the user's requirement that the client, not the server, decides actual execution timing |
|
||||||
| Keep publish-task execution behavior intact while evolving monitor-task scheduling | Publish is already working and should not be destabilized by monitor-specific policy changes |
|
| Keep publish-task execution behavior intact while evolving monitor-task scheduling | Publish is already working and should not be destabilized by monitor-specific policy changes |
|
||||||
| Build the hidden Playwright layer as reusable infrastructure instead of burying it inside one adapter | Qwen is the immediate motivation, but the same layer will likely be needed by other complex AI platforms |
|
| Build the hidden Playwright layer as reusable infrastructure instead of burying it inside one adapter | Qwen is the immediate motivation, but the same layer will likely be needed by other complex AI platforms |
|
||||||
|
| Port Qwen by evaluating in-page managers/state instead of reverse-engineering private APIs | The existing extension path already proved this is the stable way to drive Qwen's encrypted web client |
|
||||||
|
| Exclude CSRF-only cookie names from generic AI auth fingerprinting | Qwen sets `XSRF-TOKEN` on anonymous visits, which would otherwise create false-positive auth evidence |
|
||||||
|
| Only enforce active auth preflight for monitor tasks on `yuanbao`, `kimi`, and `deepseek` | The user explicitly allowed anonymous execution for the other AI monitoring platforms |
|
||||||
|
| Gate tracking `collect-now` on the current actor's online desktop client, not any workspace client | The user explicitly wants the action tied to the current logged-in account's client presence |
|
||||||
|
| Expose current-user desktop-client availability through the monitoring dashboard response | `TrackingView` already loads the dashboard, so returning the runtime bit there avoids a second frontend probe and keeps button gating aligned with backend validation |
|
||||||
|
|
||||||
## Issues Encountered
|
## Issues Encountered
|
||||||
| Issue | Resolution |
|
| Issue | Resolution |
|
||||||
|
|||||||
@@ -1115,8 +1115,13 @@ export interface MonitoringCitedArticle {
|
|||||||
citation_rate: number | null;
|
citation_rate: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MonitoringDashboardRuntime {
|
||||||
|
current_user_client_online: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface MonitoringDashboardCompositeResponse {
|
export interface MonitoringDashboardCompositeResponse {
|
||||||
overview: MonitoringOverview;
|
overview: MonitoringOverview;
|
||||||
|
runtime: MonitoringDashboardRuntime;
|
||||||
platform_breakdown: MonitoringPlatformBreakdownItem[];
|
platform_breakdown: MonitoringPlatformBreakdownItem[];
|
||||||
brand_time_buckets: MonitoringTimeBucket[];
|
brand_time_buckets: MonitoringTimeBucket[];
|
||||||
hot_questions: MonitoringHotQuestion[];
|
hot_questions: MonitoringHotQuestion[];
|
||||||
|
|||||||
+49
@@ -2,6 +2,26 @@
|
|||||||
|
|
||||||
## Session: 2026-04-20
|
## Session: 2026-04-20
|
||||||
|
|
||||||
|
### Phase 20: Tracking Collect-Now Repair
|
||||||
|
- **Status:** complete
|
||||||
|
- Actions taken:
|
||||||
|
- Re-read the `admin-web` tracking page, monitoring API client, and tenant monitoring handler/service chain to isolate why `立即采集` still depended on the removed browser-plugin runtime.
|
||||||
|
- Confirmed the frontend was still blocking on `monitoring-plugin` readiness and trying to kickoff a plugin cycle even though monitoring collection is now meant to run silently through `desktop-client`.
|
||||||
|
- Added monitoring dashboard runtime state so the page now knows whether the current logged-in user's own desktop client is online.
|
||||||
|
- Tightened `collect-now` server validation to only accept requests when the current actor has an online desktop client, instead of falling back to another online client in the same workspace.
|
||||||
|
- Updated `TrackingView.vue` to remove the plugin kickoff path, call only the backend `collect-now` endpoint, and disable the CTA with an explicit reason tooltip when brand/keyword/client-online requirements are not met.
|
||||||
|
- Added the missing `desktop_client_offline` frontend error mapping plus new i18n strings for the button gating reasons.
|
||||||
|
- Files created/modified:
|
||||||
|
- `task_plan.md` (updated)
|
||||||
|
- `findings.md` (updated)
|
||||||
|
- `progress.md` (updated)
|
||||||
|
- `apps/admin-web/src/views/TrackingView.vue` (updated)
|
||||||
|
- `apps/admin-web/src/i18n/messages/zh-CN.ts` (updated)
|
||||||
|
- `apps/admin-web/src/i18n/messages/en-US.ts` (updated)
|
||||||
|
- `apps/admin-web/src/lib/errors.ts` (updated)
|
||||||
|
- `packages/shared-types/src/index.ts` (updated)
|
||||||
|
- `server/internal/tenant/app/monitoring_service.go` (updated)
|
||||||
|
|
||||||
### Phase 15: Desktop Scheduler Design & Integration
|
### Phase 15: Desktop Scheduler Design & Integration
|
||||||
- **Status:** complete
|
- **Status:** complete
|
||||||
- Actions taken:
|
- Actions taken:
|
||||||
@@ -49,6 +69,35 @@
|
|||||||
- `server/internal/tenant/app/desktop_task_service.go` (updated)
|
- `server/internal/tenant/app/desktop_task_service.go` (updated)
|
||||||
- `server/internal/tenant/app/publish_job_service.go` (updated)
|
- `server/internal/tenant/app/publish_job_service.go` (updated)
|
||||||
|
|
||||||
|
### Phase 18: Qwen Adapter & Auth Relaxation
|
||||||
|
- **Status:** complete
|
||||||
|
- Actions taken:
|
||||||
|
- Ported the browser-extension `qwen` monitoring flow into a new desktop Playwright adapter that drives Tongyi's in-page managers via `page.evaluate()` and extracts answer/source data from `__qianwenChatAPI`.
|
||||||
|
- Registered the new `qwen` monitor adapter in the desktop runtime so monitor tasks can execute through the hidden Playwright CDP layer.
|
||||||
|
- Tightened generic AI auth fingerprinting to ignore CSRF-only cookies like `XSRF-TOKEN`, which Qwen sets even for anonymous visits.
|
||||||
|
- Added a Qwen-specific bind detector so the auth window can auto-close once a real session footprint is present, even if the page has not yet rendered an obvious avatar/name signal.
|
||||||
|
- Updated monitor preflight policy so only `yuanbao`, `kimi`, and `deepseek` require active login before execution; `qwen`, `doubao`, and `wenxin` are allowed to run anonymously when needed.
|
||||||
|
- Files created/modified:
|
||||||
|
- `task_plan.md` (updated)
|
||||||
|
- `findings.md` (updated)
|
||||||
|
- `progress.md` (updated)
|
||||||
|
- `apps/desktop-client/src/main/adapters/qwen.ts` (created)
|
||||||
|
- `apps/desktop-client/src/main/adapters/index.ts` (updated)
|
||||||
|
- `apps/desktop-client/src/main/runtime-controller.ts` (updated)
|
||||||
|
- `apps/desktop-client/src/main/account-binder.ts` (updated)
|
||||||
|
|
||||||
|
### Phase 19: Qwen Verification & Delivery
|
||||||
|
- **Status:** complete
|
||||||
|
- Actions taken:
|
||||||
|
- Verified `pnpm --filter @geo/desktop-client typecheck` passes after the Qwen adapter, bind-flow, and auth-policy updates.
|
||||||
|
- Verified `pnpm --filter @geo/desktop-client build` passes after the same changes.
|
||||||
|
- Followed up on a real Qwen bind stall reported by the user, inspected the machine's persisted `pending-qwen-*` partition, and confirmed logged-in cookies like `tongyi_sso_ticket`, `tongyi_sso_ticket_hash`, and `b-user-id` are present.
|
||||||
|
- Reworked Qwen account detection so bind/probe can succeed from those persisted auth cookies even when the page nickname/avatar heuristics do not trigger.
|
||||||
|
- Files created/modified:
|
||||||
|
- `task_plan.md` (updated)
|
||||||
|
- `findings.md` (updated)
|
||||||
|
- `progress.md` (updated)
|
||||||
|
|
||||||
## Session: 2026-04-04
|
## Session: 2026-04-04
|
||||||
|
|
||||||
### Phase 13: Prompt Centralization
|
### Phase 13: Prompt Centralization
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ func NewMonitoringService(businessPool, monitoringPool *pgxpool.Pool) *Monitorin
|
|||||||
|
|
||||||
type MonitoringDashboardCompositeResponse struct {
|
type MonitoringDashboardCompositeResponse struct {
|
||||||
Overview MonitoringOverview `json:"overview"`
|
Overview MonitoringOverview `json:"overview"`
|
||||||
|
Runtime MonitoringDashboardRuntime `json:"runtime"`
|
||||||
PlatformBreakdown []MonitoringPlatformDaily `json:"platform_breakdown"`
|
PlatformBreakdown []MonitoringPlatformDaily `json:"platform_breakdown"`
|
||||||
BrandTimeBuckets []MonitoringTimeBucket `json:"brand_time_buckets"`
|
BrandTimeBuckets []MonitoringTimeBucket `json:"brand_time_buckets"`
|
||||||
HotQuestions []MonitoringHotQuestion `json:"hot_questions"`
|
HotQuestions []MonitoringHotQuestion `json:"hot_questions"`
|
||||||
@@ -47,6 +48,10 @@ type MonitoringDashboardCompositeResponse struct {
|
|||||||
CitedArticles []MonitoringCitedArticle `json:"cited_articles"`
|
CitedArticles []MonitoringCitedArticle `json:"cited_articles"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MonitoringDashboardRuntime struct {
|
||||||
|
CurrentUserClientOnline bool `json:"current_user_client_online"`
|
||||||
|
}
|
||||||
|
|
||||||
type MonitoringOverview struct {
|
type MonitoringOverview struct {
|
||||||
CollectionMode string `json:"collection_mode"`
|
CollectionMode string `json:"collection_mode"`
|
||||||
MentionRate *float64 `json:"mention_rate"`
|
MentionRate *float64 `json:"mention_rate"`
|
||||||
@@ -285,6 +290,11 @@ func (s *MonitoringService) DashboardComposite(
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
runtime, err := s.loadDashboardRuntime(ctx, actor)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
configuredQuestions, err := s.loadConfiguredQuestions(ctx, actor.TenantID, brand.ID, keywordID)
|
configuredQuestions, err := s.loadConfiguredQuestions(ctx, actor.TenantID, brand.ID, keywordID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -338,6 +348,7 @@ func (s *MonitoringService) DashboardComposite(
|
|||||||
|
|
||||||
return &MonitoringDashboardCompositeResponse{
|
return &MonitoringDashboardCompositeResponse{
|
||||||
Overview: overview,
|
Overview: overview,
|
||||||
|
Runtime: runtime,
|
||||||
PlatformBreakdown: platformBreakdown,
|
PlatformBreakdown: platformBreakdown,
|
||||||
BrandTimeBuckets: timeBuckets,
|
BrandTimeBuckets: timeBuckets,
|
||||||
HotQuestions: hotQuestions,
|
HotQuestions: hotQuestions,
|
||||||
@@ -346,6 +357,17 @@ func (s *MonitoringService) DashboardComposite(
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *MonitoringService) loadDashboardRuntime(ctx context.Context, actor auth.Actor) (MonitoringDashboardRuntime, error) {
|
||||||
|
online, err := s.hasOnlineClientForUser(ctx, actor.TenantID, actor.PrimaryWorkspaceID, actor.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return MonitoringDashboardRuntime{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return MonitoringDashboardRuntime{
|
||||||
|
CurrentUserClientOnline: online,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *MonitoringService) QuestionDetail(ctx context.Context, brandID, questionID int64, dateFrom, dateTo string, questionHash string, aiPlatformID *string) (*MonitoringQuestionDetailResponse, error) {
|
func (s *MonitoringService) QuestionDetail(ctx context.Context, brandID, questionID int64, dateFrom, dateTo string, questionHash string, aiPlatformID *string) (*MonitoringQuestionDetailResponse, error) {
|
||||||
actor := auth.MustActor(ctx)
|
actor := auth.MustActor(ctx)
|
||||||
|
|
||||||
@@ -502,12 +524,12 @@ func (s *MonitoringService) CollectNow(ctx context.Context, brandID int64, keywo
|
|||||||
platforms := resolveMonitoringPlatforms(quota.EnabledPlatforms)
|
platforms := resolveMonitoringPlatforms(quota.EnabledPlatforms)
|
||||||
questionIDs := configuredQuestionIDs(configuredQuestions)
|
questionIDs := configuredQuestionIDs(configuredQuestions)
|
||||||
|
|
||||||
clientID, err := s.resolveCollectNowClientID(ctx, actor.TenantID, actor.PrimaryWorkspaceID, quota.PrimaryClientID)
|
clientID, err := s.resolveCollectNowClientID(ctx, actor.TenantID, actor.PrimaryWorkspaceID, actor.UserID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if clientID == nil {
|
if clientID == nil {
|
||||||
return nil, response.ErrConflict(40941, "desktop_client_offline", "primary monitoring desktop client is offline")
|
return nil, response.ErrConflict(40941, "desktop_client_offline", "current user's monitoring desktop client is offline")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.ensureClientOnline(ctx, actor.TenantID, actor.PrimaryWorkspaceID, *clientID); err != nil {
|
if err := s.ensureClientOnline(ctx, actor.TenantID, actor.PrimaryWorkspaceID, *clientID); err != nil {
|
||||||
@@ -589,47 +611,32 @@ func (s *MonitoringService) CollectNow(ctx context.Context, brandID int64, keywo
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *MonitoringService) resolveCollectNowClientID(ctx context.Context, tenantID, workspaceID int64, preferred *uuid.UUID) (*uuid.UUID, error) {
|
func (s *MonitoringService) resolveCollectNowClientID(ctx context.Context, tenantID, workspaceID, userID int64) (*uuid.UUID, error) {
|
||||||
if preferred != nil {
|
return s.findLatestOnlineClientForUser(ctx, tenantID, workspaceID, userID)
|
||||||
online, err := s.isClientOnline(ctx, tenantID, workspaceID, *preferred)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if online {
|
|
||||||
return preferred, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fallbackID, err := s.findLatestOnlineClient(ctx, tenantID, workspaceID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if fallbackID == nil {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if preferred == nil || *preferred != *fallbackID {
|
|
||||||
if err := s.persistPrimaryClientID(ctx, tenantID, workspaceID, *fallbackID); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return fallbackID, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *MonitoringService) findLatestOnlineClient(ctx context.Context, tenantID, workspaceID int64) (*uuid.UUID, error) {
|
func (s *MonitoringService) hasOnlineClientForUser(ctx context.Context, tenantID, workspaceID, userID int64) (bool, error) {
|
||||||
|
clientID, err := s.findLatestOnlineClientForUser(ctx, tenantID, workspaceID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return clientID != nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *MonitoringService) findLatestOnlineClientForUser(ctx context.Context, tenantID, workspaceID, userID int64) (*uuid.UUID, error) {
|
||||||
var clientID uuid.UUID
|
var clientID uuid.UUID
|
||||||
err := s.businessPool.QueryRow(ctx, `
|
err := s.businessPool.QueryRow(ctx, `
|
||||||
SELECT id
|
SELECT id
|
||||||
FROM desktop_clients
|
FROM desktop_clients
|
||||||
WHERE tenant_id = $1
|
WHERE tenant_id = $1
|
||||||
AND workspace_id = $2
|
AND workspace_id = $2
|
||||||
|
AND user_id = $3
|
||||||
AND revoked_at IS NULL
|
AND revoked_at IS NULL
|
||||||
AND last_seen_at IS NOT NULL
|
AND last_seen_at IS NOT NULL
|
||||||
AND last_seen_at >= NOW() - $3::interval
|
AND last_seen_at >= NOW() - $4::interval
|
||||||
ORDER BY last_seen_at DESC, created_at DESC, id DESC
|
ORDER BY last_seen_at DESC, created_at DESC, id DESC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
`, tenantID, workspaceID, formatPgInterval(monitoringOnlineDuration)).Scan(&clientID)
|
`, tenantID, workspaceID, userID, formatPgInterval(desktopClientPresenceTTL)).Scan(&clientID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == pgx.ErrNoRows {
|
if err == pgx.ErrNoRows {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
@@ -2463,7 +2470,7 @@ func (s *MonitoringService) ensureClientOnline(ctx context.Context, tenantID, wo
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if !online {
|
if !online {
|
||||||
return response.ErrConflict(40941, "desktop_client_offline", "primary monitoring desktop client is offline")
|
return response.ErrConflict(40941, "desktop_client_offline", "current user's monitoring desktop client is offline")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -2485,7 +2492,7 @@ func (s *MonitoringService) isClientOnline(ctx context.Context, tenantID, worksp
|
|||||||
return false, response.ErrInternal(50041, "query_failed", "failed to inspect monitoring desktop client")
|
return false, response.ErrInternal(50041, "query_failed", "failed to inspect monitoring desktop client")
|
||||||
}
|
}
|
||||||
|
|
||||||
if !lastSeenAt.Valid || time.Since(lastSeenAt.Time) > monitoringOnlineDuration {
|
if !lastSeenAt.Valid || time.Since(lastSeenAt.Time) > desktopClientPresenceTTL {
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
return true, nil
|
return true, nil
|
||||||
|
|||||||
+22
-1
@@ -4,7 +4,7 @@
|
|||||||
Continue the desktop AI monitoring implementation by first adding a client-side local scheduler with durable queueing and adaptive execution policy, then adding a reusable hidden Playwright CDP execution layer that attaches to Electron Chromium and reuses existing account partitions.
|
Continue the desktop AI monitoring implementation by first adding a client-side local scheduler with durable queueing and adaptive execution policy, then adding a reusable hidden Playwright CDP execution layer that attaches to Electron Chromium and reuses existing account partitions.
|
||||||
|
|
||||||
## Current Phase
|
## Current Phase
|
||||||
Phase 17
|
Phase 20
|
||||||
|
|
||||||
## Phases
|
## Phases
|
||||||
### Phase 1: Progress Verification
|
### Phase 1: Progress Verification
|
||||||
@@ -114,6 +114,26 @@ Phase 17
|
|||||||
- [ ] Summarize what is production-ready versus still scaffolded
|
- [ ] Summarize what is production-ready versus still scaffolded
|
||||||
- **Status:** in_progress
|
- **Status:** in_progress
|
||||||
|
|
||||||
|
### Phase 18: Qwen Adapter & Auth Relaxation
|
||||||
|
- [x] Port the browser-extension `qwen` monitor logic into a desktop Playwright adapter
|
||||||
|
- [x] Register the new adapter in the desktop runtime monitor execution path
|
||||||
|
- [x] Relax Qwen binding completion so the auth window can close once a real session footprint is present
|
||||||
|
- [x] Allow anonymous-capable AI monitor platforms to execute without preflight auth blocking
|
||||||
|
- **Status:** complete
|
||||||
|
|
||||||
|
### Phase 19: Qwen Verification & Delivery
|
||||||
|
- [x] Run targeted desktop type/build verification after the Qwen adapter and auth updates
|
||||||
|
- [x] Update planning files with the Qwen implementation details and access-policy decision
|
||||||
|
- [x] Deliver the completed slice and remaining validation gaps
|
||||||
|
- **Status:** complete
|
||||||
|
|
||||||
|
### Phase 20: Tracking Collect-Now Repair
|
||||||
|
- [x] Audit the `admin-web` tracking collect-now flow against the current desktop monitoring architecture
|
||||||
|
- [x] Remove the obsolete browser-plugin kickoff dependency from `TrackingView`
|
||||||
|
- [x] Enforce the current logged-in user's desktop client online requirement in both frontend gating and backend collect-now validation
|
||||||
|
- [x] Run targeted frontend/backend verification for the repaired flow
|
||||||
|
- **Status:** complete
|
||||||
|
|
||||||
## Key Questions
|
## Key Questions
|
||||||
1. How should the desktop client defer and locally optimize monitor-task execution without violating one-task-per-platform serialism?
|
1. How should the desktop client defer and locally optimize monitor-task execution without violating one-task-per-platform serialism?
|
||||||
2. What is the smallest durable local cache that allows same-day resume while safely dropping stale next-day monitor tasks?
|
2. What is the smallest durable local cache that allows same-day resume while safely dropping stale next-day monitor tasks?
|
||||||
@@ -138,6 +158,7 @@ Phase 17
|
|||||||
| Move scheduling authority fully into `desktop-client` | The user explicitly wants the server to dispatch tasks while the client decides when to execute them |
|
| Move scheduling authority fully into `desktop-client` | The user explicitly wants the server to dispatch tasks while the client decides when to execute them |
|
||||||
| Allow stale cross-day monitor tasks to be dropped on the client | The user explicitly allows漏采 and does not want next-day catch-up for unfinished tasks |
|
| Allow stale cross-day monitor tasks to be dropped on the client | The user explicitly allows漏采 and does not want next-day catch-up for unfinished tasks |
|
||||||
| Implement hidden browser infrastructure before expanding more adapters | Qwen and similar platforms cannot reliably use direct API calls and need browser-native execution |
|
| Implement hidden browser infrastructure before expanding more adapters | Qwen and similar platforms cannot reliably use direct API calls and need browser-native execution |
|
||||||
|
| Only require active login for monitor tasks on `yuanbao` / `kimi` / `deepseek` | The user clarified that other AI platforms can still query and collect anonymously |
|
||||||
|
|
||||||
## Errors Encountered
|
## Errors Encountered
|
||||||
| Error | Attempt | Resolution |
|
| Error | Attempt | Resolution |
|
||||||
|
|||||||
Reference in New Issue
Block a user