Files
codex-market-app/electron/lib/voice_inject.js
T

820 lines
36 KiB
JavaScript
Raw Normal View History

// 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]*?</" + name + "\\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 = '<svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true"><path d="M6.25 5.75h7.5m-6.5 0 .45 9h4.6l.45-9M8.1 5.75V4.5h3.8v1.25M8.65 8v4.6M11.35 8v4.6" stroke="currentColor" stroke-width="1.35" stroke-linecap="round" stroke-linejoin="round"/></svg>';
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"
? '<svg viewBox="0 0 24 24" fill="none" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="8" y="8" width="8" height="8" rx="1.5"></rect></svg>'
: st === "transcribing"
? '<svg viewBox="0 0 24 24" fill="none" stroke-width="2.3" stroke-linecap="round" aria-hidden="true"><path d="M12 3a9 9 0 1 1-8.2 5.3"></path></svg>'
: '<svg viewBox="0 0 24 24" fill="none" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3a3 3 0 0 0-3 3v5a3 3 0 0 0 6 0V6a3 3 0 0 0-3-3Z"></path><path d="M19 10v1a7 7 0 0 1-14 0v-1"></path><path d="M12 18v3"></path><path d="M8 21h8"></path></svg>';
btn.innerHTML = '<span class="cv-ico">' + icon + '</span>';
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 };