diff --git a/apps/admin-web/src/i18n/messages/en-US.ts b/apps/admin-web/src/i18n/messages/en-US.ts index d02d39b..c43cebc 100644 --- a/apps/admin-web/src/i18n/messages/en-US.ts +++ b/apps/admin-web/src/i18n/messages/en-US.ts @@ -346,7 +346,10 @@ const enUS = { brandPlaceholder: "Select brand", keywordPlaceholder: "Select keyword", collectNow: "Collect Now", + collectBrandRequired: "Select a brand before collecting.", 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", pluginHint: "Plugin-mode data depends on device uptime. Low coverage should be treated as directional only.", awaitingSnapshot: "Waiting for the next valid snapshot", diff --git a/apps/admin-web/src/i18n/messages/zh-CN.ts b/apps/admin-web/src/i18n/messages/zh-CN.ts index 69e06a1..0d0e57e 100644 --- a/apps/admin-web/src/i18n/messages/zh-CN.ts +++ b/apps/admin-web/src/i18n/messages/zh-CN.ts @@ -346,7 +346,10 @@ const zhCN = { brandPlaceholder: "选择品牌", keywordPlaceholder: "选择关键词", collectNow: "立即采集", + collectBrandRequired: "请先选择品牌,再发起采集", collectKeywordRequired: "请先选择关键词,再立即采集当前问题", + collectClientChecking: "正在检查当前账号的桌面客户端状态", + collectClientOnlineRequired: "当前登录账号的 desktop-client 未在线,暂时不能立即采集", pluginMode: "插件采样", pluginHint: "插件模式下结果受设备在线率影响,低覆盖样本仅供趋势参考。", awaitingSnapshot: "等待下一次有效快照", diff --git a/apps/admin-web/src/lib/errors.ts b/apps/admin-web/src/lib/errors.ts index 63a159f..5376ede 100644 --- a/apps/admin-web/src/lib/errors.ts +++ b/apps/admin-web/src/lib/errors.ts @@ -52,6 +52,7 @@ const errorMessageMap: Record = { publish_cover_required: "已选择百家号,请先上传封面图", desktop_account_client_missing: "所选账号还没有绑定桌面客户端,暂时无法发布", desktop_account_not_found: "所选桌面账号不存在,请刷新后重试", + desktop_client_offline: "当前登录账号的桌面客户端未在线,请先打开 desktop-client", desktop_publish_job_store_unavailable: "发布队列暂时不可用,请稍后重试", desktop_media_api_removed: "旧版媒体接口已下线,请刷新页面后重试", publisher_plugin_timeout: "浏览器插件响应超时,请刷新当前页面后重试", diff --git a/apps/admin-web/src/views/TrackingView.vue b/apps/admin-web/src/views/TrackingView.vue index 38b0949..71d9273 100644 --- a/apps/admin-web/src/views/TrackingView.vue +++ b/apps/admin-web/src/views/TrackingView.vue @@ -13,7 +13,7 @@ import type { MonitoringTimeBucket, } 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 { useRoute, useRouter } from "vue-router"; @@ -21,8 +21,6 @@ import PageHero from "@/components/PageHero.vue"; import { brandsApi, monitoringApi, tenantAccountsApi } from "@/lib/api"; import { formatDateTime } from "@/lib/display"; import { formatError } from "@/lib/errors"; -import { kickoffMonitoringCycle } from "@/lib/monitoring-plugin"; -import { loadPublisherRuntimeState } from "@/lib/publisher-runtime"; const { t } = useI18n(); const route = useRoute(); @@ -102,7 +100,6 @@ const selectedBrandId = useStorage("tracking_selected_brand", nul const selectedKeywordId = useStorage("tracking_selected_keyword", null); const selectedPlatformId = useStorage("tracking_selected_ai_platform_id", "all"); const selectedBusinessDate = useStorage("tracking_selected_business_date", trackingToday); -const pluginPreparing = ref(false); selectedPlatformId.value = normalizeTrackingPlatformId(selectedPlatformId.value); selectedBusinessDate.value = normalizeTrackingBusinessDate(selectedBusinessDate.value) ?? trackingToday; @@ -228,25 +225,6 @@ const trackingWindow = computed(() => { }; }); -async function ensureMonitoringPluginReady(options?: { silent?: boolean }): Promise { - 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({ queryKey: computed(() => [ "tracking", @@ -274,52 +252,28 @@ const aiAccountsQuery = useQuery({ const collectNowMutation = useMutation({ mutationFn: async () => { + if (!selectedBrandId.value) { + throw new Error("tracking_brand_required"); + } if (!selectedKeywordId.value) { 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, }); - - try { - const kickoffResult = await kickoffMonitoringCycle(); - return { - collectNowResult, - kickoffResult, - kickoffError: null, - }; - } catch (error) { - return { - collectNowResult, - kickoffResult: null, - kickoffError: formatError(error), - }; - } }, - onSuccess: ({ collectNowResult, kickoffResult, kickoffError }) => { - if (kickoffError) { - message.warning(`${collectNowResult.message},但后台采集启动失败:${kickoffError}`); - void dashboardQuery.refetch(); - return; - } - - if (kickoffResult?.status === "already_running") { - message.success(`${collectNowResult.message},插件后台已继续执行`); - void dashboardQuery.refetch(); - return; - } - + onSuccess: (collectNowResult) => { message.success(collectNowResult.message); void dashboardQuery.refetch(); }, onError: (error) => { - if (error instanceof Error && error.message === "tracking_keyword_required") { - message.warning(t("tracking.collectKeywordRequired")); + if (error instanceof Error && error.message === "tracking_brand_required") { + message.warning(t("tracking.collectBrandRequired")); return; } - if (error instanceof Error && (error.message === "monitoring_plugin_required" || error.message === "monitoring_plugin_register_required")) { - message.warning(formatError(error)); + if (error instanceof Error && error.message === "tracking_keyword_required") { + message.warning(t("tracking.collectKeywordRequired")); return; } message.error(formatError(error)); @@ -330,6 +284,26 @@ const selectedBrand = computed(() => { 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(() => { 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 overview = dashboardQuery.data.value?.overview; if (!overview?.last_sampled_at) { @@ -767,15 +739,19 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean { :disabled-date="disableTrackingDate" /> - - - {{ t("tracking.collectNow") }} - + + + + + {{ t("tracking.collectNow") }} + + + diff --git a/apps/desktop-client/src/main/account-binder.ts b/apps/desktop-client/src/main/account-binder.ts index 8484465..6bfada3 100644 --- a/apps/desktop-client/src/main/account-binder.ts +++ b/apps/desktop-client/src/main/account-binder.ts @@ -1009,6 +1009,10 @@ function genericAIConversationPath(pathname: 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); } @@ -1126,6 +1130,103 @@ async function detectGenericAIPlatform( }); } +async function detectQwenFromSession( + session: Session, + hints: { + displayName?: string | null; + avatarUrl?: string | null; + } = {}, +): Promise { + const cookies = await session.cookies.get({ url: "https://www.qianwen.com/" }).catch(() => []); + if (!cookies.length) { + return null; + } + + const cookieMap = new Map(); + 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 { + 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 { + if (platformId === "qwen") { + return await detectQwenPlatform(platformId, label, context); + } + + return await detectGenericAIPlatform(platformId, label, context); +} + async function detectToutiao({ session, webContents }: DetectContext): Promise { const pageResponse = await pageFetchJson( 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, webContents: window.webContents, }); @@ -2329,7 +2430,7 @@ const aiBindingDefinitions: Record = O label: platform.label, loginUrl: platform.loginUrl, consoleUrl: platform.consoleUrl, - detect: (context: DetectContext) => detectGenericAIPlatform(platform.id, platform.label, context), + detect: (context: DetectContext) => detectAIPlatformAccount(platform.id, platform.label, context), }, ]), ) as Record; diff --git a/apps/desktop-client/src/main/adapters/index.ts b/apps/desktop-client/src/main/adapters/index.ts index ad7d604..294c8f0 100644 --- a/apps/desktop-client/src/main/adapters/index.ts +++ b/apps/desktop-client/src/main/adapters/index.ts @@ -1,4 +1,5 @@ export * from "./base"; export * from "./doubao"; +export * from "./qwen"; export * from "./toutiao"; export * from "./zhihu"; diff --git a/apps/desktop-client/src/main/adapters/qwen.ts b/apps/desktop-client/src/main/adapters/qwen.ts new file mode 100644 index 0000000..0a7d07f --- /dev/null +++ b/apps/desktop-client/src/main/adapters/qwen.ts @@ -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 | null; + extraInfo: Record | null; + communication: Record | 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 { + 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(); + 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 { + 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 = {}; + 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 = {}, +): Record { + return { + code, + message, + ...extras, + }; +} + +function qwenQueryInPage(parameters: { + questionText: string; + timeoutMs: number; + readyTimeoutMs: number; + deepThink: boolean; +}): Promise { + const questionText = parameters.questionText; + const queryTimeoutMs = parameters.timeoutMs; + const readyTimeoutMs = parameters.readyTimeoutMs; + const deepThinkEnabled = parameters.deepThink; + + const wait = (timeMs: number) => + new Promise((resolve) => { + window.setTimeout(resolve, timeMs); + }); + + const isObjectRecord = (value: unknown): value is Record => + 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 | null => { + if (value == null) { + return null; + } + + try { + return JSON.parse(JSON.stringify(value)) as Record; + } 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; + }; + }; + }; + } + ).__TONGYI_PORTSL_GLOBAL_CONTAINER__?.container?._bindingDictionary?._map; + + if (!(bindingMap instanceof Map)) { + return null; + } + + let textAreaManager: Record | null = null; + let chatManager: Record | 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; + }; + }; + }; + } + ).__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>; + }; + }; + }; + }; + } + ).__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 => { + 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", + }, + }, + }; + }, +}; diff --git a/apps/desktop-client/src/main/runtime-controller.ts b/apps/desktop-client/src/main/runtime-controller.ts index ba8bf29..0987520 100644 --- a/apps/desktop-client/src/main/runtime-controller.ts +++ b/apps/desktop-client/src/main/runtime-controller.ts @@ -15,6 +15,7 @@ import { getAIPlatformCatalogItem, isAIPlatformId } from "@geo/shared-types"; import { doubaoAdapter, + qwenAdapter, toutiaoAdapter, zhihuAdapter, type AdapterExecutionResult, @@ -104,6 +105,7 @@ const pullIntervalMs = 60_000; const leaseExtendIntervalMs = 60_000; const maxActivityItems = 60; const monitorBusinessTimeZone = "Asia/Shanghai"; +const authRequiredMonitorPlatforms = new Set(["yuanbao", "kimi", "deepseek"]); interface RuntimeTaskRecord { id: string; @@ -1131,7 +1133,7 @@ async function executeTaskAdapter( signal: AbortSignal, ): Promise { const accountIdentity = accountIdentityFromTask(task); - if (accountIdentity) { + if (accountIdentity && shouldEnforceActiveAccount(task)) { const readiness = await ensureAccountReady(accountIdentity); if (readiness.authState !== "active") { 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( task: RuntimeTaskRecord, readiness: Awaited>, @@ -1645,6 +1659,9 @@ function selectMonitorAdapter(platform: string): MonitorAdapter | null { if (platform === doubaoAdapter.provider) { return doubaoAdapter; } + if (platform === qwenAdapter.provider) { + return qwenAdapter; + } return null; } diff --git a/findings.md b/findings.md index 842053b..de863aa 100644 --- a/findings.md +++ b/findings.md @@ -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. - 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. +- 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 | 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 | | 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 | +| 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 | Issue | Resolution | diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index d2627bf..39b1936 100644 --- a/packages/shared-types/src/index.ts +++ b/packages/shared-types/src/index.ts @@ -1115,8 +1115,13 @@ export interface MonitoringCitedArticle { citation_rate: number | null; } +export interface MonitoringDashboardRuntime { + current_user_client_online: boolean; +} + export interface MonitoringDashboardCompositeResponse { overview: MonitoringOverview; + runtime: MonitoringDashboardRuntime; platform_breakdown: MonitoringPlatformBreakdownItem[]; brand_time_buckets: MonitoringTimeBucket[]; hot_questions: MonitoringHotQuestion[]; diff --git a/progress.md b/progress.md index 404f588..9c913fe 100644 --- a/progress.md +++ b/progress.md @@ -2,6 +2,26 @@ ## 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 - **Status:** complete - Actions taken: @@ -49,6 +69,35 @@ - `server/internal/tenant/app/desktop_task_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 ### Phase 13: Prompt Centralization diff --git a/server/internal/tenant/app/monitoring_service.go b/server/internal/tenant/app/monitoring_service.go index 43231a0..1355a04 100644 --- a/server/internal/tenant/app/monitoring_service.go +++ b/server/internal/tenant/app/monitoring_service.go @@ -40,6 +40,7 @@ func NewMonitoringService(businessPool, monitoringPool *pgxpool.Pool) *Monitorin type MonitoringDashboardCompositeResponse struct { Overview MonitoringOverview `json:"overview"` + Runtime MonitoringDashboardRuntime `json:"runtime"` PlatformBreakdown []MonitoringPlatformDaily `json:"platform_breakdown"` BrandTimeBuckets []MonitoringTimeBucket `json:"brand_time_buckets"` HotQuestions []MonitoringHotQuestion `json:"hot_questions"` @@ -47,6 +48,10 @@ type MonitoringDashboardCompositeResponse struct { CitedArticles []MonitoringCitedArticle `json:"cited_articles"` } +type MonitoringDashboardRuntime struct { + CurrentUserClientOnline bool `json:"current_user_client_online"` +} + type MonitoringOverview struct { CollectionMode string `json:"collection_mode"` MentionRate *float64 `json:"mention_rate"` @@ -285,6 +290,11 @@ func (s *MonitoringService) DashboardComposite( 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) if err != nil { return nil, err @@ -338,6 +348,7 @@ func (s *MonitoringService) DashboardComposite( return &MonitoringDashboardCompositeResponse{ Overview: overview, + Runtime: runtime, PlatformBreakdown: platformBreakdown, BrandTimeBuckets: timeBuckets, HotQuestions: hotQuestions, @@ -346,6 +357,17 @@ func (s *MonitoringService) DashboardComposite( }, 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) { actor := auth.MustActor(ctx) @@ -502,12 +524,12 @@ func (s *MonitoringService) CollectNow(ctx context.Context, brandID int64, keywo platforms := resolveMonitoringPlatforms(quota.EnabledPlatforms) 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 { return nil, err } 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 { @@ -589,47 +611,32 @@ func (s *MonitoringService) CollectNow(ctx context.Context, brandID int64, keywo }, nil } -func (s *MonitoringService) resolveCollectNowClientID(ctx context.Context, tenantID, workspaceID int64, preferred *uuid.UUID) (*uuid.UUID, error) { - if preferred != nil { - 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) resolveCollectNowClientID(ctx context.Context, tenantID, workspaceID, userID int64) (*uuid.UUID, error) { + return s.findLatestOnlineClientForUser(ctx, tenantID, workspaceID, userID) } -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 err := s.businessPool.QueryRow(ctx, ` SELECT id FROM desktop_clients WHERE tenant_id = $1 AND workspace_id = $2 + AND user_id = $3 AND revoked_at IS 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 LIMIT 1 - `, tenantID, workspaceID, formatPgInterval(monitoringOnlineDuration)).Scan(&clientID) + `, tenantID, workspaceID, userID, formatPgInterval(desktopClientPresenceTTL)).Scan(&clientID) if err != nil { if err == pgx.ErrNoRows { return nil, nil @@ -2463,7 +2470,7 @@ func (s *MonitoringService) ensureClientOnline(ctx context.Context, tenantID, wo return err } 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 } @@ -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") } - if !lastSeenAt.Valid || time.Since(lastSeenAt.Time) > monitoringOnlineDuration { + if !lastSeenAt.Valid || time.Since(lastSeenAt.Time) > desktopClientPresenceTTL { return false, nil } return true, nil diff --git a/task_plan.md b/task_plan.md index 6eb9032..3fb9f9b 100644 --- a/task_plan.md +++ b/task_plan.md @@ -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. ## Current Phase -Phase 17 +Phase 20 ## Phases ### Phase 1: Progress Verification @@ -114,6 +114,26 @@ Phase 17 - [ ] Summarize what is production-ready versus still scaffolded - **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 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? @@ -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 | | 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 | +| 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 | Error | Attempt | Resolution |