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>
This commit is contained in:
@@ -0,0 +1,350 @@
|
||||
// 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 };
|
||||
Reference in New Issue
Block a user