From 1f1ae96acdfbfd67c28c28e988dd04cd62980cc2 Mon Sep 17 00:00:00 2001 From: LucasYeh702 <273656084+LucasYeh702@users.noreply.github.com> Date: Wed, 24 Jun 2026 13:51:23 +0800 Subject: [PATCH] Support localized ChatGPT UI selectors --- plugins/gpt-relay/scripts/chatgpt_relay.mjs | 409 +++++++++++++----- .../gpt-relay/scripts/chatgpt_relay.test.mjs | 43 ++ 2 files changed, 350 insertions(+), 102 deletions(-) diff --git a/plugins/gpt-relay/scripts/chatgpt_relay.mjs b/plugins/gpt-relay/scripts/chatgpt_relay.mjs index 88c5de9..6a253f2 100644 --- a/plugins/gpt-relay/scripts/chatgpt_relay.mjs +++ b/plugins/gpt-relay/scripts/chatgpt_relay.mjs @@ -129,10 +129,75 @@ const REASONING_EFFORT_LABELS = { extended: "Extended", heavy: "Heavy", }; +const LOCALIZED_UI_LABELS = Object.freeze({ + "Chat with ChatGPT": [ + "Chat with ChatGPT", + "Message ChatGPT", + "Ask ChatGPT", + "詢問 ChatGPT", + "問 ChatGPT", + "傳訊息給 ChatGPT", + "傳送訊息給 ChatGPT", + "與 ChatGPT 對話", + ], + "Send prompt": [ + "Send prompt", + "Send message", + "Submit", + "傳送", + "送出", + "傳送提示", + "送出提示", + "傳送訊息", + ], + "Configure...": ["Configure...", "Configure…", "設定", "設定...", "設定…"], + "Add files and more": [ + "Add files and more", + "Attach files", + "新增檔案和更多", + "新增檔案與更多", + "新增檔案及更多", + "新增檔案", + "加入檔案", + "附加檔案", + ], + "Add photos & files": [ + "Add photos & files", + "Add photos and files", + "新增相片和檔案", + "新增相片與檔案", + "新增照片和檔案", + "新增照片與檔案", + "新增圖片和檔案", + "新增圖片與檔案", + "上傳檔案", + "新增檔案", + ], + "Create image": ["Create image", "建立圖片", "產生圖片", "生成圖片"], + "Deep research": ["Deep research", "深入研究", "深度研究"], + "Web search": ["Web search", "網頁搜尋", "搜尋網頁", "網路搜尋"], + Projects: ["Projects", "專案", "項目"], + Model: ["Model", "模型"], + Instant: ["Instant", "即時", "快速"], + Thinking: ["Thinking", "Think", "思考", "推理"], + Pro: ["Pro", "專業", "研究級"], + Light: ["Light", "Lite", "輕量", "輕度", "低強度"], + Standard: ["Standard", "Normal", "標準", "一般", "普通", "中等"], + Extended: ["Extended", "Extend", "延伸", "擴展", "高強度"], + Heavy: ["Heavy", "Maximum", "Max", "重度", "最強", "最高"], + Export: ["Export", "匯出", "導出"], + "Export to Markdown": ["Export to Markdown", "匯出為 Markdown", "導出為 Markdown"], +}); const INTELLIGENCE_ENTRY_BUTTON_RE = - /^(?:Extended Pro|Pro|Thinking|Instant|Light|Standard|Extended|Heavy|(?:Light|Standard|Extended|Heavy)\s+(?:Pro|Thinking)|(?:Instant|Thinking|Pro)\s*[•·]\s*(?:Light|Standard|Extended|Heavy)|(?:5\.5|5\.4|5\.3|5\.2|4\.5|o3)\s+(?:Instant|Thinking|Pro)(?:\s+(?:Light|Standard|Extended|Heavy))?)$/i; + /^(?:Extended Pro|Pro|Thinking|Instant|Light|Standard|Extended|Heavy|思考|推理|即時|快速|輕量|輕度|標準|一般|普通|延伸|擴展|重度|(?:Light|Standard|Extended|Heavy|輕量|輕度|標準|一般|普通|延伸|擴展|重度)\s+(?:Pro|Thinking|思考|推理)|(?:Instant|Thinking|Pro|即時|快速|思考|推理)\s+(?:Light|Standard|Extended|Heavy|輕量|輕度|標準|一般|普通|延伸|擴展|重度)|(?:Instant|Thinking|Pro|即時|快速|思考|推理)\s*[•·]\s*(?:Light|Standard|Extended|Heavy|輕量|輕度|標準|一般|普通|延伸|擴展|重度)|(?:5\.5|5\.4|5\.3|5\.2|4\.5|o3)\s+(?:Instant|Thinking|Pro|即時|快速|思考|推理)(?:\s+(?:Light|Standard|Extended|Heavy|輕量|輕度|標準|一般|普通|延伸|擴展|重度))?)$/i; const INTELLIGENCE_MODEL_VALUE_RE = /^(5\.5|5\.4|5\.3|5\.2|4\.5|o3)$/i; -const INTELLIGENCE_EFFORT_VALUE_RE = /^(Light|Standard|Extended|Heavy)$/i; +const INTELLIGENCE_EFFORT_VALUE_RE = /^(Light|Standard|Extended|Heavy|Lite|Normal|Maximum|Max|輕量|輕度|低強度|標準|一般|普通|中等|延伸|擴展|高強度|重度|最強|最高)$/i; +const INTELLIGENCE_CONFIGURE_TEXT_RE = + /(?:\bIntelligence\b[\s\S]*\bModel\b|(?:智慧|智能|模型)[\s\S]*(?:Model|模型|Instant|Thinking|Pro|即時|快速|思考|推理)|(?:Model|模型)[\s\S]*(?:Instant|Thinking|Pro|即時|快速|思考|推理))/i; +const MODEL_SECTION_RE = /^(?:Model|模型)$/i; +const EFFORT_SECTION_RE = /(?:Thinking effort|Pro thinking effort|思考.*(?:強度|程度)|推理.*(?:強度|程度)|(?:強度|程度))/i; +const RESPONSE_ACTION_BUTTON_RE = /\b(copy response|more actions)\b|複製(?:回應|訊息)|更多動作/i; +const RESPONSE_ACTION_TESTID_RE = /copy-turn-action-button/i; export async function runExtendedProRelay(options = {}) { return relayPrompt(options); @@ -604,13 +669,9 @@ async function ensureComposer(tab) { let lastError = null; while (Date.now() < deadline) { - const composer = tab.playwright.getByRole("textbox", { - name: "Chat with ChatGPT", - }); - try { - const count = await composer.count(); - if (count === 1) { + const composer = await findComposerTextbox(tab); + if (composer) { return composer; } } catch (error) { @@ -642,6 +703,25 @@ async function ensureComposer(tab) { ); } +async function findComposerTextbox(tab) { + const namePattern = localizedLabelPattern("Chat with ChatGPT"); + const candidates = [ + tab.playwright.getByRole("textbox", { name: namePattern }), + tab.playwright.locator("form [role='textbox']"), + tab.playwright.locator("[contenteditable='true'][role='textbox']"), + tab.playwright.locator("[role='textbox']"), + ]; + + for (const locator of candidates) { + const visible = await firstVisibleLocator(locator); + if (visible) { + return visible; + } + } + + return null; +} + async function selectChatGPTIntelligence(tab, request = DEFAULT_INTELLIGENCE_REQUEST) { if (!request.explicit) { const current = await readCurrentIntelligenceSelection(tab); @@ -663,7 +743,7 @@ async function configureChatGPTIntelligence(tab, request) { await openIntelligenceConfigure(tab); let available = await readConfigureOptions(tab); - if (request.model) { + if (request.model && (!available.selectedModel || normalizeIntelligenceModel(available.selectedModel) !== request.model)) { try { await selectConfigureModel(tab, request.model); } catch (error) { @@ -684,7 +764,7 @@ async function configureChatGPTIntelligence(tab, request) { available = await readConfigureOptions(tab); } - if (request.mode) { + if (request.mode && available.selectedMode !== request.mode) { try { await selectConfigureMode(tab, request.mode); } catch (error) { @@ -702,7 +782,7 @@ async function configureChatGPTIntelligence(tab, request) { available = await readConfigureOptions(tab); } - if (request.effort) { + if (request.effort && available.selectedEffort !== request.effort) { try { await selectConfigureEffort(tab, request.effort, request.mode); } catch (error) { @@ -767,7 +847,12 @@ async function readConfigureSelection(tab, request = DEFAULT_INTELLIGENCE_REQUES } async function readConfigureOptions(tab) { - return await tab.playwright.evaluate(() => { + const configurePattern = { + source: INTELLIGENCE_CONFIGURE_TEXT_RE.source, + flags: INTELLIGENCE_CONFIGURE_TEXT_RE.flags, + }; + return await tab.playwright.evaluate(({ configurePattern: configPattern }) => { + const configureRe = new RegExp(configPattern.source, configPattern.flags); const normalize = (value) => String(value ?? "").trim().replace(/\s+/g, " "); const unique = (values) => [...new Set(values.filter(Boolean))]; const isVisible = (element) => { @@ -793,9 +878,9 @@ async function readConfigureOptions(tab) { }; const allVisible = (selector) => [...document.querySelectorAll(selector)].filter(isVisible); - const scopes = allVisible("[role='dialog']").filter((element) => { + const scopes = allVisible("[role='dialog'],[data-testid='composer-intelligence-picker-content'],[role='menu']").filter((element) => { const text = normalize(element.innerText || element.textContent); - return /\bIntelligence\b/.test(text) && /\bModel\b/.test(text); + return configureRe.test(text); }); const scope = scopes[0]; if (!scope) { @@ -804,36 +889,56 @@ async function readConfigureOptions(tab) { const text = normalize(scope.innerText || scope.textContent); const scopeElements = [...scope.querySelectorAll("*")].filter(isVisible); - const modelRe = /^(5\.5|5\.4|5\.3|5\.2|4\.5|o3)$/i; - const effortRe = /^(Light|Standard|Extended|Heavy)$/i; + const modelRe = /^(?:GPT-?)?(5\.5|5\.4|5\.3|5\.2|4\.5|o3)$/i; + const effortRe = /^(Light|Standard|Extended|Heavy|Lite|Normal|Maximum|Max|輕量|輕度|低強度|標準|一般|普通|中等|延伸|擴展|高強度|重度|最強|最高)$/i; const elementText = (element) => normalize(element.innerText || element.textContent); + const toModel = (value) => { + const match = normalize(value).match(modelRe); + return match ? match[1].toLowerCase() : null; + }; const toMode = (value) => { const entryText = normalize(value); - if (/^Instant\b/i.test(entryText)) return "instant"; - if (/^Thinking\b/i.test(entryText)) return "thinking"; - if (/^Pro\b/i.test(entryText)) return "pro"; + if (/^(?:Instant|即時|快速)\b/i.test(entryText)) return "instant"; + if (/^(?:Thinking|Think|思考|推理)\b/i.test(entryText)) return "thinking"; + if (/^(?:Pro|專業|研究級)\b/i.test(entryText)) return "pro"; + if (/(?:\bInstant\b|即時|快速)/i.test(entryText)) return "instant"; + if (/(?:\bThinking\b|\bThink\b|思考|推理)/i.test(entryText)) return "thinking"; + if (/(?:\bPro\b|專業|研究級)/i.test(entryText)) return "pro"; + return null; + }; + const toEffort = (value) => { + const entryText = normalize(value).toLowerCase(); + if (/\b(light|lite|low)\b|輕量|輕度|低強度/i.test(entryText)) return "light"; + if (/\b(standard|normal|medium)\b|標準|普通|一般|中等/i.test(entryText)) return "standard"; + if (/\b(extended|extend|high)\b|延伸|擴展|高強度/i.test(entryText)) return "extended"; + if (/\b(heavy|maximum|max)\b|重度|最強|最高/i.test(entryText)) return "heavy"; return null; }; const selectedModelControl = scopeElements.find((element) => - element.matches("[role='combobox']") && modelRe.test(elementText(element)) + element.matches("[role='combobox'],[role='menuitem'],[role='menuitemradio']") && modelRe.test(elementText(element)) ); - const selectedModel = selectedModelControl ? elementText(selectedModelControl) : null; - const modelOptions = allVisible("[role='option']") + const selectedModel = selectedModelControl ? toModel(elementText(selectedModelControl)) : null; + const modelOptions = allVisible("[role='option'],[role='menuitem'],[role='menuitemradio']") .map(elementText) - .filter((value) => modelRe.test(value)); + .map(toModel) + .filter(Boolean); const models = unique([selectedModel, ...modelOptions]); const modeRows = scopeElements.filter((element) => element.matches("[role='radio']")); - const modes = unique(modeRows.map((element) => toMode(elementText(element)))); + const menuModeRows = scopeElements.filter((element) => element.matches("[role='menuitemradio']")); + const modeChoices = [...modeRows, ...menuModeRows]; + const modes = unique(modeChoices.map((element) => toMode(elementText(element)))); const checkedMode = modeRows.find((element) => element.getAttribute("aria-checked") === "true"); - const selectedMode = checkedMode ? toMode(elementText(checkedMode)) : null; + const checkedMenuMode = menuModeRows.find((element) => element.getAttribute("aria-checked") === "true"); + const selectedModeText = checkedMode ? elementText(checkedMode) : (checkedMenuMode ? elementText(checkedMenuMode) : null); + const selectedMode = selectedModeText ? toMode(selectedModeText) : null; const selectedEffortControl = scopeElements.find((element) => element.matches("[role='combobox']") && effortRe.test(elementText(element)) ); - const selectedEffort = selectedEffortControl ? elementText(selectedEffortControl) : null; - const effortOptions = allVisible("[role='option']") + const selectedEffort = selectedEffortControl ? elementText(selectedEffortControl) : selectedModeText; + const effortOptions = allVisible("[role='option'],[role='menuitemradio']") .map(elementText) .filter((value) => effortRe.test(value)); const efforts = unique([selectedEffort, ...effortOptions]); @@ -842,14 +947,18 @@ async function readConfigureOptions(tab) { models, modes, efforts, - thinkingEfforts: efforts.filter((effort) => ["Light", "Standard", "Extended", "Heavy"].includes(effort)), - proEfforts: efforts.filter((effort) => ["Standard", "Extended"].includes(effort)), + thinkingEfforts: unique(efforts.map(toEffort)).filter((effort) => + ["light", "standard", "extended", "heavy"].includes(effort) + ), + proEfforts: unique(efforts.map(toEffort)).filter((effort) => + ["standard", "extended"].includes(effort) + ), selectedModel, selectedMode, - selectedEffort: selectedEffort?.toLowerCase() ?? null, + selectedEffort: toEffort(selectedEffort), textSnippet: text.slice(0, 1200), }; - }, undefined, { timeoutMs: 5000 }).catch(() => ({ + }, { configurePattern }, { timeoutMs: 5000 }).catch(() => ({ models: [], modes: [], efforts: [], @@ -904,6 +1013,10 @@ function normalizeAvailableModes(modes = []) { function normalizeAvailableEfforts(efforts = []) { return [...new Set(efforts)] .map((effort) => { + const parsed = extractReasoningEffort(effort); + if (parsed) { + return REASONING_EFFORT_LABELS[parsed]; + } const value = String(effort).toLowerCase(); return REASONING_EFFORT_LABELS[value] ?? effort; }) @@ -932,14 +1045,14 @@ function parseVisibleIntelligenceLabel(label = "") { parsed.model = normalizeIntelligenceModel(modelMatch[1]); } - if (/\bExtended Pro\b/i.test(text)) { + if (/\bExtended Pro\b|\bPro\s+(?:Extended|Extend)\b|(?:延伸|擴展|高強度)\s*Pro|Pro\s*(?:延伸|擴展|高強度)/i.test(text)) { parsed.mode = "pro"; parsed.effort = "extended"; - } else if (/\bPro\b/i.test(text)) { + } else if (/\bPro\b|專業|研究級/i.test(text)) { parsed.mode = "pro"; - } else if (/\bThinking\b/i.test(text)) { + } else if (/\bThinking\b|\bThink\b|思考|推理/i.test(text)) { parsed.mode = "thinking"; - } else if (/\bInstant\b/i.test(text)) { + } else if (/\bInstant\b|即時|快速/i.test(text)) { parsed.mode = "instant"; } @@ -955,6 +1068,10 @@ function parseVisibleIntelligenceLabel(label = "") { return parsed; } +function isIntelligenceEntryButtonLabel(label = "") { + return INTELLIGENCE_ENTRY_BUTTON_RE.test(normalizeWhitespace(label)); +} + async function openIntelligenceConfigure(tab) { const deadline = Date.now() + 30000; let lastControlError = null; @@ -970,6 +1087,10 @@ async function openIntelligenceConfigure(tab) { lastControlError = error; } + if (await isConfigureDialogOpen(tab)) { + return; + } + if (await clickIntelligenceConfigureMenuItem(tab)) { await tab.playwright.waitForTimeout(500); if (await isConfigureDialogOpen(tab)) { @@ -988,7 +1109,8 @@ async function openIntelligenceConfigure(tab) { } async function clickIntelligenceConfigureMenuItem(tab) { - const targetRect = await tab.playwright.evaluate(() => { + const labels = localizedLabelAlternatives("Configure..."); + const targetRect = await tab.playwright.evaluate((targetLabels) => { const normalize = (value) => String(value ?? "").trim().replace(/\s+/g, " "); const isVisible = (element) => { const style = getComputedStyle(element); @@ -1010,7 +1132,8 @@ async function clickIntelligenceConfigureMenuItem(tab) { return true; } - return /^Configure(?:\.{3}|…)$/i.test(normalize(element.innerText || element.textContent)); + const text = normalize(element.innerText || element.textContent); + return targetLabels.includes(text) || targetLabels.some((label) => text.startsWith(label)); }); if (!target) { @@ -1023,7 +1146,7 @@ async function clickIntelligenceConfigureMenuItem(tab) { y: rect.top + rect.height / 2, text: normalize(target.innerText || target.textContent), }; - }, undefined, { timeoutMs: 5000 }).catch(() => null); + }, labels, { timeoutMs: 5000 }).catch(() => null); if (!targetRect || !tab.cua?.click) { return false; @@ -1060,7 +1183,7 @@ async function clickModeControl(tab) { } async function selectConfigureModel(tab, model) { - if (!(await clickConfigureSectionValueControl(tab, /^Model$/i, INTELLIGENCE_MODEL_VALUE_RE))) { + if (!(await clickConfigureSectionValueControl(tab, MODEL_SECTION_RE, INTELLIGENCE_MODEL_VALUE_RE))) { throw codedError( "INTELLIGENCE_MODEL_CONTROL_MISSING", "Could not find the ChatGPT model selector in Configure." @@ -1096,16 +1219,13 @@ async function selectConfigureEffort(tab, effort, mode) { const targetAlreadyVisible = await findConfigureValueControl( tab, - new RegExp(`^${escapeRegExp(targetLabel)}$`, "i") + localizedLabelPattern(targetLabel) ); if (targetAlreadyVisible) { return; } - const effortSectionPattern = mode === "pro" - ? /^Pro thinking effort$/i - : /^(?:Thinking effort|Pro thinking effort)$/i; - if (!(await clickConfigureSectionValueControl(tab, effortSectionPattern, INTELLIGENCE_EFFORT_VALUE_RE))) { + if (!(await clickConfigureSectionValueControl(tab, EFFORT_SECTION_RE, INTELLIGENCE_EFFORT_VALUE_RE))) { throw codedError( "REASONING_EFFORT_CONTROL_MISSING", "Could not find the ChatGPT reasoning effort selector in Configure." @@ -1117,7 +1237,7 @@ async function selectConfigureEffort(tab, effort, mode) { await tab.playwright.waitForTimeout(300); await assertConfigureValueVisible( tab, - new RegExp(`^${escapeRegExp(targetLabel)}$`, "i"), + localizedLabelPattern(targetLabel), "REASONING_EFFORT_NOT_SELECTED", `ChatGPT reasoning effort selector did not update to '${targetLabel}'.` ); @@ -1132,9 +1252,14 @@ async function clickConfigureSectionValueControl(tab, sectionPattern, valuePatte source: valuePattern.source, flags: valuePattern.flags, }; - const targetRect = await tab.playwright.evaluate(({ section, value }) => { + const configure = { + source: INTELLIGENCE_CONFIGURE_TEXT_RE.source, + flags: INTELLIGENCE_CONFIGURE_TEXT_RE.flags, + }; + const targetRect = await tab.playwright.evaluate(({ section, value, configure }) => { const sectionRe = new RegExp(section.source, section.flags); const valueRe = new RegExp(value.source, value.flags); + const configureRe = new RegExp(configure.source, configure.flags); const normalize = (raw) => String(raw ?? "").trim().replace(/\s+/g, " "); const isVisible = (element) => { const style = getComputedStyle(element); @@ -1148,7 +1273,7 @@ async function clickConfigureSectionValueControl(tab, sectionPattern, valuePatte }; const scopes = [...document.querySelectorAll("[role='dialog']")].filter((element) => { const text = normalize(element.innerText || element.textContent); - return isVisible(element) && /\bIntelligence\b/.test(text) && /\bModel\b/.test(text); + return isVisible(element) && configureRe.test(text); }); const scope = scopes[0] || document.body; const elements = [...scope.querySelectorAll("*")].filter(isVisible); @@ -1192,7 +1317,7 @@ async function clickConfigureSectionValueControl(tab, sectionPattern, valuePatte } return null; - }, { section, value }, { timeoutMs: 5000 }).catch(() => null); + }, { section, value, configure }, { timeoutMs: 5000 }).catch(() => null); if (!targetRect || !tab.cua?.click) { return false; @@ -1236,7 +1361,7 @@ async function assertConfigureValueVisible(tab, valuePattern, errorCode, message async function getIntelligenceConfigureScope(tab) { const dialog = tab.playwright.getByRole("dialog").filter({ - hasText: /Intelligence/i, + hasText: INTELLIGENCE_CONFIGURE_TEXT_RE, }); if ((await safeLocatorCount(dialog)) > 0) { @@ -1247,12 +1372,13 @@ async function getIntelligenceConfigureScope(tab) { } async function clickDropdownOption(tab, label, errorCode) { - const exactLabel = new RegExp(`^${escapeRegExp(label)}\\b`, "i"); + const exactLabel = localizedLabelPattern(label); + const labels = localizedLabelAlternatives(label); const scope = await getIntelligenceConfigureScope(tab); const candidates = [ scope.getByRole("radio", { name: exactLabel }), scope.locator("[role='radio']").filter({ hasText: exactLabel }), - tab.playwright.getByRole("option", { name: label }), + ...labels.map((entry) => tab.playwright.getByRole("option", { name: entry })), tab.playwright.getByRole("option", { name: exactLabel }), tab.playwright.locator("[role='option']").filter({ hasText: exactLabel }), tab.playwright.getByRole("menuitemradio", { name: exactLabel }), @@ -1269,7 +1395,7 @@ async function clickDropdownOption(tab, label, errorCode) { } } - if (await clickVisibleConfigureElementByText(tab, [label])) { + if (await clickVisibleConfigureElementByText(tab, labels)) { return; } @@ -1278,7 +1404,12 @@ async function clickDropdownOption(tab, label, errorCode) { async function clickVisibleConfigureElementByText(tab, labels) { const normalizedLabels = labels.map((label) => normalizeWhitespace(label)); - const targetRect = await tab.playwright.evaluate((targetLabels) => { + const configure = { + source: INTELLIGENCE_CONFIGURE_TEXT_RE.source, + flags: INTELLIGENCE_CONFIGURE_TEXT_RE.flags, + }; + const targetRect = await tab.playwright.evaluate(({ targetLabels, configure }) => { + const configureRe = new RegExp(configure.source, configure.flags); const normalize = (value) => String(value ?? "").trim().replace(/\s+/g, " "); const isVisible = (element) => { const style = getComputedStyle(element); @@ -1292,7 +1423,7 @@ async function clickVisibleConfigureElementByText(tab, labels) { }; const dialogs = [...document.querySelectorAll("[role='dialog']")].filter((element) => { const text = normalize(element.innerText || element.textContent); - return isVisible(element) && /\bIntelligence\b/.test(text) && /\bModel\b/.test(text); + return isVisible(element) && configureRe.test(text); }); const dialog = dialogs[0]; if (!dialog) { @@ -1334,7 +1465,7 @@ async function clickVisibleConfigureElementByText(tab, labels) { y: targetBounds.top + targetBounds.height / 2, text: normalize(target.innerText || target.textContent), }; - }, normalizedLabels, { timeoutMs: 5000 }).catch(() => null); + }, { targetLabels: normalizedLabels, configure }, { timeoutMs: 5000 }).catch(() => null); if (!targetRect || !tab.cua?.click) { return false; @@ -1390,8 +1521,13 @@ async function findComposerModeControl(tab) { source: INTELLIGENCE_ENTRY_BUTTON_RE.source, flags: INTELLIGENCE_ENTRY_BUTTON_RE.flags, }; - const target = await tab.playwright.evaluate(({ pattern: entryPattern }) => { + const composerPattern = { + source: localizedLabelPattern("Chat with ChatGPT").source, + flags: localizedLabelPattern("Chat with ChatGPT").flags, + }; + const target = await tab.playwright.evaluate(({ pattern: entryPattern, composerPattern }) => { const entryRe = new RegExp(entryPattern.source, entryPattern.flags); + const composerRe = new RegExp(composerPattern.source, composerPattern.flags); const normalize = (value) => String(value ?? "").trim().replace(/\s+/g, " "); const isVisible = (element) => { const style = getComputedStyle(element); @@ -1417,9 +1553,11 @@ async function findComposerModeControl(tab) { }; }; const buttons = [...document.querySelectorAll("button")].filter(isVisible); - const composerTextbox = [...document.querySelectorAll("[role='textbox']")] - .filter(isVisible) - .find((element) => element.getAttribute("aria-label") === "Chat with ChatGPT"); + const visibleTextboxes = [...document.querySelectorAll("[role='textbox']")] + .filter(isVisible); + const composerTextbox = visibleTextboxes + .find((element) => composerRe.test(normalize(element.getAttribute("aria-label")))) ?? + visibleTextboxes.at(-1); const textboxRect = composerTextbox ? rectOf(composerTextbox) : null; const candidates = buttons .map((element) => ({ @@ -1466,7 +1604,7 @@ async function findComposerModeControl(tab) { x: target.rect.centerX, y: target.rect.centerY, }; - }, { pattern }, { timeoutMs: 5000 }).catch(() => null); + }, { pattern, composerPattern }, { timeoutMs: 5000 }).catch(() => null); if (!target) { return null; @@ -1521,19 +1659,26 @@ async function applyComposerFeatures(tab, { feature, projectName }) { } async function openComposerMenu(tab) { - const menuButton = tab.playwright.getByRole("button", { - name: "Add files and more", - }); + const labelPattern = localizedLabelPattern("Add files and more"); + const candidates = [ + tab.playwright.getByRole("button", { name: labelPattern }), + tab.playwright.locator("button").filter({ hasText: labelPattern }), + tab.playwright.locator("button[aria-label]").filter({ hasText: labelPattern }), + ]; - if ((await menuButton.count()) !== 1) { - throw codedError( - "COMPOSER_MENU_UNAVAILABLE", - "Could not find the ChatGPT composer menu button." - ); + for (const locator of candidates) { + const visible = await firstVisibleLocator(locator); + if (visible) { + await visible.click({}); + await tab.playwright.waitForTimeout(300); + return; + } } - await menuButton.click({}); - await tab.playwright.waitForTimeout(300); + throw codedError( + "COMPOSER_MENU_UNAVAILABLE", + "Could not find the ChatGPT composer menu button." + ); } async function selectComposerMenuItem(tab, label, errorCode) { @@ -1550,28 +1695,28 @@ async function clickMenuLabel(tab, label, errorCode) { } async function tryClickMenuLabel(tab, label, { timeoutMs = 15000 } = {}) { - const labelPattern = - label === "Configure..." ? /^Configure(?:\.{3}|…)$/i : new RegExp(`^${escapeRegExp(label)}$`, "i"); + const labelPattern = localizedLabelPattern(label); + const labels = localizedLabelAlternatives(label); const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { if ( await clickVisibleElementByText( tab, - label === "Configure..." ? ["Configure...", "Configure…"] : [label] + labels ) ) { return true; } const candidates = [ - tab.playwright.getByRole("menuitem", { name: label }), + ...labels.map((entry) => tab.playwright.getByRole("menuitem", { name: entry })), tab.playwright.getByRole("menuitem", { name: labelPattern }), tab.playwright.locator("[role='menuitem']").filter({ hasText: labelPattern }), - tab.playwright.getByRole("button", { name: label }), + ...labels.map((entry) => tab.playwright.getByRole("button", { name: entry })), tab.playwright.getByRole("button", { name: labelPattern }), tab.playwright.locator("button").filter({ hasText: labelPattern }), - tab.playwright.getByText(label, { exact: true }), + ...labels.map((entry) => tab.playwright.getByText(entry, { exact: true })), tab.playwright.getByText(labelPattern), ]; @@ -1590,7 +1735,12 @@ async function tryClickMenuLabel(tab, label, { timeoutMs = 15000 } = {}) { } async function isConfigureDialogOpen(tab) { - return await tab.playwright.evaluate(() => { + const configure = { + source: INTELLIGENCE_CONFIGURE_TEXT_RE.source, + flags: INTELLIGENCE_CONFIGURE_TEXT_RE.flags, + }; + return await tab.playwright.evaluate(({ configure }) => { + const configureRe = new RegExp(configure.source, configure.flags); const normalize = (value) => String(value ?? "").trim().replace(/\s+/g, " "); const isVisible = (element) => { const style = getComputedStyle(element); @@ -1603,11 +1753,11 @@ async function isConfigureDialogOpen(tab) { ); }; - return [...document.querySelectorAll("[role='dialog']")].some((element) => { + return [...document.querySelectorAll("[role='dialog'],[data-testid='composer-intelligence-picker-content'],[role='menu']")].some((element) => { const text = normalize(element.innerText || element.textContent); - return isVisible(element) && /\bIntelligence\b/.test(text) && /\bModel\b/.test(text); + return isVisible(element) && configureRe.test(text); }); - }, undefined, { timeoutMs: 5000 }).catch(() => false); + }, { configure }, { timeoutMs: 5000 }).catch(() => false); } async function clickVisibleElementByText(tab, labels) { @@ -1695,6 +1845,19 @@ async function isVisibleElementTextPresent(tab, labels) { }, normalizedLabels, { timeoutMs: 5000 }).catch(() => false); } +function localizedLabelAlternatives(label) { + const values = LOCALIZED_UI_LABELS[label] ?? [label]; + return [...new Set(values.map((value) => normalizeWhitespace(value)).filter(Boolean))]; +} + +function localizedLabelPattern(label) { + const alternatives = localizedLabelAlternatives(label); + const pattern = alternatives + .map((value) => escapeRegExp(value)) + .join("|"); + return new RegExp(`^(?:${pattern})(?:\\b|\\s|$|[::。..])`, "iu"); +} + function normalizeWhitespace(value) { return String(value ?? "").trim().replace(/\s+/g, " "); } @@ -1941,7 +2104,7 @@ async function uploadAttachment(tab, attachment, { timeoutMs }) { name: "generic-add-files-button", run: async () => { const locator = tab.playwright.getByRole("button", { - name: "Add files and more", + name: localizedLabelPattern("Add files and more"), }); await clickFileChooserLocator(tab, locator, paths, timeoutMs); }, @@ -1974,18 +2137,19 @@ async function uploadAttachment(tab, attachment, { timeoutMs }) { } async function clickChatGPTAddPhotosMenuItem(tab, paths, timeoutMs) { + const labelPattern = localizedLabelPattern("Add photos & files"); let menuItem = tab.playwright.locator("div[role='menuitem']").filter({ - hasText: "Add photos & files", + hasText: labelPattern, }); - if ((await safeLocatorCount(menuItem)) !== 1) { + if (!(await firstVisibleLocator(menuItem))) { await openComposerMenu(tab); menuItem = tab.playwright.locator("div[role='menuitem']").filter({ - hasText: "Add photos & files", + hasText: labelPattern, }); } - await clickFileChooserLocator(tab, menuItem, paths, timeoutMs); + await clickFileChooserLocator(tab, await firstVisibleLocator(menuItem) ?? menuItem, paths, timeoutMs); } async function clickFileChooserTarget(tab, selector, paths, timeoutMs, options = {}) { @@ -2474,18 +2638,26 @@ async function pressPaste(tab, composer) { } async function clickSend(tab) { - const sendButton = tab.playwright.getByRole("button", { - name: "Send prompt", - }); + const sendLabelPattern = localizedLabelPattern("Send prompt"); + const candidates = [ + tab.playwright.getByRole("button", { name: sendLabelPattern }), + tab.playwright.locator("button[data-testid='send-button']"), + tab.playwright.locator("button[data-testid*='send']"), + tab.playwright.locator("button[aria-label]").filter({ hasText: sendLabelPattern }), + ]; - if ((await sendButton.count()) !== 1) { - throw codedError( - "SEND_BUTTON_MISSING", - "Could not find the ChatGPT send button after filling the prompt." - ); + for (const locator of candidates) { + const visible = await firstVisibleLocator(locator); + if (visible) { + await visible.click({}); + return; + } } - await sendButton.click({}); + throw codedError( + "SEND_BUTTON_MISSING", + "Could not find the ChatGPT send button after filling the prompt." + ); } async function waitForConversationUrl(tab, timeoutMs = 15000) { @@ -2643,6 +2815,11 @@ function isResponseCompleteSnapshot(snapshot) { return textComplete || artifactComplete || deepResearchComplete; } +function isResponseActionSignal({ label = "", testid = "" } = {}) { + return RESPONSE_ACTION_BUTTON_RE.test(String(label)) || + RESPONSE_ACTION_TESTID_RE.test(String(testid)); +} + async function waitForAssistantResponseInChunks(tab, { timeoutMs, waitChunkMs, @@ -2687,7 +2864,22 @@ async function waitForAssistantResponseInChunks(tab, { } async function readChatState(tab) { - const state = await tab.playwright.evaluate(({ minImageEdge, minImageArea }) => { + const responseActionPattern = { + source: RESPONSE_ACTION_BUTTON_RE.source, + flags: RESPONSE_ACTION_BUTTON_RE.flags, + }; + const responseActionTestidPattern = { + source: RESPONSE_ACTION_TESTID_RE.source, + flags: RESPONSE_ACTION_TESTID_RE.flags, + }; + const state = await tab.playwright.evaluate(({ + minImageEdge, + minImageArea, + responseActionPattern: actionPattern, + responseActionTestidPattern: actionTestidPattern, + }) => { + const responseActionRe = new RegExp(actionPattern.source, actionPattern.flags); + const responseActionTestidRe = new RegExp(actionTestidPattern.source, actionTestidPattern.flags); function isAnsweringButtonLabel(label) { const normalized = label.trim().replace(/\s+/g, " "); return ( @@ -2993,13 +3185,18 @@ async function readChatState(tab) { text: messageTextFromElement(el), })); - const buttonLabels = Array.from(document.querySelectorAll("button")) - .map((button) => - (button.getAttribute("aria-label") || + const buttonEntries = Array.from(document.querySelectorAll("button")) + .map((button) => ({ + label: ( + button.getAttribute("aria-label") || button.innerText || button.textContent || - "").trim() - ) + "" + ).trim(), + testid: button.getAttribute("data-testid") || "", + })); + const buttonLabels = buttonEntries + .map((entry) => entry.label) .filter(Boolean); const hasStopControl = Array.from(document.querySelectorAll("button")).some( @@ -3020,8 +3217,9 @@ async function readChatState(tab) { ) ); - const responseActionsAvailable = buttonLabels.some((label) => - /\b(copy response|more actions)\b/i.test(label) + const responseActionsAvailable = buttonEntries.some((entry) => + responseActionRe.test(entry.label) || + responseActionTestidRe.test(entry.testid) ); function textOf(element) { @@ -3141,6 +3339,8 @@ async function readChatState(tab) { }, { minImageEdge: GENERATED_IMAGE_MIN_EDGE, minImageArea: GENERATED_IMAGE_MIN_AREA, + responseActionPattern, + responseActionTestidPattern, }, { timeoutMs: 5000 }); state.deepResearch = parseDeepResearchState({ @@ -4820,6 +5020,7 @@ export const __testing = { uploadFailedError, isAssistantStatusText, isResponseCompleteSnapshot, + isResponseActionSignal, generatedImageArtifacts, imageArtifactSignature, parseDataUrl, @@ -4837,7 +5038,11 @@ export const __testing = { isAttachmentUploadBusySignal, resolveIntelligenceRequest, parseIntelligenceRequestFromPrompt, + parseVisibleIntelligenceLabel, + isIntelligenceEntryButtonLabel, formatIntelligenceLabel, + localizedLabelAlternatives, + localizedLabelPattern, }; function codedError(code, message, extra = {}) { diff --git a/plugins/gpt-relay/scripts/chatgpt_relay.test.mjs b/plugins/gpt-relay/scripts/chatgpt_relay.test.mjs index a14b754..0db5255 100644 --- a/plugins/gpt-relay/scripts/chatgpt_relay.test.mjs +++ b/plugins/gpt-relay/scripts/chatgpt_relay.test.mjs @@ -79,6 +79,12 @@ test("response completion requires stable non-transient text and a completion si }), true); }); +test("response action signals include localized ChatGPT controls", () => { + assert.equal(__testing.isResponseActionSignal({ label: "複製回應" }), true); + assert.equal(__testing.isResponseActionSignal({ label: "更多動作" }), true); + assert.equal(__testing.isResponseActionSignal({ testid: "copy-turn-action-button" }), true); +}); + test("create-image completion can be artifact-only", () => { assert.equal(__testing.isResponseCompleteSnapshot({ latestText: "", @@ -554,3 +560,40 @@ test("intelligence request supports options overriding prompt", () => { assert.equal(request.effort, "extended"); assert.equal(request.source, "options"); }); + +test("visible intelligence labels parse Traditional Chinese UI text", () => { + const thinking = __testing.parseVisibleIntelligenceLabel("5.5 思考 輕量"); + assert.equal(thinking.model, "5.5"); + assert.equal(thinking.mode, "thinking"); + assert.equal(thinking.effort, "light"); + + const instant = __testing.parseVisibleIntelligenceLabel("5.4 即時"); + assert.equal(instant.model, "5.4"); + assert.equal(instant.mode, "instant"); + assert.equal(instant.effort, undefined); + + const pro = __testing.parseVisibleIntelligenceLabel("5.5 Pro 延伸"); + assert.equal(pro.model, "5.5"); + assert.equal(pro.mode, "pro"); + assert.equal(pro.effort, "extended"); + + const compactPro = __testing.parseVisibleIntelligenceLabel("Pro 延伸"); + assert.equal(compactPro.mode, "pro"); + assert.equal(compactPro.effort, "extended"); +}); + +test("visible intelligence control labels include localized mode-effort order", () => { + assert.equal(__testing.isIntelligenceEntryButtonLabel("Pro 延伸"), true); + assert.equal(__testing.isIntelligenceEntryButtonLabel("Thinking Light"), true); + assert.equal(__testing.isIntelligenceEntryButtonLabel("思考 輕量"), true); +}); + +test("localized UI label alternatives include Chinese ChatGPT controls", () => { + assert.deepEqual( + __testing.localizedLabelAlternatives("Configure..."), + ["Configure...", "Configure…", "設定", "設定...", "設定…"] + ); + assert.match(String(__testing.localizedLabelPattern("Send prompt")), /傳送|送出/); + assert.match(String(__testing.localizedLabelPattern("Chat with ChatGPT")), /ChatGPT/); + assert.match(String(__testing.localizedLabelPattern("Add photos & files")), /檔案|圖片|相片|照片/); +});