Files

820 lines
36 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 };