// voice_inject.js —— 注入到 Codex 页面(app://-/index.html)的脚本,以字符串形式导出。 // 由 voice_cdp.js 经 CDP Page.addScriptToEvaluateOnNewDocument(常驻) + Runtime.evaluate(立即) 注入。 // 职责:在 .composer-footer 插麦克风按钮;点击录音→MediaRecorder 出 Blob→base64 经 // window.__voiceBridge(CDP binding) 回传主进程转写→回传文字→execCommand('insertText') 写回 composer。 // 写回手法参考 CodexPlusPlus 粘贴修复(renderer-inject.js 约第 8952 行):document.execCommand('insertText', false, text)。 // 注意:脚本体用普通模板字符串包裹;内部避免出现 ${(已无)与裸反引号(内部的模板字符串反引号需转义为 \`)。 const INJECT_SCRIPT = ` (() => { const VER = "voice-inject-v13"; if (window.__voiceInjectInstalled === VER) return; // 版本变了或首次:完整卸载旧实例,避免旧 observer 在新会话继续挂回按钮。 if (typeof window.__voiceInjectDispose === "function") { try { window.__voiceInjectDispose(); } catch {} } else if (window.__voiceInjectObserver) { try { window.__voiceInjectObserver.disconnect(); } catch {} } const oldBtn = document.getElementById("codex-voice-input-btn"); if (oldBtn) oldBtn.remove(); document.querySelectorAll("[data-td-sidebar-delete]").forEach((el) => el.remove()); window.__voiceInjectInstalled = VER; const TAG = "[VoiceInput]"; const BTN_ID = "codex-voice-input-btn"; const STYLE_ID = "codex-voice-input-style-v13"; let disposed = false; // —— 样式 —— if (!document.getElementById(STYLE_ID)) { const st = document.createElement("style"); st.id = STYLE_ID; st.textContent = \` .codex-voice-input-btn{ display:inline-flex;align-items:center;justify-content:center;flex:none; width:32px;height:32px;padding:0;border:0;border-radius:999px; background:rgba(17,24,39,.04);color:var(--text-secondary,#6b7280); cursor:pointer;line-height:1;transition:background .15s,color .15s,box-shadow .15s,transform .15s; -webkit-app-region:no-drag; } .codex-voice-input-btn:hover{ background:rgba(17,24,39,.08);color:var(--text-primary,#111827); box-shadow:0 1px 4px rgba(15,23,42,.08); } .codex-voice-input-btn:active{transform:scale(.96);} .codex-voice-input-btn:focus-visible{outline:2px solid rgba(59,130,246,.55);outline-offset:2px;} .codex-voice-input-btn .cv-ico{width:17px;height:17px;display:block;} .codex-voice-input-btn .cv-ico svg{width:100%;height:100%;display:block;stroke:currentColor;} .codex-voice-input-btn[data-state="recording"]{ background:rgba(239,68,68,.12);color:#ef4444; animation:codexVoicePulse 1.1s ease-in-out infinite; } .codex-voice-input-btn[data-state="transcribing"]{background:rgba(59,130,246,.1);color:#2563eb;cursor:wait;} .codex-voice-input-btn[data-state="transcribing"] .cv-ico{animation:codexVoiceSpin .8s linear infinite;} .codex-voice-input-btn[data-state="error"]{background:rgba(239,68,68,.1);color:#dc2626;} @media (prefers-color-scheme:dark){ .codex-voice-input-btn{background:rgba(255,255,255,.05);color:var(--text-secondary,#a1a1aa);} .codex-voice-input-btn:hover{background:rgba(255,255,255,.1);color:var(--text-primary,#f4f4f5);box-shadow:none;} } @keyframes codexVoicePulse{0%,100%{box-shadow:0 0 0 0 rgba(239,68,68,.42)}50%{box-shadow:0 0 0 7px rgba(239,68,68,0)}} @keyframes codexVoiceSpin{to{transform:rotate(360deg)}} .codex-voice-toast{ position:fixed;left:50%;bottom:96px;transform:translateX(-50%); background:rgba(24,24,27,.96);color:#f3f4f6;padding:9px 16px;border-radius:10px; font:13px system-ui,sans-serif;z-index:999999;box-shadow:0 8px 30px rgba(0,0,0,.5); max-width:80vw; } .td-sidebar-delete-btn{color:rgba(113,113,122,.72)!important;} .td-sidebar-delete-btn:hover,.td-sidebar-delete-btn:focus-visible{color:#dc2626!important;} @media (prefers-color-scheme:dark){ .td-sidebar-delete-btn{color:rgba(161,161,170,.72)!important;} .td-sidebar-delete-btn:hover,.td-sidebar-delete-btn:focus-visible{color:#f87171!important;} } \`; (document.head || document.documentElement).appendChild(st); } function toast(msg, ms = 2400) { if (disposed) return; const t = document.createElement("div"); t.className = "codex-voice-toast"; t.textContent = msg; document.body.appendChild(t); setTimeout(() => t.remove(), ms); } // 某些中转会把内部思考块作为普通正文推给页面。只处理已明确成对的 // 内部标签,保留用户输入框、代码编辑器和工具箱自己注入的节点。 function stripVisibleThoughtText(text) { let out = String(text || ""); for (const name of ["thinking", "analysis", "reasoning"]) { out = out.replace(new RegExp("<" + name + "\\b[^>]*>[\\s\\S]*?", "gi"), ""); } return out.replace(/<\\/?(?:thinking|analysis|reasoning)\\b[^>]*>/gi, ""); } function sanitizeVisibleThoughts(root) { if (!root || !document.createTreeWalker) return; const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); const nodes = []; let node; while ((node = walker.nextNode())) nodes.push(node); for (const textNode of nodes) { const parent = textNode.parentElement; if (!parent || parent.closest("#codex-voice-input-btn, textarea, input, [contenteditable='true'], script, style")) continue; const next = stripVisibleThoughtText(textNode.nodeValue); if (next !== textNode.nodeValue) textNode.nodeValue = next; } } // 在 Codex 任务右键菜单中增加真正删除入口。只从当前菜单关联的 // DOM 数据/链接提取 thread id,拿不到 id 时不显示,避免误删。 let currentMenuThreadId = ""; const THREAD_ID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i; function readReactThreadId(node) { for (const key of Object.keys(node || {})) { if (!key.startsWith("__reactProps$") && !key.startsWith("__reactFiber$")) continue; const seen = new Set(); const walk = (value, depth, field) => { if (depth > 4 || value == null) return ""; if (typeof value === "string") { const hit = value.match(THREAD_ID_RE); return hit && /thread|conversation|session|href|id/i.test(field || "") ? hit[0] : ""; } if (typeof value !== "object" || seen.has(value) || value instanceof Node) return ""; seen.add(value); for (const [name, child] of Object.entries(value)) { if (/^(children|return|sibling|child|stateNode|alternate|_owner)$/i.test(name)) continue; const hit = walk(child, depth + 1, name); if (hit) return hit; } return ""; }; const hit = walk(node[key], 0, key); if (hit) return hit; } return ""; } function readThreadId(node) { let cur = node; for (let depth = 0; cur && depth < 8; depth++, cur = cur.parentElement) { for (const key of ["threadId", "conversationId", "sessionId", "id"]) { const value = cur.dataset && cur.dataset[key]; if (value && THREAD_ID_RE.test(value)) return value.match(THREAD_ID_RE)[0]; } const href = cur.getAttribute && cur.getAttribute("href"); const hit = href && href.match(THREAD_ID_RE); if (hit) return hit[0]; const text = cur.getAttribute && (cur.getAttribute("data-state") || cur.getAttribute("aria-controls") || ""); const textHit = text.match(THREAD_ID_RE); if (textHit) return textHit[0]; const nested = cur.querySelector && cur.querySelector("a[href],[data-thread-id],[data-conversation-id],[data-session-id]"); if (nested && nested !== cur) { const nestedValue = (nested.getAttribute("href") || "") + " " + (nested.dataset && (nested.dataset.threadId || nested.dataset.conversationId || nested.dataset.sessionId) || ""); const nestedHit = nestedValue.match(THREAD_ID_RE); if (nestedHit) return nestedHit[0]; } const reactHit = readReactThreadId(cur); if (reactHit) return reactHit; } return ""; } function sidebarThreadId(row) { const raw = row && row.getAttribute && row.getAttribute("data-app-action-sidebar-thread-id"); const hit = String(raw || "").match(THREAD_ID_RE); return hit ? hit[0] : readThreadId(row); } function copySessionIdItem(menu) { if (!menu) return null; const candidates = menu.querySelectorAll("button,[role='menuitem'],[role='button'],li,div"); for (const item of candidates) { if (item.dataset && item.dataset.tdPermanentDelete) continue; if (String(item.textContent || "").trim() === "复制会话 ID") return item; } return null; } function menuLooksLikeSession(menu) { const text = String(menu && menu.textContent || ""); return !!copySessionIdItem(menu) && /归档任务|复制深度链接|重命名任务/.test(text); } function findMenuThreadId(menu) { const direct = readThreadId(menu); if (direct) return direct; const links = menu ? menu.querySelectorAll("a,button,[role='menuitem'],[data-thread-id],[data-conversation-id]") : []; for (const el of links) { const hit = readThreadId(el); if (hit) return hit; } return currentMenuThreadId; } function sendDeleteRequest(threadId, menu) { if (!threadId || typeof window.__voiceBridge !== "function") { const nativeCopy = copySessionIdItem(menu); if (!nativeCopy || typeof window.__voiceBridge !== "function") { toast("未找到会话 ID,请从工具箱会话记录页删除"); return; } const requestId = "td-del-" + Date.now() + "-" + Math.random().toString(16).slice(2); try { // 先让主进程保存剪贴板,随后复用 Codex 原生“复制会话 ID”菜单动作。 window.__voiceBridge(JSON.stringify({ kind: "delete-session-clipboard-prepare", requestId })); setTimeout(() => { try { nativeCopy.click(); } catch {} setTimeout(() => { try { window.__voiceBridge(JSON.stringify({ kind: "delete-session-clipboard-read", requestId })); } catch {} }, 90); }, 110); toast("正在读取会话 ID…", 2200); } catch (e) { toast("删除请求发送失败"); } return true; } try { window.__voiceBridge(JSON.stringify({ kind: "delete-session", threadId })); toast("正在执行永久删除,请稍候…", 3200); return false; } catch (e) { toast("删除请求发送失败"); return false; } } function addDeleteMenuItem(menu) { if (!menu || menu.querySelector("[data-td-permanent-delete]")) return; if (!menuLooksLikeSession(menu)) return; const threadId = findMenuThreadId(menu); const nativeCopy = copySessionIdItem(menu); if (!nativeCopy) return; const item = document.createElement("button"); item.type = "button"; item.dataset.tdPermanentDelete = "1"; item.textContent = "彻底删除本地会话"; item.title = "退出 Codex 后删除本地会话、SQLite 索引和工具箱记忆"; item.style.cssText = "display:block;width:100%;margin-top:6px;padding:8px 12px;border:0;border-top:1px solid rgba(127,127,127,.2);background:transparent;color:#dc2626;text-align:left;font:inherit;cursor:pointer;"; item.addEventListener("mouseenter", () => { item.style.background = "rgba(220,38,38,.10)"; }); item.addEventListener("mouseleave", () => { item.style.background = "transparent"; }); item.addEventListener("click", (event) => { event.preventDefault(); event.stopPropagation(); const needsNativeCopy = sendDeleteRequest(threadId, menu); const root = item.closest("[role='menu'],[data-radix-menu-content]") || item.parentElement; if (root) setTimeout(() => root.remove(), needsNativeCopy ? 280 : 0); }); (nativeCopy.parentElement || menu).appendChild(item); } function scanSessionMenus() { document.querySelectorAll("[role='menu'],[data-radix-menu-content],div[class*='menu'],div[class*='popover']").forEach((el) => addDeleteMenuItem(el)); } function captureMenuTarget(event) { currentMenuThreadId = readThreadId(event.target); setTimeout(scanSessionMenus, 30); setTimeout(scanSessionMenus, 180); } if (window.__tdPermanentDeleteContextHandler) { document.removeEventListener("contextmenu", window.__tdPermanentDeleteContextHandler, true); document.removeEventListener("pointerdown", window.__tdPermanentDeleteContextHandler, true); } // 原生菜单不经过网页 DOM,不再对全局 pointerdown 做昂贵扫描。保留手动 // scanner 仅用于未来 Codex 切回网页菜单时诊断。 window.__tdPermanentDeleteContextHandler = null; window.__tdScanPermanentDeleteMenu = scanSessionMenus; // Codex 的右键菜单是 Electron 原生 Menu,网页脚本无法向其中追加 DOM。 // 因此把彻底删除入口挂到任务行已有的“置顶/归档”操作组,macOS/Windows 共用。 function makeSidebarDeleteButton(row, actionBox) { const archive = actionBox.querySelector("button[aria-label='归档任务'],button[aria-label='Archive task']"); const btn = document.createElement("button"); btn.type = "button"; btn.dataset.tdSidebarDelete = "1"; btn.className = ((archive && archive.className) || "") + " td-sidebar-delete-btn"; btn.setAttribute("aria-label", "彻底删除本地会话"); btn.title = "彻底删除本地会话"; btn.innerHTML = ''; btn.addEventListener("pointerdown", (event) => { event.preventDefault(); event.stopPropagation(); }); btn.addEventListener("click", (event) => { event.preventDefault(); event.stopPropagation(); const threadId = sidebarThreadId(row); if (!threadId) { toast("未找到会话 ID,请从工具箱会话记录页删除"); return; } sendDeleteRequest(threadId, null); }); return btn; } // —— 侧栏行/归档按钮/操作容器 查找:多策略 + 调试日志 —— // 行查找:优先 Codex 内部属性,退化到 aria-label/href/testid 关键字匹配。 function findSidebarRows(root) { const scope = root || document; const out = []; const seen = new Set(); const push = (el) => { if (el && !seen.has(el)) { seen.add(el); out.push(el); } }; // 1) Codex 内部属性(最准) try { if (scope.nodeType === 1 && scope.matches && scope.matches("[data-app-action-sidebar-thread-row]")) push(scope); if (scope.querySelectorAll) scope.querySelectorAll("[data-app-action-sidebar-thread-row]").forEach(push); } catch (e) { console.log("[TD] findSidebarRows: attr strategy failed", e && e.message); } if (out.length) { console.log("[TD] findSidebarRows: attr strategy hit", out.length); return out; } // 2) aria-label 含 thread/conversation/任务/会话 的按钮或链接 try { const ariaEls = scope.querySelectorAll("button[aria-label],a[aria-label],[role='button'][aria-label]"); const kwRe = /(thread|conversation|任务|会话)/i; for (const el of ariaEls) { const label = el.getAttribute("aria-label") || ""; if (kwRe.test(label)) push(el.closest("li,[data-app-action-sidebar-thread-row],div,section") || el.parentElement || el); } } catch (e) { console.log("[TD] findSidebarRows: aria strategy failed", e && e.message); } if (out.length) { console.log("[TD] findSidebarRows: aria strategy hit", out.length); return out; } // 3) href 含 UUID 的链接 try { const hrefEls = scope.querySelectorAll("a[href]"); for (const el of hrefEls) { const href = el.getAttribute("href") || ""; if (THREAD_ID_RE.test(href)) push(el.closest("li,[data-app-action-sidebar-thread-row],div,section") || el.parentElement || el); } } catch (e) { console.log("[TD] findSidebarRows: href strategy failed", e && e.message); } if (out.length) { console.log("[TD] findSidebarRows: href strategy hit", out.length); return out; } // 4) data-testid 含 thread/conversation try { const testEls = scope.querySelectorAll("[data-testid]"); const testRe = /(thread|conversation)/i; for (const el of testEls) { const tid = el.getAttribute("data-testid") || ""; if (testRe.test(tid)) push(el.closest("li,[data-app-action-sidebar-thread-row],div,section") || el.parentElement || el); } } catch (e) { console.log("[TD] findSidebarRows: testid strategy failed", e && e.message); } if (out.length) console.log("[TD] findSidebarRows: testid strategy hit", out.length); else console.log("[TD] findSidebarRows: no rows found via any strategy"); return out; } // 归档按钮查找:aria-label/title/textContent/SVG 多策略,大小写不敏感、部分匹配。 function findArchiveButton(row) { if (!row) return null; const kwRe = /(归档|archive)/i; // 1) aria-label(大小写不敏感 + 部分匹配) try { const byAria = row.querySelector("button[aria-label],a[aria-label],[role='button'][aria-label]"); const list = byAria ? [byAria, ...row.querySelectorAll("button[aria-label],a[aria-label],[role='button'][aria-label]")] : row.querySelectorAll("button[aria-label],a[aria-label],[role='button'][aria-label]"); for (const el of list) { const label = (el.getAttribute && el.getAttribute("aria-label")) || ""; if (kwRe.test(label)) { console.log("[TD] findArchiveButton: aria-label hit", label); return el; } } } catch (e) { console.log("[TD] findArchiveButton: aria strategy failed", e && e.message); } // 2) title 属性 try { for (const el of row.querySelectorAll("button[title],a[title],[role='button'][title]")) { const title = (el.getAttribute && el.getAttribute("title")) || ""; if (kwRe.test(title)) { console.log("[TD] findArchiveButton: title hit", title); return el; } } } catch (e) { console.log("[TD] findArchiveButton: title strategy failed", e && e.message); } // 3) textContent 匹配 try { for (const el of row.querySelectorAll("button,a,[role='button']")) { const text = String(el.textContent || "").trim(); if (text && kwRe.test(text)) { console.log("[TD] findArchiveButton: textContent hit", text); return el; } } } catch (e) { console.log("[TD] findArchiveButton: text strategy failed", e && e.message); } // 4) SVG 图标启发:含 archive/pin 风格 path 的按钮 try { const svgBtns = row.querySelectorAll("button:has(svg),a:has(svg),[role='button']:has(svg)"); for (const el of svgBtns) { const svg = el.querySelector("svg"); if (!svg) continue; const d = (svg.querySelector("path") && svg.querySelector("path").getAttribute("d")) || ""; // 归档盒常见:横向顶条 + 下方矩形 if (/m.*h.*v.*h.*z/i.test(d) && d.length > 30) { console.log("[TD] findArchiveButton: svg heuristics hit"); return el; } } } catch (e) { console.log("[TD] findArchiveButton: svg strategy failed", e && e.message); } console.log("[TD] findArchiveButton: no archive button found"); return null; } // 操作容器查找:从归档按钮向上试多种深度,或直接定位行内操作组。 function findSidebarActionBox(row) { const archive = findArchiveButton(row); if (archive) { // 试 parent.depth 1..4,找到含多个按钮的容器即视为 actionBox let cur = archive.parentElement; for (let depth = 1; cur && depth <= 4; depth++, cur = cur.parentElement) { const btns = cur.querySelectorAll("button,[role='button']"); if (btns.length >= 2) { console.log("[TD] findSidebarActionBox: via archive, depth", depth, "btns", btns.length); return { actionBox: cur, anchor: archive.parentElement || archive }; } } // 退而求其次:归档按钮的直接父级 console.log("[TD] findSidebarActionBox: fallback to archive.parentElement"); return { actionBox: archive.parentElement, anchor: archive }; } // 不经归档按钮:直接找行内按钮组 try { const groups = row.querySelectorAll("div:has(> button),span:has(> button)"); for (const g of groups) { if (g.querySelectorAll("button,[role='button']").length >= 2) { console.log("[TD] findSidebarActionBox: direct group hit"); return { actionBox: g, anchor: null }; } } } catch (e) { console.log("[TD] findSidebarActionBox: direct group strategy failed", e && e.message); } console.log("[TD] findSidebarActionBox: not found"); return null; } function mountSidebarDeleteButtons(root = document) { const rows = findSidebarRows(root); console.log("[TD] mountSidebarDeleteButtons: rows", rows.length); let mounted = 0, skipped = 0, failed = 0; for (const row of rows) { if (row.querySelector("[data-td-sidebar-delete]")) { skipped++; continue; } const found = findSidebarActionBox(row); if (!found || !found.actionBox) { console.log("[TD] mountSidebarDeleteButtons: no actionBox for row", row); failed++; continue; } const { actionBox, anchor } = found; try { actionBox.style.width = "76px"; actionBox.insertBefore(makeSidebarDeleteButton(row, actionBox), anchor || actionBox.firstChild); mounted++; console.log("[TD] mountSidebarDeleteButtons: mounted on row", row); } catch (e) { console.log("[TD] mountSidebarDeleteButtons: insert failed", e && e.message); failed++; } } console.log(\`[TD] mountSidebarDeleteButtons: done mounted=\${mounted} skipped=\${skipped} failed=\${failed}\`); } // —— 诊断:把侧边栏 DOM 结构通过 binding 回传主进程(无需 DevTools)—— function dumpSidebarInfo() { if (typeof window.__voiceBridge !== "function") return; const info = { sidebar: null, rows: [], archive: null, actionBox: null }; // 1) 侧边栏容器 const sidebarCandidates = document.querySelectorAll("[class*='sidebar'],[class*='thread-list'],[class*='conversation-list'],[class*='nav-list'],[class*='task-list']"); info.sidebar = sidebarCandidates.length; // 2) 每行采样:取前 3 行看 HTML 结构 const allRows = document.querySelectorAll("a[href*='thread'],a[href*='conversation'],a[href*='/c/'],a[href*='/chat/'],[data-app-action-sidebar-thread-row],[class*='thread-row'],[class*='conversation-row'],[class*='session-row']"); if (allRows.length) { for (let i = 0; i < Math.min(3, allRows.length); i++) { const el = allRows[i]; info.rows.push({ tag: el.tagName, id: el.id, classes: (el.className || "").slice(0, 120), attrs: [].map.call(el.attributes || [], function(a) { return a.name + '=\\"' + a.value.slice(0, 60) + '\\"'; }).join(" ").slice(0, 300), inner: el.innerHTML.replace(/\\s+/g, " ").slice(0, 400), }); } } // 3) 归档按钮 const archiveBtn = document.querySelector("button[aria-label*='归档'],button[aria-label*='Archive'],button[aria-label*='archive'],button[aria-label*='archiv'],button[title*='归档'],button[title*='Archive'],button[title*='archive']"); if (archiveBtn) { info.archive = { tag: archiveBtn.tagName, id: archiveBtn.id, ariaLabel: archiveBtn.getAttribute("aria-label") || "", title: archiveBtn.title, classes: (archiveBtn.className || "").slice(0, 120), outer: archiveBtn.outerHTML.slice(0, 300), }; } // 4) 侧边栏内所有按钮 info.allButtons = [].map.call(document.querySelectorAll("[class*='sidebar'] button,[class*='thread-list'] button"), function(b) { return { ariaLabel: b.getAttribute("aria-label") || "", title: b.title, classes: (b.className || "").slice(0, 60) }; }); try { window.__voiceBridge(JSON.stringify({ kind: "debug-sidebar-dump", info: info })); } catch {} } window.__tdDumpSidebarInfo = dumpSidebarInfo; // 周期性重试:侧边栏可能是懒加载的,首次注入时 DOM 可能还没渲染出来 let retryCount = 0; const MAX_RETRIES = 10; function retryMountSidebar() { if (retryCount >= MAX_RETRIES) return; retryCount++; setTimeout(function() { const rows = findSidebarRows(document); if (rows.length > 0) { mountSidebarDeleteButtons(document); dumpSidebarInfo(); } else { console.log("[TD] retryMountSidebar: attempt " + retryCount + " still no rows, retrying..."); retryMountSidebar(); } }, 1500); } setTimeout(retryMountSidebar, 2000); // —— binding 通道(window.__voiceBridge 由主进程 CDP addBinding 注入)—— const pending = new Map(); let seq = 0; window.__voiceInjectHandle = (msg) => { let p; try { p = typeof msg === "string" ? JSON.parse(msg) : msg; } catch { return; } const entry = pending.get(p.id); if (!entry) return; pending.delete(p.id); if (p.err) entry.reject(new Error(p.err)); else entry.resolve(p.text || ""); }; function sendToBackend(audioB64, mime) { const id = String(++seq); return new Promise((resolve, reject) => { pending.set(id, { resolve, reject }); if (typeof window.__voiceBridge !== "function") { pending.delete(id); reject(new Error("语音通道未就绪,请重新连接工具箱")); return; } try { window.__voiceBridge(JSON.stringify({ id, kind: "transcribe", audioB64, mime })); } catch (e) { pending.delete(id); reject(e); } }); } // —— 找底部输入工具栏:从编辑器往上找含 footer 的工具行容器 —— function findToolbar() { const rich = document.querySelector("DIV.ProseMirror, [contenteditable], textarea"); if (!rich) return null; let p = rich; for (let i = 0; i < 6 && p; i++) { const foot = p.querySelector("[class*='_footer_'], [class*='composer-footer'], footer.composer, [class*='footer'][class*='composer']"); if (foot && foot.querySelectorAll("button, [role='button']").length >= 2) return foot; p = p.parentElement; } return null; } function findEditor() { return ( document.querySelector("DIV.ProseMirror") || document.querySelector("[contenteditable='true'],[contenteditable='']") || document.querySelector("textarea") ); } // —— 录音 —— let mediaRecorder = null; let activeStream = null; let chunks = []; let curBtn = null; let stopTimer = null; function setBtnState(btn, st) { if (!btn) return; btn.dataset.state = st || ""; const label = st === "recording" ? "停止录音" : st === "transcribing" ? "转写中…" : st === "error" ? "语音输入出错" : "语音输入"; const icon = st === "recording" ? '' : st === "transcribing" ? '' : ''; btn.innerHTML = '' + icon + ''; btn.title = label; btn.setAttribute("aria-label", label); } async function startRecording(btn) { if (disposed) return; if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { toast("此环境不支持麦克风"); setBtnState(btn, "error"); return; } let stream; try { stream = await navigator.mediaDevices.getUserMedia({ audio: true }); } catch (e) { const name = (e && e.name) || ""; if (name === "NotAllowedError" || name === "SecurityError") { toast("麦克风被拒绝:系统设置 → 隐私 → 麦克风 → 允许 ChatGPT", 4200); } else { toast("无法访问麦克风:" + (e && e.message ? e.message : name)); } setBtnState(btn, "error"); return; } curBtn = btn; activeStream = stream; chunks = []; const mime = MediaRecorder.isTypeSupported("audio/webm") ? "audio/webm" : MediaRecorder.isTypeSupported("audio/mp4") ? "audio/mp4" : ""; try { mediaRecorder = new MediaRecorder(stream, mime ? { mimeType: mime } : undefined); } catch (e) { toast("录音器初始化失败"); setBtnState(btn, "error"); stream.getTracks().forEach((t) => t.stop()); return; } mediaRecorder.ondataavailable = (e) => { if (e.data && e.data.size) chunks.push(e.data); }; mediaRecorder.onstop = () => { stream.getTracks().forEach((t) => t.stop()); activeStream = null; if (disposed) return; const blob = new Blob(chunks, { type: mime || "audio/webm" }); if (blob.size < 800) { toast("未录到声音"); setBtnState(btn, ""); return; } setBtnState(btn, "transcribing"); const reader = new FileReader(); reader.onload = async () => { if (disposed) return; const dataUrl = String(reader.result || ""); const comma = dataUrl.indexOf(","); const b64 = comma >= 0 ? dataUrl.slice(comma + 1) : dataUrl; try { const text = await sendToBackend(b64, mime || "audio/webm"); if (text) writeBack(text); else toast("转写结果为空"); } catch (err) { toast("转写失败:" + (err && err.message ? err.message : err), 4200); setBtnState(btn, "error"); return; } setBtnState(btn, ""); }; reader.onerror = () => { if (disposed) return; toast("音频读取失败"); setBtnState(btn, "error"); }; reader.readAsDataURL(blob); }; mediaRecorder.start(); setBtnState(btn, "recording"); stopTimer = setTimeout(() => stopRecording(), 60000); } function stopRecording() { if (stopTimer) { clearTimeout(stopTimer); stopTimer = null; } if (mediaRecorder && mediaRecorder.state !== "inactive") { try { mediaRecorder.stop(); } catch {} } } function writeBack(text) { const ed = findEditor(); if (!ed) { toast("未找到输入框,文字已无法填入", 3600); return; } try { ed.focus(); } catch {} const inserted = document.execCommand("insertText", false, text); if (!inserted) { // 兜底:textarea / input 用 setRangeText if (ed.tagName === "TEXTAREA" || ed.tagName === "INPUT") { const s = ed.selectionStart || 0; const e = ed.selectionEnd || 0; const v = ed.value || ""; ed.value = v.slice(0, s) + text + v.slice(e); const pos = s + text.length; try { ed.setSelectionRange(pos, pos); } catch {} ed.dispatchEvent(new Event("input", { bubbles: true })); } else { // contenteditable 兜底 try { const sel = window.getSelection(); const range = sel && sel.rangeCount ? sel.getRangeAt(0) : null; if (range) { range.deleteContents(); range.insertNode(document.createTextNode(text)); } } catch {} } } } function onBtnClick(btn) { if (disposed) return; if (mediaRecorder && mediaRecorder.state === "recording") { stopRecording(); return; } startRecording(btn); } function makeButton() { const b = document.createElement("button"); b.type = "button"; b.id = BTN_ID; b.className = "codex-voice-input-btn"; b.setAttribute("aria-label", "语音输入"); b.addEventListener("click", (e) => { e.preventDefault(); e.stopPropagation(); onBtnClick(b); }); setBtnState(b, ""); return b; } // 在工具栏里找「发送按钮」:默认取工具栏最后一个 button(Codex 里发送在最右)。 // 新版 Codex 会把模型选择和发送按钮包在右侧 action 容器里,语音按钮要挂到 // 发送按钮的直接父容器,才能落在「模型选择」与「发送」之间。 function findSendButton(toolbar) { const btns = toolbar.querySelectorAll("button, [role='button']"); if (!btns || !btns.length) return null; return btns[btns.length - 1]; } function containsNode(parent, child) { return !!(parent && child && (parent === child || parent.contains(child))); } let firstMount = true; function tryMount() { if (disposed) return; if (!firstMount && document.getElementById(BTN_ID)) return; // 已在,避免频繁重建丢焦点 firstMount = false; const existing = document.getElementById(BTN_ID); if (existing) existing.remove(); // 重新注入时刷新一次,确保新样式/位置 const toolbar = findToolbar(); if (!toolbar) { firstMount = true; return; } // 工具栏还没出来,下次再试 const sendBtn = findSendButton(toolbar); const btn = makeButton(); const sendParent = sendBtn && sendBtn.parentElement; if (sendParent && containsNode(toolbar, sendParent)) { sendParent.insertBefore(btn, sendBtn); // 插在发送按钮前,保持在右侧 action 组内 } else { toolbar.appendChild(btn); } } // —— MutationObserver:composer/页面重建后重挂 —— let mo = null; function startObserver() { if (disposed || mo) return; mo = new MutationObserver((records) => { if (disposed) return; if (!document.getElementById(BTN_ID)) { firstMount = true; tryMount(); } for (const record of records) { if (record.target && record.target.nodeType === 1) sanitizeVisibleThoughts(record.target); for (const added of record.addedNodes || []) { if (added.nodeType !== 1) continue; sanitizeVisibleThoughts(added); mountSidebarDeleteButtons(added); } } }); window.__voiceInjectObserver = mo; // 供重新安装时 disconnect const root = document.body || document.documentElement; if (root) mo.observe(root, { childList: true, subtree: true }); // 首次尝试 tryMount(); mountSidebarDeleteButtons(root); sanitizeVisibleThoughts(root); } function ensureStarted() { if (disposed) return; if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", startObserver, { once: true }); } else { startObserver(); } } // 由主进程在“关闭语音输入”时调用。必须在断开 CDP 前执行, // 否则页面里的 observer 仍会在输入框重建时重新挂出麦克风按钮。 window.__voiceInjectDispose = () => { if (disposed) return; disposed = true; if (stopTimer) { clearTimeout(stopTimer); stopTimer = null; } if (mediaRecorder && mediaRecorder.state !== "inactive") { try { mediaRecorder.stop(); } catch {} } if (activeStream) { try { activeStream.getTracks().forEach((track) => track.stop()); } catch {} activeStream = null; } for (const entry of pending.values()) { try { entry.reject(new Error("语音输入已关闭")); } catch {} } pending.clear(); if (mo) { try { mo.disconnect(); } catch {} mo = null; } if (window.__voiceInjectObserver) delete window.__voiceInjectObserver; document.getElementById(BTN_ID)?.remove(); document.getElementById(STYLE_ID)?.remove(); document.querySelectorAll(".codex-voice-toast").forEach((el) => el.remove()); if (window.__voiceInjectHandle) delete window.__voiceInjectHandle; if (window.__voiceInjectInstalled) delete window.__voiceInjectInstalled; }; ensureStarted(); })(); `; module.exports = { INJECT_SCRIPT };