feat(monitoring): dispatch monitoring tasks to desktop via AMQP outbox
Replace SSE /desktop/events with priority AMQP dispatch for monitoring
runs, and add phase1/phase2 infrastructure so desktop workers can lease,
resume, report, skip, and cancel monitoring tasks over the existing
dispatch WebSocket.
- Add monitoring collect outbox worker and phase2 desktop task fields
- Add /desktop/monitoring/tasks/{lease,resume,result,skip,cancel} routes
- Introduce execution-devtools and network-observer on desktop runtime
- Refactor runtime-controller, LoginView, and doubao adapter for the new flow
- Add channelName column to tracking views
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,16 @@
|
||||
import type { JsonValue, MonitoringSourceItem } from "@geo/shared-types";
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { ensureViewLoaded, normalizeText } from "./common";
|
||||
import type { JsonValue, MonitoringSourceItem } from "@geo/shared-types";
|
||||
import type { Session } from "electron/main";
|
||||
import type { Page as PlaywrightPage } from "playwright-core";
|
||||
|
||||
import { normalizeText } from "./common";
|
||||
import type { MonitorAdapter } from "./base";
|
||||
|
||||
const DOUBAO_APP_ID = "497858";
|
||||
const DOUBAO_BOT_ID = "7338286299411103781";
|
||||
const DOUBAO_HOME_URL = "https://www.doubao.com/";
|
||||
const DOUBAO_CHAT_URL = "https://www.doubao.com/chat/completion";
|
||||
const DOUBAO_REFERER = "https://www.doubao.com/chat/";
|
||||
|
||||
@@ -63,6 +69,7 @@ type DoubaoStreamEvent = {
|
||||
|
||||
type DoubaoStreamSummary = {
|
||||
answer: string | null;
|
||||
reasoning: string | null;
|
||||
provider_request_id: string | null;
|
||||
request_id: string | null;
|
||||
conversation_id: string | null;
|
||||
@@ -72,6 +79,12 @@ type DoubaoStreamSummary = {
|
||||
error_message: string | null;
|
||||
};
|
||||
|
||||
const ANSWER_EVENT_WHITELIST = new Set([
|
||||
"STREAM_CHUNK",
|
||||
"STREAM_MSG_NOTIFY",
|
||||
"FULL_MSG_NOTIFY",
|
||||
]);
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
@@ -326,13 +339,30 @@ function collectSources(
|
||||
}
|
||||
}
|
||||
|
||||
function collectAnswerFragments(payload: unknown, result: string[] = []): string[] {
|
||||
type AnswerFragment = {
|
||||
text: string;
|
||||
kind: "answer" | "reasoning";
|
||||
};
|
||||
|
||||
type AnswerWalkContext = {
|
||||
inReasoning: boolean;
|
||||
};
|
||||
|
||||
function isReasoningIcon(value: unknown): boolean {
|
||||
return typeof value === "string" && value.includes("Deep_Think");
|
||||
}
|
||||
|
||||
function collectAnswerFragments(
|
||||
payload: unknown,
|
||||
result: AnswerFragment[] = [],
|
||||
context: AnswerWalkContext = { inReasoning: false },
|
||||
): AnswerFragment[] {
|
||||
if (payload == null) {
|
||||
return result;
|
||||
}
|
||||
if (Array.isArray(payload)) {
|
||||
for (const item of payload) {
|
||||
collectAnswerFragments(item, result);
|
||||
collectAnswerFragments(item, result, context);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -340,19 +370,32 @@ function collectAnswerFragments(payload: unknown, result: string[] = []): string
|
||||
return result;
|
||||
}
|
||||
|
||||
const textBlockText = readNestedString(payload, ["text_block", "text"]);
|
||||
if (textBlockText) {
|
||||
result.push(textBlockText);
|
||||
let nextContext = context;
|
||||
if (typeof payload.block_type === "number" && payload.block_type !== 10000) {
|
||||
// Non-answer blocks (search queries, status cards, etc.) — skip entirely.
|
||||
return result;
|
||||
}
|
||||
if (isReasoningIcon((payload as { icon_url?: unknown }).icon_url)) {
|
||||
nextContext = { inReasoning: true };
|
||||
}
|
||||
|
||||
const textBlock = payload.text_block;
|
||||
if (isRecord(textBlock)) {
|
||||
const text = normalizeOptionalString(textBlock.text);
|
||||
if (text) {
|
||||
const reasoning = context.inReasoning || isReasoningIcon(textBlock.icon_url);
|
||||
result.push({ text, kind: reasoning ? "reasoning" : "answer" });
|
||||
}
|
||||
}
|
||||
|
||||
const deltaText = normalizeOptionalString(payload.delta);
|
||||
if (deltaText) {
|
||||
result.push(deltaText);
|
||||
result.push({ text: deltaText, kind: nextContext.inReasoning ? "reasoning" : "answer" });
|
||||
}
|
||||
|
||||
const nestedDeltaText = readNestedString(payload, ["delta", "text"]);
|
||||
if (nestedDeltaText) {
|
||||
result.push(nestedDeltaText);
|
||||
result.push({ text: nestedDeltaText, kind: nextContext.inReasoning ? "reasoning" : "answer" });
|
||||
}
|
||||
|
||||
const assistantText =
|
||||
@@ -360,12 +403,13 @@ function collectAnswerFragments(payload: unknown, result: string[] = []): string
|
||||
normalizeOptionalString(payload.message_type) === "assistant") &&
|
||||
normalizeOptionalString(payload.text);
|
||||
if (assistantText) {
|
||||
result.push(assistantText);
|
||||
result.push({ text: assistantText, kind: nextContext.inReasoning ? "reasoning" : "answer" });
|
||||
}
|
||||
|
||||
for (const value of Object.values(payload)) {
|
||||
for (const [key, value] of Object.entries(payload)) {
|
||||
if (key === "text_block") continue;
|
||||
if (Array.isArray(value) || isRecord(value)) {
|
||||
collectAnswerFragments(value, result);
|
||||
collectAnswerFragments(value, result, nextContext);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -423,6 +467,7 @@ function formatDoubaoStreamError(payload: unknown): string {
|
||||
async function parseDoubaoStream(response: Response): Promise<DoubaoStreamSummary> {
|
||||
const summary: DoubaoStreamSummary = {
|
||||
answer: null,
|
||||
reasoning: null,
|
||||
provider_request_id: null,
|
||||
request_id: null,
|
||||
conversation_id: null,
|
||||
@@ -466,9 +511,15 @@ async function parseDoubaoStream(response: Response): Promise<DoubaoStreamSummar
|
||||
summary.request_id = findFirstStringByKey(parsed.payload, ["message_id", "local_message_id", "reply_id"]);
|
||||
}
|
||||
|
||||
const fragments = collectAnswerFragments(parsed.payload);
|
||||
for (const fragment of fragments) {
|
||||
summary.answer = mergeAnswerText(summary.answer, fragment);
|
||||
if (ANSWER_EVENT_WHITELIST.has(parsed.event.toUpperCase())) {
|
||||
const fragments = collectAnswerFragments(parsed.payload);
|
||||
for (const fragment of fragments) {
|
||||
if (fragment.kind === "answer") {
|
||||
summary.answer = mergeAnswerText(summary.answer, fragment.text);
|
||||
} else {
|
||||
summary.reasoning = mergeAnswerText(summary.reasoning, fragment.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
collectSources(parsed.payload, summary.search_results, summary.citations);
|
||||
@@ -573,101 +624,496 @@ function buildDoubaoRequestBody(questionText: string, runtimeState: DoubaoRuntim
|
||||
};
|
||||
}
|
||||
|
||||
function parseRuntimeState(value: unknown): DoubaoRuntimeState | null {
|
||||
const DOUBAO_RUNTIME_STATE_FIELDS: Array<keyof DoubaoRuntimeState> = [
|
||||
"fp",
|
||||
"ms_token",
|
||||
"device_id",
|
||||
"web_id",
|
||||
"tea_uuid",
|
||||
];
|
||||
const DOUBAO_RUNTIME_STATE_TIMEOUT_MS = 15_000;
|
||||
const DOUBAO_RUNTIME_STATE_POLL_INTERVAL_MS = 500;
|
||||
const DOUBAO_RUNTIME_STATE_DISK_SCAN_FILE_LIMIT = 8;
|
||||
const DOUBAO_RUNTIME_STATE_DISK_SCAN_MAX_BYTES = 512_000;
|
||||
const DOUBAO_XMST_PATTERN = /xmst[\x00-\x20\x7f-\xff]{0,8}([A-Za-z0-9_-]{20,}={0,2})/g;
|
||||
const DOUBAO_TEA_TOKENS_PATTERN = /__tea_cache_tokens_497858(?:u)?[\x00-\x20\x7f-\xff]{0,8}(\{[^{}]{0,500}\})/g;
|
||||
const DOUBAO_SAMANTHA_WEB_ID_PATTERN = /samantha_web_web_id[\x00-\x20\x7f-\xff]{0,8}(\{[^{}]{0,500}\})/g;
|
||||
const DOUBAO_SLARDAR_FLOW_PATTERN = /SLARDARflow_web[\x00-\x20\x7f-\xff]{0,16}(JTdC[A-Za-z0-9+/=_-]{40,}JTdE)/g;
|
||||
|
||||
function parseRuntimeState(value: unknown): {
|
||||
state: DoubaoRuntimeState | null;
|
||||
missing: Array<keyof DoubaoRuntimeState>;
|
||||
readError: string | null;
|
||||
} {
|
||||
if (!isRecord(value)) {
|
||||
return null;
|
||||
return { state: null, missing: [...DOUBAO_RUNTIME_STATE_FIELDS], readError: null };
|
||||
}
|
||||
|
||||
const readError = normalizeOptionalString(value.error);
|
||||
const fp = normalizeOptionalString(value.fp);
|
||||
const msToken = normalizeOptionalString(value.ms_token);
|
||||
const deviceID = normalizeOptionalString(value.device_id);
|
||||
const webID = normalizeOptionalString(value.web_id);
|
||||
const teaUUID = normalizeOptionalString(value.tea_uuid);
|
||||
if (!fp || !msToken || !deviceID || !webID || !teaUUID) {
|
||||
return null;
|
||||
|
||||
const missing: Array<keyof DoubaoRuntimeState> = [];
|
||||
if (!fp) missing.push("fp");
|
||||
if (!msToken) missing.push("ms_token");
|
||||
if (!deviceID) missing.push("device_id");
|
||||
if (!webID) missing.push("web_id");
|
||||
if (!teaUUID) missing.push("tea_uuid");
|
||||
|
||||
if (missing.length > 0 || !fp || !msToken || !deviceID || !webID || !teaUUID) {
|
||||
return { state: null, missing, readError };
|
||||
}
|
||||
|
||||
return {
|
||||
fp,
|
||||
ms_token: msToken,
|
||||
device_id: deviceID,
|
||||
web_id: webID,
|
||||
tea_uuid: teaUUID,
|
||||
state: {
|
||||
fp,
|
||||
ms_token: msToken,
|
||||
device_id: deviceID,
|
||||
web_id: webID,
|
||||
tea_uuid: teaUUID,
|
||||
},
|
||||
missing,
|
||||
readError,
|
||||
};
|
||||
}
|
||||
|
||||
async function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
if (signal?.aborted) {
|
||||
reject(new Error("adapter_aborted"));
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
resolve();
|
||||
}, ms);
|
||||
const onAbort = () => {
|
||||
clearTimeout(timer);
|
||||
reject(new Error("adapter_aborted"));
|
||||
};
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
async function ensurePageOnDoubaoChat(
|
||||
page: PlaywrightPage,
|
||||
signal: AbortSignal,
|
||||
): Promise<void> {
|
||||
if (signal.aborted) {
|
||||
throw new Error("adapter_aborted");
|
||||
}
|
||||
const currentURL = safePlaywrightPageURL(page);
|
||||
if (!currentURL.startsWith("https://www.doubao.com/")) {
|
||||
await page.goto(DOUBAO_HOME_URL, { waitUntil: "domcontentloaded" });
|
||||
if (signal.aborted) {
|
||||
throw new Error("adapter_aborted");
|
||||
}
|
||||
}
|
||||
if (!safePlaywrightPageURL(page).startsWith("https://www.doubao.com/chat")) {
|
||||
await page.goto(DOUBAO_REFERER, { waitUntil: "domcontentloaded" });
|
||||
}
|
||||
await page.waitForLoadState("domcontentloaded");
|
||||
await page.waitForLoadState("networkidle", { timeout: 3_000 }).catch(() => undefined);
|
||||
if (signal.aborted) {
|
||||
throw new Error("adapter_aborted");
|
||||
}
|
||||
}
|
||||
|
||||
function safePlaywrightPageURL(page: PlaywrightPage): string {
|
||||
try {
|
||||
return page.url();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
const DOUBAO_READ_RUNTIME_STATE_SCRIPT = () => {
|
||||
try {
|
||||
const safeParse = (value) => {
|
||||
if (!value) return null;
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
};
|
||||
const readStorage = (name) => {
|
||||
try {
|
||||
return window.localStorage.getItem(name) || window.sessionStorage.getItem(name);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
const decodeFlowState = (value) => {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
return null;
|
||||
}
|
||||
let current = value.trim();
|
||||
try {
|
||||
current = window.atob(current);
|
||||
} catch {
|
||||
// Keep original text when it is not base64-encoded.
|
||||
}
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
try {
|
||||
const next = decodeURIComponent(current);
|
||||
if (next === current) {
|
||||
break;
|
||||
}
|
||||
current = next;
|
||||
} catch {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return safeParse(current);
|
||||
};
|
||||
const samanthaState = safeParse(window.localStorage.getItem("samantha_web_web_id"));
|
||||
const teaState = safeParse(window.localStorage.getItem("__tea_cache_tokens_497858"));
|
||||
const teaSDKState = safeParse(window.localStorage.getItem("__tea_sdk_ab_version_497858"));
|
||||
const flowState = decodeFlowState(readStorage("SLARDARflow_web"));
|
||||
const pick = (source, paths) => {
|
||||
for (const path of paths) {
|
||||
let current = source;
|
||||
let ok = true;
|
||||
for (const key of path) {
|
||||
if (!current || typeof current !== "object" || !(key in current)) {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
current = current[key];
|
||||
}
|
||||
if (ok && typeof current === "string" && current.trim()) {
|
||||
return current.trim();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const readCookie = (name) => {
|
||||
const segments = document.cookie.split(";");
|
||||
for (const segment of segments) {
|
||||
const [rawName, ...rest] = segment.trim().split("=");
|
||||
if (rawName === name) {
|
||||
const joined = rest.join("=").trim();
|
||||
return joined || null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
return {
|
||||
fp: readCookie("s_v_web_id"),
|
||||
ms_token: readStorage("xmst") || readStorage("msToken"),
|
||||
device_id:
|
||||
pick(flowState, [["deviceId"], ["device_id"], ["id"]]) ||
|
||||
pick(samanthaState, [["device_id"], ["deviceId"], ["web_id"], ["webId"], ["id"]]),
|
||||
web_id:
|
||||
pick(teaState, [["web_id"], ["webId"], ["id"]]) ||
|
||||
pick(samanthaState, [["web_id"], ["webId"], ["device_id"], ["deviceId"], ["id"]]),
|
||||
tea_uuid:
|
||||
pick(teaState, [["user_unique_id"], ["userUniqueId"], ["tea_uuid"], ["teaUuid"], ["uuid"], ["web_id"]]) ||
|
||||
pick(teaSDKState, [["uuid"], ["user_unique_id"], ["userUniqueId"]]),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
error: error instanceof Error ? error.message : "runtime_state_read_failed",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
function lastPatternMatch(text: string, pattern: RegExp): string | null {
|
||||
pattern.lastIndex = 0;
|
||||
let last: string | null = null;
|
||||
let matched: RegExpExecArray | null = null;
|
||||
while ((matched = pattern.exec(text)) !== null) {
|
||||
const candidate = normalizeOptionalString(matched[1]);
|
||||
if (candidate) {
|
||||
last = candidate;
|
||||
}
|
||||
}
|
||||
pattern.lastIndex = 0;
|
||||
return last;
|
||||
}
|
||||
|
||||
function readFileTailAsLatin1(filePath: string, maxBytes = DOUBAO_RUNTIME_STATE_DISK_SCAN_MAX_BYTES): string {
|
||||
const buffer = readFileSync(filePath);
|
||||
if (buffer.length <= maxBytes) {
|
||||
return buffer.toString("latin1");
|
||||
}
|
||||
return buffer.subarray(buffer.length - maxBytes).toString("latin1");
|
||||
}
|
||||
|
||||
function parseDoubaoRecord(value: string | null): Record<string, unknown> | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const parsed = safeParseJSON(value);
|
||||
return isRecord(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function decodeDoubaoFlowState(value: string | null): Record<string, unknown> | null {
|
||||
const raw = normalizeOptionalString(value);
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
let decoded = Buffer.from(raw, "base64").toString("utf8");
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
try {
|
||||
const next = decodeURIComponent(decoded);
|
||||
if (next === decoded) {
|
||||
break;
|
||||
}
|
||||
decoded = next;
|
||||
} catch {
|
||||
break;
|
||||
}
|
||||
}
|
||||
const parsed = safeParseJSON(decoded);
|
||||
return isRecord(parsed) ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function flushDoubaoSessionPersistence(session: Session): Promise<void> {
|
||||
const maybeFlush = (session as Session & {
|
||||
flushStorageData?: () => Promise<void> | void;
|
||||
}).flushStorageData;
|
||||
if (typeof maybeFlush === "function") {
|
||||
try {
|
||||
await Promise.resolve(maybeFlush.call(session));
|
||||
} catch {
|
||||
// Ignore persistence flush errors and continue with the best available state.
|
||||
}
|
||||
}
|
||||
try {
|
||||
await Promise.resolve(session.cookies.flushStore());
|
||||
} catch {
|
||||
// Ignore cookie flush errors and continue with the best available state.
|
||||
}
|
||||
}
|
||||
|
||||
async function readDoubaoCookieValue(
|
||||
session: Session,
|
||||
name: string,
|
||||
): Promise<string | null> {
|
||||
let cookies: Awaited<ReturnType<Session["cookies"]["get"]>> = [];
|
||||
try {
|
||||
cookies = await Promise.resolve(session.cookies.get({ url: DOUBAO_HOME_URL }));
|
||||
} catch {
|
||||
cookies = [];
|
||||
}
|
||||
for (const cookie of cookies) {
|
||||
if (cookie.name !== name) {
|
||||
continue;
|
||||
}
|
||||
const value = normalizeOptionalString(cookie.value);
|
||||
if (value) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function readDoubaoPersistedStorageState(storagePath: string | null): Partial<DoubaoRuntimeState> {
|
||||
if (!storagePath) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const leveldbPath = join(storagePath, "Local Storage", "leveldb");
|
||||
let candidates: string[] = [];
|
||||
try {
|
||||
candidates = readdirSync(leveldbPath)
|
||||
.map((entry) => join(leveldbPath, entry))
|
||||
.filter((entry) => /\.(?:log|ldb)$/i.test(entry))
|
||||
.sort((left, right) => statSync(right).mtimeMs - statSync(left).mtimeMs)
|
||||
.slice(0, DOUBAO_RUNTIME_STATE_DISK_SCAN_FILE_LIMIT);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
|
||||
let xmst: string | null = null;
|
||||
let teaState: Record<string, unknown> | null = null;
|
||||
let samanthaState: Record<string, unknown> | null = null;
|
||||
let flowState: Record<string, unknown> | null = null;
|
||||
|
||||
for (const filePath of candidates) {
|
||||
let text = "";
|
||||
try {
|
||||
text = readFileTailAsLatin1(filePath);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!text) {
|
||||
continue;
|
||||
}
|
||||
xmst ||= lastPatternMatch(text, DOUBAO_XMST_PATTERN);
|
||||
teaState ||= parseDoubaoRecord(lastPatternMatch(text, DOUBAO_TEA_TOKENS_PATTERN));
|
||||
samanthaState ||= parseDoubaoRecord(lastPatternMatch(text, DOUBAO_SAMANTHA_WEB_ID_PATTERN));
|
||||
flowState ||= decodeDoubaoFlowState(lastPatternMatch(text, DOUBAO_SLARDAR_FLOW_PATTERN));
|
||||
if (xmst && teaState && samanthaState && flowState) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const deviceID =
|
||||
getDirectString(flowState ?? {}, ["deviceId", "device_id", "id"]) ??
|
||||
getDirectString(samanthaState ?? {}, ["device_id", "deviceId", "web_id", "webId", "id"]);
|
||||
const webID =
|
||||
getDirectString(teaState ?? {}, ["web_id", "webId", "id"]) ??
|
||||
getDirectString(samanthaState ?? {}, ["web_id", "webId", "device_id", "deviceId", "id"]);
|
||||
const teaUUID =
|
||||
getDirectString(teaState ?? {}, ["user_unique_id", "userUniqueId", "tea_uuid", "teaUuid", "uuid", "web_id", "webId"]) ??
|
||||
webID;
|
||||
|
||||
return {
|
||||
ms_token: xmst ?? undefined,
|
||||
device_id: deviceID ?? undefined,
|
||||
web_id: webID ?? undefined,
|
||||
tea_uuid: teaUUID ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function mergeDoubaoRuntimeStateSources(...sources: Array<Record<string, unknown> | null | undefined>): Record<string, unknown> {
|
||||
const merged: Record<string, unknown> = {};
|
||||
for (const field of DOUBAO_RUNTIME_STATE_FIELDS) {
|
||||
for (const source of sources) {
|
||||
const value = normalizeOptionalString(source?.[field]);
|
||||
if (value) {
|
||||
merged[field] = value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const source of sources) {
|
||||
const error = normalizeOptionalString(source?.error);
|
||||
if (error) {
|
||||
merged.error = error;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
async function readDoubaoRuntimeStateFallback(session: Session): Promise<Record<string, unknown>> {
|
||||
const [fp, persisted] = await Promise.all([
|
||||
readDoubaoCookieValue(session, "s_v_web_id"),
|
||||
Promise.resolve(readDoubaoPersistedStorageState(session.storagePath)),
|
||||
]);
|
||||
return {
|
||||
...(fp ? { fp } : {}),
|
||||
...persisted,
|
||||
};
|
||||
}
|
||||
|
||||
async function loadDoubaoRuntimeState(context: Parameters<MonitorAdapter["query"]>[0]): Promise<DoubaoRuntimeState> {
|
||||
if (!context.view) {
|
||||
throw new Error("doubao_view_required");
|
||||
if (!context.playwright) {
|
||||
throw new Error("doubao_playwright_required");
|
||||
}
|
||||
const view = context.view;
|
||||
const page = context.playwright.page;
|
||||
|
||||
context.reportProgress("doubao.bootstrap_view");
|
||||
await ensureViewLoaded(view, DOUBAO_REFERER, context.signal);
|
||||
await ensurePageOnDoubaoChat(page, context.signal);
|
||||
await flushDoubaoSessionPersistence(context.session);
|
||||
|
||||
context.reportProgress("doubao.read_runtime_state");
|
||||
const state = await view.webContents.executeJavaScript(
|
||||
`(() => {
|
||||
try {
|
||||
const safeParse = (value) => {
|
||||
if (!value) return null;
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
};
|
||||
const samanthaState = safeParse(window.localStorage.getItem("samantha_web_web_id"));
|
||||
const teaState = safeParse(window.localStorage.getItem("__tea_cache_tokens_497858"));
|
||||
const pick = (source, paths) => {
|
||||
for (const path of paths) {
|
||||
let current = source;
|
||||
let ok = true;
|
||||
for (const key of path) {
|
||||
if (!current || typeof current !== "object" || !(key in current)) {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
current = current[key];
|
||||
}
|
||||
if (ok && typeof current === "string" && current.trim()) {
|
||||
return current.trim();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const readCookie = (name) => {
|
||||
const segments = document.cookie.split(";");
|
||||
for (const segment of segments) {
|
||||
const [rawName, ...rest] = segment.trim().split("=");
|
||||
if (rawName === name) {
|
||||
const joined = rest.join("=").trim();
|
||||
return joined || null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
return {
|
||||
fp: readCookie("s_v_web_id"),
|
||||
ms_token: window.localStorage.getItem("xmst"),
|
||||
device_id: pick(samanthaState, [["web_id"], ["device_id"], ["deviceId"], ["id"]]),
|
||||
web_id: pick(teaState, [["web_id"], ["webId"], ["id"]]),
|
||||
tea_uuid: pick(teaState, [["web_id"], ["tea_uuid"], ["teaUuid"], ["user_unique_id"], ["uuid"]]),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
error: error instanceof Error ? error.message : "runtime_state_read_failed",
|
||||
};
|
||||
}
|
||||
})();`,
|
||||
true,
|
||||
);
|
||||
|
||||
const runtimeState = parseRuntimeState(state);
|
||||
if (!runtimeState) {
|
||||
throw new Error("doubao_runtime_state_missing");
|
||||
const deadline = Date.now() + DOUBAO_RUNTIME_STATE_TIMEOUT_MS;
|
||||
let lastMissing: Array<keyof DoubaoRuntimeState> = [...DOUBAO_RUNTIME_STATE_FIELDS];
|
||||
let lastReadError: string | null = null;
|
||||
const persistedFallback = await readDoubaoRuntimeStateFallback(context.session).catch(() => ({}));
|
||||
|
||||
while (true) {
|
||||
if (context.signal?.aborted) {
|
||||
throw new Error("adapter_aborted");
|
||||
}
|
||||
|
||||
const raw = await page.evaluate(DOUBAO_READ_RUNTIME_STATE_SCRIPT);
|
||||
const merged = mergeDoubaoRuntimeStateSources(raw, persistedFallback);
|
||||
const { state, missing, readError } = parseRuntimeState(merged);
|
||||
if (state) {
|
||||
return state;
|
||||
}
|
||||
lastMissing = missing;
|
||||
lastReadError = readError;
|
||||
|
||||
if (Date.now() >= deadline) {
|
||||
break;
|
||||
}
|
||||
await sleep(DOUBAO_RUNTIME_STATE_POLL_INTERVAL_MS, context.signal);
|
||||
}
|
||||
return runtimeState;
|
||||
|
||||
const detail = lastReadError
|
||||
? `read_error=${lastReadError}`
|
||||
: `missing=${lastMissing.join(",") || "unknown"}`;
|
||||
throw new Error(`doubao_runtime_state_missing:${detail}`);
|
||||
}
|
||||
|
||||
const DOUBAO_COMPLETION_FETCH_SCRIPT = async (
|
||||
{ url, body, referer }: { url: string; body: Record<string, unknown>; referer: string },
|
||||
) => {
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
referrer: referer,
|
||||
headers: {
|
||||
Accept: "text/event-stream",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const text = await res.text();
|
||||
return { ok: res.ok, status: res.status, body: text };
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 0,
|
||||
body: "",
|
||||
error: error instanceof Error ? error.message : "doubao_completion_fetch_failed",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
type DoubaoCompletionResult = {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
body: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
async function fetchDoubaoCompletion(
|
||||
page: PlaywrightPage,
|
||||
url: string,
|
||||
body: Record<string, unknown>,
|
||||
): Promise<DoubaoCompletionResult> {
|
||||
let result: unknown;
|
||||
try {
|
||||
result = await page.evaluate(DOUBAO_COMPLETION_FETCH_SCRIPT, {
|
||||
url,
|
||||
body,
|
||||
referer: DOUBAO_REFERER,
|
||||
});
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 0,
|
||||
body: "",
|
||||
error: error instanceof Error ? error.message : "doubao_completion_fetch_eval_failed",
|
||||
};
|
||||
}
|
||||
|
||||
if (!isRecord(result)) {
|
||||
return { ok: false, status: 0, body: "", error: "doubao_completion_fetch_malformed" };
|
||||
}
|
||||
return {
|
||||
ok: Boolean(result.ok),
|
||||
status: typeof result.status === "number" ? result.status : 0,
|
||||
body: typeof result.body === "string" ? result.body : "",
|
||||
error: normalizeOptionalString(result.error) ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function extractQuestionText(payload: Record<string, unknown>): string {
|
||||
@@ -716,50 +1162,70 @@ function buildAdapterError(
|
||||
|
||||
export const doubaoAdapter: MonitorAdapter = {
|
||||
provider: "doubao",
|
||||
executionMode: "view",
|
||||
executionMode: "playwright",
|
||||
async query(context, payload) {
|
||||
if (!context.view) {
|
||||
if (!context.playwright) {
|
||||
return {
|
||||
status: "failed",
|
||||
summary: "豆包监测缺少页面执行上下文。",
|
||||
summary: "豆包监测缺少 Playwright 执行上下文。",
|
||||
error: buildAdapterError(
|
||||
"doubao_view_required",
|
||||
"doubao_view_required",
|
||||
"doubao_playwright_required",
|
||||
"doubao_playwright_required",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const questionText = extractQuestionText(payload);
|
||||
const runtimeState = await loadDoubaoRuntimeState(context);
|
||||
let runtimeState: DoubaoRuntimeState;
|
||||
try {
|
||||
runtimeState = await loadDoubaoRuntimeState(context);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (message.startsWith("doubao_runtime_state_missing")) {
|
||||
const detail = message.slice("doubao_runtime_state_missing:".length) || "unknown";
|
||||
return {
|
||||
status: "failed",
|
||||
summary: `豆包运行时上下文未就绪(${detail}),请稍后重试或在桌面端打开豆包页面完成初始化。`,
|
||||
error: buildAdapterError(
|
||||
"doubao_runtime_state_missing",
|
||||
`doubao_runtime_state_missing:${detail}`,
|
||||
),
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const requestBody = buildDoubaoRequestBody(questionText, runtimeState);
|
||||
|
||||
context.reportProgress("doubao.query");
|
||||
const response = await context.session.fetch(buildDoubaoRequestUrl(runtimeState), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "text/event-stream",
|
||||
"Content-Type": "application/json",
|
||||
referer: DOUBAO_REFERER,
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
const fetchResult = await fetchDoubaoCompletion(
|
||||
context.playwright.page,
|
||||
buildDoubaoRequestUrl(runtimeState),
|
||||
requestBody,
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const bodyText = await response.text().catch(() => "");
|
||||
const detail = normalizeText(bodyText)?.slice(0, 240);
|
||||
if (fetchResult.error) {
|
||||
return {
|
||||
status: "failed",
|
||||
summary: detail ? `豆包请求失败:${detail}` : `豆包请求失败(${response.status})。`,
|
||||
summary: `豆包请求发送失败:${fetchResult.error}`,
|
||||
error: buildAdapterError("doubao_request_failed", fetchResult.error),
|
||||
};
|
||||
}
|
||||
|
||||
if (!fetchResult.ok) {
|
||||
const detail = normalizeText(fetchResult.body)?.slice(0, 240);
|
||||
return {
|
||||
status: "failed",
|
||||
summary: detail ? `豆包请求失败:${detail}` : `豆包请求失败(${fetchResult.status})。`,
|
||||
error: buildAdapterError(
|
||||
"doubao_request_failed",
|
||||
detail || `doubao_request_failed_${response.status}`,
|
||||
{ http_status: response.status },
|
||||
detail || `doubao_request_failed_${fetchResult.status}`,
|
||||
{ http_status: fetchResult.status },
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
context.reportProgress("doubao.parse_stream");
|
||||
const streamSummary = await parseDoubaoStream(response);
|
||||
const streamSummary = await parseDoubaoStream(new Response(fetchResult.body));
|
||||
if (streamSummary.error_message) {
|
||||
return {
|
||||
status: "failed",
|
||||
@@ -786,6 +1252,7 @@ export const doubaoAdapter: MonitorAdapter = {
|
||||
request_id: streamSummary.request_id ?? streamSummary.conversation_id,
|
||||
conversation_id: streamSummary.conversation_id,
|
||||
answer: streamSummary.answer,
|
||||
reasoning: streamSummary.reasoning,
|
||||
citation_count: streamSummary.citations.length,
|
||||
search_result_count: streamSummary.search_results.length,
|
||||
event_count: streamSummary.event_count,
|
||||
|
||||
Reference in New Issue
Block a user