// 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-v6"; if (window.__voiceInjectInstalled === VER) return; // 版本变了或首次:清理旧按钮/旧 observer,重新安装 if (window.__voiceInjectObserver) { try { window.__voiceInjectObserver.disconnect(); } catch {} } const oldBtn = document.getElementById("codex-voice-input-btn"); if (oldBtn) oldBtn.remove(); window.__voiceInjectInstalled = VER; const TAG = "[VoiceInput]"; const BTN_ID = "codex-voice-input-btn"; const STYLE_ID = "codex-voice-input-style-v6"; // —— 样式 —— 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; } \`; (document.head || document.documentElement).appendChild(st); } function toast(msg, ms = 2400) { const t = document.createElement("div"); t.className = "codex-voice-toast"; t.textContent = msg; document.body.appendChild(t); setTimeout(() => t.remove(), ms); } // —— 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 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 (!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; 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()); 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 () => { 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 = () => { 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 (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 (!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 (mo) return; mo = new MutationObserver(() => { if (!document.getElementById(BTN_ID)) { firstMount = true; tryMount(); } }); window.__voiceInjectObserver = mo; // 供重新安装时 disconnect const root = document.body || document.documentElement; if (root) mo.observe(root, { childList: true, subtree: true }); // 首次尝试 tryMount(); } function ensureStarted() { if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", startObserver, { once: true }); } else { startObserver(); } } ensureStarted(); })(); `; module.exports = { INJECT_SCRIPT };