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;
+57
View File
@@ -102,6 +102,11 @@
<div class="tool-desc">自动把历史会话摘要成「长期记忆」并写入项目 HANDOFF,让 AI 跨会话记住上下文。可按项目开关,检索每次的决策 / 下一步 / 涉及文件。</div>
<div class="tool-foot"><button class="btn primary small" data-open-panel="memory">打开</button></div>
</div>
<div class="tool-card">
<div class="tool-top"><span class="tool-ico">🎤</span><div class="tool-name">语音输入</div></div>
<div class="tool-desc">给 Codex 输入框注入麦克风按钮,说话即转文字填入。需由工具箱重启 Codex 一次以启用调试通道(登录态保留)。</div>
<div class="tool-foot"><button class="btn primary small" data-open-panel="voice">打开</button></div>
</div>
</div>
<!-- 已安装插件 -->
@@ -115,6 +120,50 @@
<div id="plugin-empty" class="empty hidden"></div>
</section>
<!-- 语音输入(注入 Codex 输入框麦克风按钮) -->
<section class="panel" id="tab-voice">
<div class="panel-head">
<div>
<h2><button class="btn ghost small back-btn" data-back-plugins>← 插件市场</button> 语音输入</h2>
<p class="muted">由工具箱用 CDP 连接 Codex 桌面版,往输入框注入麦克风按钮;录音经中转站 Whisper 转文字并自动填入。</p>
</div>
<div class="head-actions">
<span id="voice-dot" class="voice-dot" data-state="unknown"></span>
<span id="voice-phase-text" class="muted small">未连接</span>
<button id="voice-connect-btn" class="btn primary">连接</button>
<button id="voice-disconnect-btn" class="btn ghost hidden">断开</button>
</div>
</div>
<div class="voice-body">
<div class="voice-card">
<div class="voice-card-title">状态</div>
<div id="voice-detail" class="muted small">点击「连接」开始。若 Codex 正在运行,会提示你确认重启 Codex 以启用调试端口。</div>
</div>
<div class="voice-card">
<div class="voice-card-title">转写分组 / 模型</div>
<div class="voice-row">
<span class="muted small">当前转写来源:</span>
<strong id="voice-stt-name">—</strong>
<span class="muted small">·</span>
<strong id="voice-stt-model">—</strong>
<button id="voice-refresh-stt" class="btn ghost small">刷新</button>
<button id="voice-probe-stt" class="btn ghost small">诊断接口</button>
</div>
<div id="voice-probe-result" class="muted small"></div>
<div class="muted small">在「路由中转」里勾选分组的「支持语音转文字」并填转写模型(如 whisper-1 / speech-01)。优先用当前激活分组,否则用任一勾选 STT 的分组。</div>
</div>
<div class="voice-card">
<div class="voice-card-title">使用说明</div>
<ol class="voice-ol">
<li>点击「连接」。若 Codex 在运行且未带调试端口,会弹窗提示重启(当前窗口会关闭一次,登录态保留)。</li>
<li>连接成功后,Codex 输入框旁出现 🎤 按钮。点击 → 授权麦克风权限 → 说话 → 再次点击停止。</li>
<li>转写完成后文字自动填入输入框。首次需在「系统设置 → 隐私 → 麦克风」允许 ChatGPT。</li>
<li>macOS 误杀进程风险已规避:仅在确认后重启。Codex 升级后若按钮消失,可能需等待工具箱适配。</li>
</ol>
</div>
</div>
</section>
<!-- ②.5 创意画布(Drawflow 节点画布) -->
<section class="panel" id="tab-canvas">
<div class="cv-toolbar">
@@ -296,6 +345,14 @@
<input id="grp-codex-image" type="checkbox" style="width:auto;margin:0" />
<span style="margin:0">在 Codex 中用于生图(写入 gen-image 技能,需支持 OpenAI 图片接口)</span>
</label>
<label class="field grow" style="margin-top:10px;flex-direction:row;align-items:center;gap:8px;cursor:pointer">
<input id="grp-stt" type="checkbox" style="width:auto;margin:0" />
<span style="margin:0">支持语音转文字(语音输入插件用此分组转写)</span>
</label>
<label class="field grow" style="margin-top:8px">
<span>语音转写模型(勾选 STT 后填,如 whisper-1 / speech-01)</span>
<input id="grp-stt-model" placeholder="whisper-1" />
</label>
<div class="field grow" style="margin-top:10px">
<span>默认模型(可选,填了则强制用它)</span>
<div class="row" style="gap:8px; align-items:center">
+14
View File
@@ -651,3 +651,17 @@ body.cv-noscroll { overflow: hidden; }
#tab-canvas.cv-fullscreen { position: fixed; inset: 0; z-index: 9999; margin: 0;
height: auto; padding: 14px 16px; background: var(--bg); }
#tab-canvas.cv-fullscreen .cv-stage { min-height: 0; border-radius: 12px; }
/* —— 语音输入面板 —— */
.voice-body { display: grid; gap: 14px; max-width: 760px; }
.voice-card { border: 1px solid var(--border, rgba(255,255,255,.1)); border-radius: 12px; padding: 14px 16px; background: var(--card, rgba(255,255,255,.02)); }
.voice-card-title { font-weight: 600; margin-bottom: 8px; }
.voice-row { display: flex; gap: 8px; align-items: center; }
.voice-row .search { flex: 1; }
.voice-ol { margin: 6px 0 0 18px; line-height: 1.9; color: var(--muted, #9ca3af); font-size: 13px; }
.voice-dot { width: 10px; height: 10px; border-radius: 999px; display: inline-block; background: #6b7280; flex: none; }
.voice-dot[data-state="connected"] { background: #34d399; box-shadow: 0 0 8px rgba(52,211,153,.7); }
.voice-dot[data-state="transcribing"], .voice-dot[data-state="recording"] { background: #fbbf24; box-shadow: 0 0 8px rgba(251,191,36,.7); }
.voice-dot[data-state="restarting"], .voice-dot[data-state="connecting"], .voice-dot[data-state="injecting"], .voice-dot[data-state="checking"] { background: #60a5fa; }
.voice-dot[data-state="error"] { background: #ef4444; box-shadow: 0 0 8px rgba(239,68,68,.7); }
.voice-dot[data-state="running_no_port"] { background: #f59e0b; }