Fix MAX sidebar chat menu actions

This commit is contained in:
sevenhill
2026-07-05 21:05:03 +03:00
parent 343a3a17f5
commit ce67dee9ed
+223 -16
View File
@@ -219,12 +219,13 @@ app.get("/inspect/indexeddb/sample", async (req, res) => {
}
});
app.get("/inspect/dom", async (_req, res) => {
app.get("/inspect/dom", async (req, res) => {
try {
res.json(await withPageLock(async () => {
const p = await ensurePage();
const role = String(req.query?.role || "default").trim() || "default";
const p = await ensurePage(role);
return await inspectDom(p);
}));
}, String(req.query?.role || "default").trim() || "default"));
} catch (error) {
res.status(500).json(errorStatus(error));
}
@@ -375,9 +376,9 @@ app.post("/chat/clear-history", async (req, res) => {
}
const result = await withPageLock(async () => {
const p = await ensurePage("maintenance");
const p = await ensurePage("incoming");
return await clearChatHistoryInMax(p, externalChatId, chatUrl);
}, "maintenance");
}, "incoming");
res.json(result);
} catch (error) {
res.json({ success: false, error: String(error?.message || error) });
@@ -393,15 +394,43 @@ app.post("/chat/delete", async (req, res) => {
}
const result = await withPageLock(async () => {
const p = await ensurePage("maintenance");
const p = await ensurePage("incoming");
return await deleteChatInMax(p, externalChatId, chatUrl);
}, "maintenance");
}, "incoming");
res.json(result);
} catch (error) {
res.json({ success: false, error: String(error?.message || error) });
}
});
app.post("/chat/menu-probe", async (req, res) => {
try {
const externalChatId = String(req.body?.externalChatId || "").trim();
const chatUrl = normalizeMaxChatUrl(req.body?.chatUrl);
const labels = Array.isArray(req.body?.labels) && req.body.labels.length > 0
? req.body.labels.map((label) => String(label || ""))
: ["\u0443\u0434\u0430\u043b\u0438\u0442\u044c \u0447\u0430\u0442", "\u0443\u0434\u0430\u043b\u0438\u0442\u044c", "delete chat", "delete conversation", "remove chat"];
if (!externalChatId) {
return res.json({ success: false, error: "externalChatId is required.", chatUrl: null });
}
const result = await withPageLock(async () => {
const p = await ensurePage("incoming");
const menuOpened = await openSidebarChatMenu(p, externalChatId, chatUrl, labels);
await p.keyboard.press("Escape").catch(() => {});
await clearSidebarSearch(p);
return {
success: menuOpened,
error: menuOpened ? null : "MAX chat menu was not opened.",
chatUrl: await getCurrentChatUrl(p)
};
}, "incoming");
res.json(result);
} catch (error) {
res.json({ success: false, error: String(error?.message || error), chatUrl: null });
}
});
app.post("/chat/clear-composer", async (req, res) => {
try {
const externalChatId = String(req.body?.externalChatId || "").trim();
@@ -2779,11 +2808,15 @@ async function clearSidebarSearch(p) {
element.getAttribute("data-testid"),
element.className,
element.id,
element.getAttribute("name")
element.getAttribute("name"),
element.value
].join(" ").toLowerCase();
const isSearchLike = /search|\u043f\u043e\u0438\u0441\u043a/.test(text);
const isTopSidebarInput = rect.left < window.innerWidth * 0.42 &&
rect.top < window.innerHeight * 0.34;
return rect.left < window.innerWidth * 0.42 &&
rect.top < window.innerHeight * 0.32 &&
/search|\u043f\u043e\u0438\u0441\u043a/.test(text);
(isSearchLike || isTopSidebarInput);
});
for (const input of inputs) {
@@ -2935,7 +2968,7 @@ async function waitForMaxChatListReady(p, timeoutMs = 15000) {
rect.left <= window.innerWidth;
};
return Array.from(document.querySelectorAll("aside button.cell, aside button[class*='cell' i]"))
return Array.from(document.querySelectorAll("aside button.cell, aside button[class*='cell' i], aside button.item, aside button[class*='item' i]"))
.some(isVisible);
}).catch(() => false);
@@ -3032,9 +3065,46 @@ async function readVisibleSidebarChatRows(p) {
/^(mon|tue|wed|thu|fri|sat|sun|\u043f\u043d|\u0432\u0442|\u0441\u0440|\u0447\u0442|\u043f\u0442|\u0441\u0431|\u0432\u0441)$/i.test(line)
) || null;
};
const textOf = (element) => String(
element.innerText ||
element.textContent ||
element.getAttribute("aria-label") ||
element.getAttribute("title") ||
""
).trim().toLowerCase();
const isMenuButton = (element) => {
const text = textOf(element);
const cls = String(element.className || "").toLowerCase();
return text.includes("\u0435\u0449") ||
text.includes("more") ||
text.includes("menu") ||
text.includes("actions") ||
text.includes("\u0434\u0435\u0439\u0441\u0442\u0432") ||
cls.includes("more") ||
cls.includes("menu") ||
text === "\u22ef" ||
text === "\u2026";
};
const menuPoint = (element) => {
const buttons = Array.from(element.querySelectorAll("button,[role='button']"))
.filter((button) => button instanceof HTMLElement && isVisible(button));
const match = buttons.find(isMenuButton) ||
buttons.sort((a, b) => b.getBoundingClientRect().right - a.getBoundingClientRect().right)[0] ||
null;
if (!match) return null;
const rect = match.getBoundingClientRect();
return {
x: Math.round(rect.left + rect.width / 2),
y: Math.round(rect.top + rect.height / 2)
};
};
const selector = [
"aside [role='presentation']",
"aside [class*='wrapper' i]",
"aside button.cell",
"aside button[class*='cell' i]",
"aside button.item",
"aside button[class*='item' i]",
"aside [role='presentation'] button",
"aside [class*='wrapper' i] button",
"aside [data-testid*='chat' i]",
@@ -3050,18 +3120,25 @@ async function readVisibleSidebarChatRows(p) {
const lines = linesFrom(element.innerText || element.textContent);
const title = extractTitle(element, lines);
const avatarUrl = extractAvatarUrl(element);
const menu = menuPoint(element);
return {
title,
text,
preview: extractPreview(element, title, lines),
timeText: extractTimeText(element, lines),
avatarUrl,
left: Math.round(rect.left),
right: Math.round(rect.right),
x: Math.round(rect.left + Math.min(rect.width - 8, Math.max(8, rect.width * 0.50))),
y: Math.round(rect.top + rect.height / 2),
menuX: menu?.x || null,
menuY: menu?.y || null,
top: Math.round(rect.top)
};
})
.filter((row) => row.title && row.text)
.filter((row, index, rows) =>
rows.findIndex((candidate) => candidate.title === row.title && Math.abs(candidate.top - row.top) <= 2) === index)
.sort((a, b) => a.top - b.top);
}).catch(() => []);
}
@@ -3135,6 +3212,92 @@ async function clickMatchingSidebarRowAndReadUrl(p, descriptor) {
return await getCurrentChatUrl(p);
}
async function openSidebarChatMenu(p, externalChatId, chatUrl, expectedLabels) {
const descriptor = decodeDomChatDescriptor(externalChatId);
if (!descriptor?.title) {
return false;
}
const targetUrl = normalizeMaxChatUrl(chatUrl);
const sameChatUrl = (value) => {
const current = normalizeMaxChatUrl(value);
return Boolean(targetUrl && current && current === targetUrl);
};
const chooseVisibleRow = async () => {
const rows = await readVisibleSidebarChatRows(p);
const matches = rows.filter((row) => rowMatchesDomChatDescriptor(row, descriptor));
if (matches.length === 0) {
return { row: null, rows };
}
if (targetUrl) {
for (const candidate of matches) {
await p.mouse.click(candidate.x, candidate.y);
await waitForDomChatReady(p, candidate.title, 5000, false);
await p.waitForTimeout(150);
if (sameChatUrl(await getCurrentChatUrl(p))) {
return { row: candidate, rows };
}
}
return { row: null, rows };
}
return { row: chooseDomChatRow(rows, descriptor), rows };
};
const clickVisibleRowMenu = async () => {
const { row, rows } = await chooseVisibleRow();
if (!row) {
return false;
}
const x = row.menuX || (row.right ? row.right - 32 : row.x);
const y = row.menuY || row.y;
console.info(`[chat/menu] matched sidebar row title=${row.title} menu=(${x},${y}) avatar=${row.avatarUrl || ""} visibleRows=${rows.length}`);
await p.mouse.move(x, y);
await p.waitForTimeout(150);
await p.mouse.click(x, y);
await p.waitForTimeout(600);
const opened = await hasMenuActionSurface(p, expectedLabels);
if (!opened) {
const menuTexts = await readVisibleActionTexts(p);
console.warn(`[chat/menu] expected action labels not found; labels=${expectedLabels.join("|")} visible=${menuTexts.slice(0, 20).join("|")}`);
}
return opened;
};
await clearSidebarSearch(p);
await resetSidebarListToTop(p);
await waitForMaxChatListReady(p, 15000);
if (await clickVisibleRowMenu()) {
return true;
}
const searched = await searchSidebarChat(p, descriptor.title);
if (searched && await clickVisibleRowMenu()) {
return true;
}
await clearSidebarSearch(p);
await resetSidebarListToTop(p);
await waitForMaxChatListReady(p, 15000);
for (let pageIndex = 0; pageIndex < 220; pageIndex++) {
if (await clickVisibleRowMenu()) {
return true;
}
const moved = await scrollSidebarForward(p);
if (!moved) break;
await p.waitForTimeout(250);
}
await clearSidebarSearch(p);
return false;
}
async function resolveDomChatUrl(p, externalChatId) {
const descriptor = decodeDomChatDescriptor(externalChatId);
if (!descriptor?.title) {
@@ -4805,22 +4968,21 @@ async function deleteChatInMax(p, externalChatId, chatUrl) {
}
async function applyChatMenuActionInMax(p, externalChatId, chatUrl, actionLabels, confirmLabels, actionName) {
const opened = await ensureDomChatOpen(p, externalChatId, 5000, false, chatUrl);
if (!opened) {
return { success: false, error: "MAX chat was not found or did not open." };
}
if (!(await openChatMenu(p, actionLabels))) {
const menuOpened = await openSidebarChatMenu(p, externalChatId, chatUrl, actionLabels);
if (!menuOpened) {
return { success: false, error: `MAX ${actionName} menu was not opened.` };
}
if (!(await clickActionByText(p, actionLabels))) {
await p.keyboard.press("Escape").catch(() => {});
await clearSidebarSearch(p);
return { success: false, error: `MAX ${actionName} action was not found in the chat menu.` };
}
await p.waitForTimeout(700);
await clickActionByText(p, confirmLabels);
await p.waitForTimeout(1200);
await clearSidebarSearch(p);
return { success: true, error: null };
}
@@ -5083,6 +5245,51 @@ async function hasMenuActionSurface(p, labels) {
}, labels).catch(() => false);
}
async function readVisibleActionTexts(p) {
return await p.evaluate(() => {
const isVisible = (element) => {
if (!(element instanceof HTMLElement)) return false;
const style = window.getComputedStyle(element);
const rect = element.getBoundingClientRect();
return style.visibility !== "hidden" &&
style.display !== "none" &&
rect.width > 0 &&
rect.height > 0 &&
rect.bottom >= 0 &&
rect.right >= 0 &&
rect.top <= window.innerHeight &&
rect.left <= window.innerWidth;
};
const textOf = (element) => String(
element.innerText ||
element.textContent ||
element.getAttribute("aria-label") ||
element.getAttribute("title") ||
""
).replace(/\s+/g, " ").trim();
const selector = [
"[role='menu']",
"[role='menuitem']",
"[class*='menu' i]",
"[class*='popover' i]",
"[class*='dropdown' i]",
"button",
"[role='button']"
].join(",");
const seen = new Set();
return Array.from(document.querySelectorAll(selector))
.filter((element) => element instanceof HTMLElement && isVisible(element))
.map(textOf)
.filter(Boolean)
.filter((text) => {
if (seen.has(text)) return false;
seen.add(text);
return true;
})
.slice(0, 80);
}).catch(() => []);
}
async function hasActionSurface(p) {
return await p.evaluate(() => {
const isVisible = (element) => {