Files
geo/apps/desktop-client/src/main/adapters/qwen.ts
T
root 7abac1e9c4 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.
2026-04-20 19:10:35 +08:00

672 lines
19 KiB
TypeScript

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",
},
},
};
},
};