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:
mac
2026-07-16 19:16:45 +08:00
parent a6207fdefd
commit 02fb553c9e
19 changed files with 1884 additions and 95 deletions
+142 -2
View File
@@ -71,6 +71,7 @@ $$("[data-open-panel]").forEach((btn) => {
activatePanel(name);
if (name === "sessions" && !sessLoaded) sessLoad();
if (name === "memory" && !memLoaded) memLoad();
if (name === "voice" && !voiceLoaded) voiceLoad();
});
});
// 内置工具面板「← 返回插件市场」
@@ -98,6 +99,140 @@ apic.onLog((msg) => {
$("#log-panel").scrollTop = $("#log-panel").scrollHeight;
});
// ===========================================================================
// 语音输入(CDP 注入 Codex 麦克风按钮)
// ===========================================================================
let voiceLoaded = false;
const PHASE_TEXT = {
unknown: "未连接",
checking: "检测中…",
not_running: "Codex 未运行",
running_no_port: "需重启 Codex",
restarting: "正在重启 Codex…",
connecting: "正在连接…",
injecting: "正在注入…",
connected: "已连接",
disconnected: "已断开",
transcribing: "转写中…",
recording: "录音中",
error: "出错",
};
function renderVoiceStatus(s) {
const dot = $("#voice-dot");
const phaseText = $("#voice-phase-text");
const detail = $("#voice-detail");
const connectBtn = $("#voice-connect-btn");
const discBtn = $("#voice-disconnect-btn");
const phase = s && s.phase ? s.phase : "unknown";
if (dot) dot.dataset.state = phase;
const txt = PHASE_TEXT[phase] || phase;
if (phaseText) phaseText.textContent = txt;
const connected = phase === "connected" || phase === "transcribing" || phase === "recording";
if (connectBtn) connectBtn.classList.toggle("hidden", connected);
if (discBtn) discBtn.classList.toggle("hidden", !connected);
let detailText = txt;
if (s && s.lastError) detailText += " " + s.lastError;
if (s && s.codexPath) detailText += " 路径:" + s.codexPath;
if (s && s.target && s.target.title) detailText += " 目标:" + s.target.title;
if (detail) detail.textContent = detailText;
}
async function voiceLoad() {
if (voiceLoaded) return;
voiceLoaded = true;
let unsub = null;
try {
const s0 = await apic.voiceStatus();
renderVoiceStatus(s0);
} catch {}
unsub = apic.onVoiceStatus((s) => renderVoiceStatus(s));
const connectBtn = $("#voice-connect-btn");
const discBtn = $("#voice-disconnect-btn");
const refreshSttBtn = $("#voice-refresh-stt");
async function loadSttInfo() {
try {
const info = await apic.voiceSttInfo();
const nameEl = $("#voice-stt-name");
const modelEl = $("#voice-stt-model");
if (info && info.ready) {
if (nameEl) nameEl.textContent = info.groupName || "(未命名)";
if (modelEl) modelEl.textContent = info.model || "whisper-1";
} else {
if (nameEl) nameEl.textContent = "未配置";
if (modelEl) modelEl.textContent = "—";
}
} catch {}
}
loadSttInfo();
connectBtn.addEventListener("click", async () => {
const cur = await apic.voiceStatus().catch(() => ({}));
// 若 Codex 在跑但没端口,需用户确认重启
if (cur.phase === "running_no_port") {
const ok = confirm(
"需要重启 Codex 以启用语音输入调试通道。\n\n当前 Codex 窗口会关闭一次后重新打开,登录态与历史会话保留。\n是否继续?"
);
if (!ok) return;
busy(connectBtn, true, "重启中…");
try {
const s = await apic.voiceConnect({ forceRestart: true });
renderVoiceStatus(s);
if (s.phase === "error") toast(s.lastError || "连接失败", "err");
} catch (e) {
toast("连接失败:" + (e && e.message ? e.message : e), "err");
} finally {
busy(connectBtn, false);
}
return;
}
busy(connectBtn, true, "连接中…");
try {
const s = await apic.voiceConnect({});
renderVoiceStatus(s);
if (s.phase === "running_no_port") {
toast("Codex 在运行,需重启以启用调试端口,请再次点击连接", "");
} else if (s.phase === "error") {
toast(s.lastError || "连接失败", "err");
} else if (s.phase === "connected") {
toast("已连接,Codex 输入框旁应出现 🎤 按钮");
}
} catch (e) {
toast("连接失败:" + (e && e.message ? e.message : e), "err");
} finally {
busy(connectBtn, false);
}
});
discBtn.addEventListener("click", async () => {
busy(discBtn, true, "断开中…");
try {
const s = await apic.voiceDisconnect();
renderVoiceStatus(s);
} finally {
busy(discBtn, false);
}
});
if (refreshSttBtn) {
refreshSttBtn.addEventListener("click", () => loadSttInfo());
}
const probeBtn = $("#voice-probe-stt");
if (probeBtn) {
probeBtn.addEventListener("click", async () => {
const out = $("#voice-probe-result");
if (out) out.textContent = "诊断中…";
try {
const arr = await apic.voiceProbeStt();
const lines = (arr || []).map((x) =>
(x.supported ? "✅" : "❌") + " " + (x.name || "") + " " + (x.baseUrl || "") + " HTTP " + x.status + (x.note ? " " + x.note : "")
);
if (out) out.innerHTML = "各分组 /v1/audio/transcriptions 支持情况(✅=路径存在,可转写):\n" + esc(lines.join("\n")).replace(/\n/g, "<br>");
} catch (e) {
if (out) out.textContent = "诊断失败:" + (e && e.message ? e.message : e);
}
});
}
}
// ===========================================================================
// ① 插件市场
// ===========================================================================
@@ -110,9 +245,10 @@ const installed = new Set(JSON.parse(localStorage.getItem("installedSkills") ||
let installedSkills = []; // 磁盘实况(~/.codex/skills 扫描结果)
let installedDirs = new Set(); // 已安装技能的目录名,用于精确标注“已安装”
// 与主进程 skillInstall 的目录命名保持一致(安装到 ~/.codex/skills/<此名>)
// 与主进程 skillInstall 的目录命名保持一致(安装到 ~/.codex/skills/<此名>)。统一小写,避免大小写变体导致"已安装"判断错位。
function safeSkillDir(name) {
return (String(name || "skill")
.toLowerCase()
.replace(/[^\w一-龥.-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80)) || "skill";
}
async function loadInstalledSkills() {
@@ -1187,7 +1323,7 @@ function renderRouter(st) {
<div class="cl-item">
<input type="radio" name="active-grp" ${g.id === st.activeId ? "checked" : ""} data-active="${g.id}" />
<div>
<div class="cl-name">${esc(g.name)} ${badges}${g.codexImage ? ' <span class="agent-badge mm">🎨 生图</span>' : ""}</div>
<div class="cl-name">${esc(g.name)} ${badges}${g.codexImage ? ' <span class="agent-badge mm">🎨 生图</span>' : ""}${g.stt ? ' <span class="agent-badge mm">🎤 转文字' + (g.sttModel ? " " + esc(g.sttModel) : "") + '</span>' : ""}</div>
<div class="cl-src">${esc(g.baseUrl)}${g.model ? " · 模型 " + esc(g.model) : ""}${!g.isDefault && g.mode === "chat" ? " · 🔄转换" : ""}${g.hasKey ? "" : " · ⚠️无密钥"}</div>
</div>
<div class="btn-group" style="margin-left:auto">${actions}</div>
@@ -1231,6 +1367,8 @@ function openGrpModal(g) {
$("#grp-key").placeholder = g && g.hasKey ? "(已保存,留空则不修改)" : "sk-…";
$("#grp-model").value = g ? g.model || "" : "";
$("#grp-codex-image").checked = !!(g && g.codexImage);
$("#grp-stt").checked = !!(g && g.stt);
$("#grp-stt-model").value = g && g.sttModel ? g.sttModel : "whisper-1";
$("#grp-ctx").value = g && g.contextWindow ? g.contextWindow : "";
const sel = $("#grp-model-list");
sel.classList.add("hidden");
@@ -1266,6 +1404,8 @@ $("#grp-save").addEventListener("click", async () => {
mode: $("#grp-mode").value,
contextWindow: $("#grp-ctx").value.trim(),
codexImage: $("#grp-codex-image").checked,
stt: $("#grp-stt").checked,
sttModel: $("#grp-stt").checked ? ($("#grp-stt-model").value.trim() || "whisper-1") : "",
};
if (grpEditing) group.id = grpEditing.id;
const key = $("#grp-key").value;