02fb553c9e
- 增强会话同步:确保所有会话完全同步到新账号 - 强制更新 model_provider 字段 - 添加 session_index.jsonl 重建功能 - 确保数据库 UPSERT 逻辑正确处理所有情况 - 新增语音输入功能(voice_cdp.js, voice_inject.js) - 测试脚本和增强模块 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
351 lines
13 KiB
JavaScript
351 lines
13 KiB
JavaScript
// 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 };
|