Files
codex-market-app/electron/lib/voice_inject.js
T
mac 02fb553c9e feat: enhance session sync and add voice input feature
- 增强会话同步:确保所有会话完全同步到新账号
  - 强制更新 model_provider 字段
  - 添加 session_index.jsonl 重建功能
  - 确保数据库 UPSERT 逻辑正确处理所有情况
- 新增语音输入功能(voice_cdp.js, voice_inject.js)
- 测试脚本和增强模块

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 19:16:45 +08:00

351 lines
13 KiB
JavaScript
Raw 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-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"
? '<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 (!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 };