From 02fb553c9e2a707f0b4f918328c1354ff3de7a8e Mon Sep 17 00:00:00 2001 From: mac Date: Thu, 16 Jul 2026 19:16:45 +0800 Subject: [PATCH] feat: enhance session sync and add voice input feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 增强会话同步:确保所有会话完全同步到新账号 - 强制更新 model_provider 字段 - 添加 session_index.jsonl 重建功能 - 确保数据库 UPSERT 逻辑正确处理所有情况 - 新增语音输入功能(voice_cdp.js, voice_inject.js) - 测试脚本和增强模块 Co-Authored-By: Claude Opus 4.8 (1M context) --- .learnings/ERRORS.md | 37 ++ .learnings/FEATURE_REQUESTS.md | 5 + .learnings/LEARNINGS.md | 7 + AGENTS.md | 47 +- HANDOFF.md | 79 ++-- electron/lib/canvas.js | 194 +++++++- electron/lib/provider_sync.js | 74 ++- electron/lib/router.js | 82 +++- electron/lib/session_sync_enhanced.js | 187 ++++++++ electron/lib/voice_cdp.js | 617 ++++++++++++++++++++++++++ electron/lib/voice_inject.js | 350 +++++++++++++++ electron/main.js | 20 +- electron/preload.js | 11 + package-lock.json | 4 +- package.json | 4 +- renderer/app.js | 144 +++++- renderer/index.html | 57 +++ renderer/styles.css | 14 + test-sync.js | 46 ++ 19 files changed, 1884 insertions(+), 95 deletions(-) create mode 100644 .learnings/ERRORS.md create mode 100644 .learnings/FEATURE_REQUESTS.md create mode 100644 .learnings/LEARNINGS.md create mode 100644 electron/lib/session_sync_enhanced.js create mode 100644 electron/lib/voice_cdp.js create mode 100644 electron/lib/voice_inject.js create mode 100644 test-sync.js diff --git a/.learnings/ERRORS.md b/.learnings/ERRORS.md new file mode 100644 index 0000000..4f08c99 --- /dev/null +++ b/.learnings/ERRORS.md @@ -0,0 +1,37 @@ +# Errors + +Command failures and integration errors. + +--- + +## [ERR-20260716-001] tavily_skill_local_setup + +**Logged**: 2026-07-16T17:27:46+0800 +**Priority**: medium +**Status**: pending +**Area**: config + +### Summary +Local Tavily skill script could not run directly and its Python dependency was not installed. + +### Error +```text +permission denied: .../Tavily-AI-Search/scripts/tavily_search.py +tavily-python package not installed +externally-managed-environment +``` + +### Context +- Attempted to use the Tavily skill for a news search. +- The script lacks executable permission, so `python3 script.py` is needed. +- Installing `tavily-python` into the system Python was blocked by PEP 668. +- `TAVILY_API_KEY` was not present in the environment. + +### Suggested Fix +Use a project or skill-local virtual environment for `tavily-python`, make the script executable if desired, and configure `TAVILY_API_KEY` or a supported app config key. + +### Metadata +- Reproducible: yes +- Related Files: /Users/mac/Library/Application Support/tokendun-codex-client/skills/Tavily-AI-Search/scripts/tavily_search.py + +--- diff --git a/.learnings/FEATURE_REQUESTS.md b/.learnings/FEATURE_REQUESTS.md new file mode 100644 index 0000000..3be3c58 --- /dev/null +++ b/.learnings/FEATURE_REQUESTS.md @@ -0,0 +1,5 @@ +# Feature Requests + +Capabilities requested by the user. + +--- diff --git a/.learnings/LEARNINGS.md b/.learnings/LEARNINGS.md new file mode 100644 index 0000000..10df08e --- /dev/null +++ b/.learnings/LEARNINGS.md @@ -0,0 +1,7 @@ +# Learnings + +Corrections, insights, and knowledge gaps captured during development. + +**Categories**: correction | insight | knowledge_gap | best_practice + +--- diff --git a/AGENTS.md b/AGENTS.md index 7fc4350..4b962d8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,36 +1,35 @@ ## 项目记忆(Codex 插件市场自动维护 · 回答前据此 · 勿手动编辑本段) -**当前状态**:已定位并修复 Codex 会话记录/项目分组同步不全问题:同步器会回填 threads 可见性与项目索引,支持 WAL,并修复 fork 会话 session_meta 覆盖导致部分会话不可见;临时数据验证 292 条线程、19 个项目分组均可恢复。用户最新要求是提交代码、推送 git 并发布热更包,尚未完成。 +**当前状态**:用户想重开 Claude Code 会话并延续之前记录。已说明可在原项目目录用 `claude --continue`/`-c` 接最近会话,用 `claude --resume`/`-r` 或 `/resume` 手动选择历史会话;会话按项目目录保存。 **已定决策**: -- 不提交无关 AGENTS/HANDOFF 改动 -- 不修改真实 ~/.codex 做验证 -- 项目分组从 threads.cwd 回填 -- has_user_event 用全文标记兜底 -- 缺失侧栏来自 threads 索引不完整 -- 备份/导入需包含 archived_sessions -- session_meta 只采用首个,避免后续覆盖 -- 优先用系统 sqlite3 处理 WAL -- 侧栏依赖 threads 与项目索引 -- 全量会话读取包含归档和 subagent -- 媒体预览保留dataUrl和原始url +- 指定历史会话用 claude --resume +- 最近会话用 claude --continue +- 优先在原项目目录恢复会话 +- 不在输出中暴露任何 API key +- Tavily 不可用时用网页搜索兜底 +- 不破坏 Homebrew 系统 Python +- 日期按 2026-07-16 +- 查询 topic 设为 news +- 使用 Tavily skill 搜索新闻 +- latest兼容下载保留但设为预发布 +- 同时发布热更和完整包 +- 非显式生图请求剥离gen-image声明 **下一步**: -- 检查 git diff 和远端 -- 只提交相关代码改动 -- 推送源码仓库 -- 运行热更发布脚本 -**关键文件**:/Users/mac/Documents/备份数据/ProjectDown/codex-market-app/electron/lib/provider_sync.js、/Users/mac/Documents/备份数据/ProjectDown/codex-market-app/electron/lib/sessions.js、/Users/mac/Documents/备份数据/ProjectDown/codex-market-app/renderer/app.js、/Users/mac/Documents/备份数据/ProjectDown/codex-market-app/AGENTS.md、/Users/mac/Documents/备份数据/ProjectDown/codex-market-app/HANDOFF.md +- 进入原项目目录 +- 运行恢复命令继续会话 +**关键文件**:~/.claude/projects/.../*.jsonl **历史话题(需其中细节时用下面命令检索)**: -- 2026-07-15 会话同步修复待发布 +- 2026-07-16 Claude Code 会话恢复 +- 2026-07-16 今日新闻搜索交接 +- 2026-07-16 发布1.5.14完整包 +- 2026-07-16 生图 skill 与聊天分组冲突 +- 2026-07-15 语音输入按钮位置与样式修复 +- 2026-07-15 Grok多参考图热更排查 +- 2026-07-15 会话同步修复与热更发布 - 2026-07-15 工具箱新增Grok视频与全量会话同步 - 2026-07-15 生成魔兽世界宣传海报 - 2026-07-15 Codex skill软链改造 - 2026-07-14 列出本地 skills - 2026-07-14 Claude CLI 中转配置排查 -- 2026-07-14 3x-ui订阅分流修复 -- 2026-07-14 图片生成与规格调整 -- 2026-07-14 生成蝴蝶图片并保存 -- 2026-07-13 项目与画布功能分析 -- 2026-07-13 网站安装数据库连接失败 -- 2026-07-12 服务器BBR已开启 > 回答涉及本段未列出的历史细节前,先运行 `rg -n "关键词" /Users/mac/.codex/memory/31c67c70b625.md` 查证再答。 diff --git a/HANDOFF.md b/HANDOFF.md index 5d72944..32bf024 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -3,70 +3,69 @@ > 核心记忆已写进本项目 `AGENTS.md`(Codex 每次会话自动读)。本文件是更详细的近期快照,供需要时参考;关键信息不依赖本文件。 -## 最近会话 · 2026-07-15 10:31 · 会话同步修复待发布 +## 最近会话 · 2026-07-16 10:37 · Claude Code 会话恢复 -已定位并修复 Codex 会话记录/项目分组同步不全问题:同步器会回填 threads 可见性与项目索引,支持 WAL,并修复 fork 会话 session_meta 覆盖导致部分会话不可见;临时数据验证 292 条线程、19 个项目分组均可恢复。用户最新要求是提交代码、推送 git 并发布热更包,尚未完成。 +用户想重开 Claude Code 会话并延续之前记录。已说明可在原项目目录用 `claude --continue`/`-c` 接最近会话,用 `claude --resume`/`-r` 或 `/resume` 手动选择历史会话;会话按项目目录保存。 **已确定的决策**: -- 不修改真实 ~/.codex 做验证 -- 优先用系统 sqlite3 处理 WAL -- 只认第一段 session_meta -- 不提交无关 AGENTS/HANDOFF 改动 +- 优先在原项目目录恢复会话 +- 最近会话用 claude --continue +- 指定历史会话用 claude --resume **下一步**: -- 检查 git diff 和远端 -- 只提交相关代码改动 -- 推送源码仓库 -- 运行热更发布脚本 +- 进入原项目目录 +- 运行恢复命令继续会话 -**涉及文件**:/Users/mac/Documents/备份数据/ProjectDown/codex-market-app/electron/lib/provider_sync.js、/Users/mac/Documents/备份数据/ProjectDown/codex-market-app/electron/lib/sessions.js、/Users/mac/Documents/备份数据/ProjectDown/codex-market-app/renderer/app.js、/Users/mac/Documents/备份数据/ProjectDown/codex-market-app/AGENTS.md、/Users/mac/Documents/备份数据/ProjectDown/codex-market-app/HANDOFF.md +**涉及文件**:~/.claude/projects/.../*.jsonl -## 更早会话 · 2026-07-15 09:02 · 工具箱新增Grok视频与全量会话同步 +## 更早会话 · 2026-07-16 09:25 · 今日新闻搜索交接 -已新增独立Grok视频节点和后端入口,配置Tokendun路由并测试15秒1080p图生视频可完成;画布媒体预览支持放大/下载并保留原始URL。会话记录页已增加手动获取全部记录功能,可读取sessions、archived_sessions及subagent并用于一键同步。 +用户要求用 Tavily 搜索今日最新新闻。Tavily 本地 skill 未跑通:缺 tavily-python,且未配置 TAVILY_API_KEY;已改用网页搜索兜底,整理了 2026-07-16 约17:30 的 AP、中国日报等要闻来源。 **已确定的决策**: -- Grok作为新增节点,不改Seedance -- Grok多图参考不支持 -- 媒体预览保留dataUrl和原始url -- 全量会话包含归档和subagent +- 不破坏 Homebrew 系统 Python +- Tavily 不可用时用网页搜索兜底 +- 不在输出中暴露任何 API key **下一步**: -- 打开应用验证全量按钮与同步流程 -- 检查Grok节点实际UI体验 -- 如需可优化长任务94%提示 +- 如需 Tavily,配置 TAVILY_API_KEY +- 用 venv 安装 tavily-python +- 可继续补充分类新闻来源 -**涉及文件**:/Users/mac/Documents/备份数据/ProjectDown/codex-market-app/electron/lib/canvas.js、/Users/mac/Documents/备份数据/ProjectDown/codex-market-app/renderer/app.js、/Users/mac/Documents/备份数据/ProjectDown/codex-market-app/renderer/index.html、/Users/mac/Documents/备份数据/ProjectDown/codex-market-app/renderer/styles.css、/Users/mac/Documents/备份数据/ProjectDown/codex-market-app/electron/lib/sessions.js、/Users/mac/Documents/备份数据/ProjectDown/codex-market-app/electron/main.js、/Users/mac/Documents/备份数据/ProjectDown/codex-market-app/electron/preload.js +**涉及文件**:/Users/mac/Library/Application Support/tokendun-codex-client/skills/Tavily-AI-Search/SKILL.md、.learnings -## 更早会话 · 2026-07-15 07:55 · 生成魔兽世界宣传海报 +## 更早会话 · 2026-07-16 05:37 · 发布1.5.14完整包 -已按用户原始提示词生成一张横版魔兽世界风格游戏宣传海报,尺寸1536x1024,文件已保存并预览确认整体可用;需注意模型生成文字和Logo可能有偏差。 +已修复普通聊天因gen-image声明触发403的问题,热更1.5.14和完整安装包1.5.14均已发布到mhgmh/codex-market-app;Windows与macOS arm64/x64下载链接已给出,latest正式版指向v1.5.14。 **已确定的决策**: -- 使用原始提示词不扩写 -- 横版海报尺寸1536x1024 -- 通过本地gen-image脚本生成 +- 非显式生图请求剥离gen-image声明 +- 版本统一为1.5.14 +- 同时发布热更和完整包 +- latest兼容下载保留但设为预发布 **下一步**: -- 正式使用前检查文字和Logo细节 +- 用户在另一台电脑安装1.5.14验证 +- 若仍403检查所选生图分组权限 -**涉及文件**:/Users/mac/Library/Application Support/tokendun-codex-client/skills/gen-image-生图/SKILL.md、/Users/mac/Library/Application Support/tokendun-codex-client/skills/gen-image-生图/output/img-20260715-155804-0.png +**涉及文件**:electron/lib/router.js、package.json、package-lock.json、scripts/publish-hot.js、electron-builder.yml -## 更早会话 · 2026-07-15 07:35 · Codex skill软链改造 +## 更早会话 · 2026-07-16 01:57 · 生图 skill 与聊天分组冲突 -已将 skill/plugin 安装、列表、卸载、迁移改为工具箱实体加 Codex 软链模式;启动迁移已跑通,列表能识别软链,删除 gen-image 已确认软链、实体和 .env 均清理成功。当前 ~/.codex/skills 仅剩 4 个 codex-client-* 软链。 +已定位:安装生图 skill 后,普通聊天请求会携带生图相关上下文,当前聊天分组未开图片权限而被 /responses 返回 403。推荐方案是普通聊天不暴露生图 skill,显式 $gen-image 时由本地拦截并直接调用 gen-image 脚本与生图分组。 **已确定的决策**: -- skill 实体放工具箱仓库 -- Codex 侧只保留软链 -- 卸载需同时删除源实体 -- openai-* 插件按系统插件保护 -- 自动迁移跳过 openai-* +- Grok 视频不是流式,是异步轮询 +- Grok 失败曾因成品视频直连超时 +- 已修 Grok 轮询与代理下载逻辑 +- 生图 403 来自聊天分组权限 +- 推荐 $gen-image 本地直执行 **下一步**: -- 确认 ontology 是否误删 -- 确认 Proactive-Agent 是否误删 -- 测试新装 skill 后仍为软链 -- 检查插件页系统插件状态 +- 实现 $gen-image 本地命令拦截 +- 普通聊天禁用生图 skill 注入 +- 调用 gen_image.py 走生图 .env +- 返回生成图片路径或预览 +- 必要时加错误提示与日志 -**涉及文件**:/Users/mac/Documents/备份数据/ProjectDown/codex-market-app/electron/main.js、/Users/mac/Documents/备份数据/ProjectDown/codex-market-app/electron/preload.js、/Users/mac/Documents/备份数据/ProjectDown/codex-market-app/electron/lib/router.js、/Users/mac/Documents/备份数据/ProjectDown/codex-market-app/renderer/app.js、/Users/mac/Documents/备份数据/ProjectDown/codex-market-app/renderer/plugins.json、/Users/mac/Documents/备份数据/ProjectDown/生图skill/DEPLOY_skill_softlink_patch.md +**涉及文件**:/Users/mac/Library/Containers/com.tencent.qq/Data/Downloads/GROK_VIDEO_API_CN.md、/Users/mac/Documents/备份数据/ProjectDown/electron/lib/canvas.js、/Users/mac/Documents/备份数据/ProjectDown/hot/1.5.13/electron/lib/canvas.js、/var/folders/xk/qrv10_rx12jg91fn322xlwr00000gn/T/codex-clipboard-f13f4be1-4b94-4878-927c-2a80e0e67b47.png diff --git a/electron/lib/canvas.js b/electron/lib/canvas.js index 7397f97..3c7d3d9 100644 --- a/electron/lib/canvas.js +++ b/electron/lib/canvas.js @@ -2,6 +2,12 @@ // 统一在主进程发起 image/video/tts 请求并把成品下载转 base64 data URL 返回渲染层。 // key 只在主进程流转;渲染层被 CSP 拦截的远程 URL 一律由这里下载转 base64。 const router = require("./router"); +const fs = require("fs"); +const http = require("http"); +const https = require("https"); +const os = require("os"); +const path = require("path"); +const { execFileSync } = require("child_process"); // —— 从 baseUrl 解析出主机名(失败则原样返回)—— function hostOf(baseUrl) { @@ -31,12 +37,116 @@ function headers(key) { }; } +function grokDebugLog(entry) { + try { + const dir = path.join(os.homedir(), "Library", "Application Support", "codex-market"); + fs.mkdirSync(dir, { recursive: true }); + fs.appendFileSync( + path.join(dir, "grok-video-debug.jsonl"), + JSON.stringify({ time: new Date().toISOString(), ...entry }) + "\n" + ); + } catch {} +} + +function systemProxyUrl() { + const envProxy = process.env.HTTPS_PROXY || process.env.https_proxy || process.env.HTTP_PROXY || process.env.http_proxy || ""; + if (envProxy) return envProxy; + if (process.platform !== "darwin") return ""; + try { + const out = execFileSync("scutil", ["--proxy"], { encoding: "utf8", timeout: 1000 }); + const get = (name) => { + const m = out.match(new RegExp("^\\s*" + name + "\\s*:\\s*(.+)$", "m")); + return m ? m[1].trim() : ""; + }; + const httpsOn = get("HTTPSEnable") === "1"; + const httpOn = get("HTTPEnable") === "1"; + const host = httpsOn ? get("HTTPSProxy") : httpOn ? get("HTTPProxy") : ""; + const port = httpsOn ? get("HTTPSPort") : httpOn ? get("HTTPPort") : ""; + return host && port ? `http://${host}:${port}` : ""; + } catch { + return ""; + } +} + +function proxyAgentFor(targetUrl, proxyUrl) { + try { + if (/^https:/i.test(targetUrl)) { + const { HttpsProxyAgent } = require("https-proxy-agent"); + return new HttpsProxyAgent(proxyUrl); + } + const { HttpProxyAgent } = require("http-proxy-agent"); + return new HttpProxyAgent(proxyUrl); + } catch { + return null; + } +} + +function requestBuffer(url, agent) { + return new Promise((resolve, reject) => { + let u; + try { + u = new URL(url); + } catch (e) { + reject(e); + return; + } + const mod = u.protocol === "https:" ? https : http; + const req = mod.get( + url, + { + agent, + headers: { "User-Agent": "codex-market" }, + }, + (res) => { + const loc = res.headers.location; + if (res.statusCode >= 300 && res.statusCode < 400 && loc) { + res.resume(); + requestBuffer(new URL(loc, url).toString(), agent).then(resolve, reject); + return; + } + if (res.statusCode < 200 || res.statusCode >= 300) { + res.resume(); + reject(new Error("HTTP " + res.statusCode)); + return; + } + const chunks = []; + res.on("data", (c) => chunks.push(c)); + res.on("end", () => resolve(Buffer.concat(chunks))); + } + ); + req.setTimeout(120000, () => req.destroy(new Error("下载超时"))); + req.on("error", reject); + }); +} + // —— 下载任意远程资源 → base64 字符串 —— async function downloadAsBase64(url) { - const res = await fetch(url); - if (!res.ok) throw new Error("下载失败 HTTP " + res.status); - const buf = Buffer.from(await res.arrayBuffer()); - return buf.toString("base64"); + const proxyUrl = systemProxyUrl(); + let proxyErr = ""; + if (proxyUrl) { + const agent = proxyAgentFor(url, proxyUrl); + if (agent) { + try { + return (await requestBuffer(url, agent)).toString("base64"); + } catch (e) { + proxyErr = String((e && e.message) || e); + // 代理下载失败时再直连兜底,保留原行为。 + } + } + } + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), 120000); + try { + const res = await fetch(url, { headers: { "User-Agent": "codex-market" }, signal: ctrl.signal }); + if (!res.ok) throw new Error("下载失败 HTTP " + res.status); + const buf = Buffer.from(await res.arrayBuffer()); + return buf.toString("base64"); + } catch (e) { + const directErr = String((e && e.message) || e); + throw new Error("下载失败:" + directErr + (proxyErr ? ";代理下载也失败:" + proxyErr : "")); + } finally { + clearTimeout(timer); + } } // —— 组装 data URL —— @@ -290,7 +400,8 @@ async function runTts(base, llm, params) { // —— 递归深挖视频地址(覆盖 result_url、data.content.video_url、data.data.content.video_url 等嵌套)—— // port 自 提示词提取/server.js function findVideoUrl(o) { - if (!o || typeof o === "string") return ""; + if (!o) return ""; + if (typeof o === "string") return /^https?:\/\//.test(o) ? o : ""; if (Array.isArray(o)) { for (const v of o) { const u = findVideoUrl(v); @@ -324,6 +435,27 @@ function joinUrl(base, p) { return trimSlash(base) + (String(p || "").startsWith("/") ? p : "/" + p); } +function pickTaskId(o) { + if (!o || typeof o !== "object") return ""; + for (const k of ["request_id", "requestId", "task_id", "taskId", "id"]) { + if (typeof o[k] === "string" && o[k]) return o[k]; + } + for (const k of ["data", "result", "task"]) { + const v = o[k]; + if (v && typeof v === "object" && !Array.isArray(v)) { + const id = pickTaskId(v); + if (id) return id; + } + } + return ""; +} + +function pushRecentPoll(arr, value) { + if (!value) return; + arr.push(String(value).slice(0, 500)); + if (arr.length > 3) arr.shift(); +} + // —— Seedance/TokenDun 视频:异步任务制(建任务 → 轮询 → 下载成品转 base64)—— // mode: "text"(文生视频,只用 prompt) | "image"(图生视频)。model 须用户填写。 // 图生视频素材两条来路:params.imageUrl(手填公网URL,优先) 或 params.image(上游 base64)。 @@ -472,6 +604,21 @@ async function runGrokVideo(base, llm, params) { } const createUrl = joinUrl(base, params.endpointPath || "/v1/videos/generations"); + const debugBase = { + base: hostOf(base), + create_url: createUrl, + mode, + request: { + model: body.model, + prompt: body.prompt, + resolution: body.resolution || "", + duration: body.duration || "", + aspect_ratio: body.aspect_ratio || "", + has_image: !!body.image, + reference_image_count: Array.isArray(body.reference_images) ? body.reference_images.length : 0, + }, + }; + grokDebugLog({ type: "grok_video_create", ...debugBase }); const cres = await fetch(createUrl, { method: "POST", headers: headers(llm.key), @@ -479,15 +626,25 @@ async function runGrokVideo(base, llm, params) { }); if (!cres.ok) { const detail = await cres.text().catch(() => ""); - return { ok: false, error: `Grok 视频建任务失败(${cres.status}):${detail.slice(0, 300)}` }; + grokDebugLog({ type: "grok_video_create_failed", ...debugBase, status: cres.status, detail: detail.slice(0, 300) }); + return { ok: false, error: `Grok 视频建任务失败(${cres.status}):POST ${createUrl} → ${detail.slice(0, 300)}` }; } - const data = await cres.json(); - const requestId = data.request_id || data.id || (data.data && (data.data.request_id || data.data.id)); + let data; + let rawCreate = ""; + try { + rawCreate = await cres.text(); + data = JSON.parse(rawCreate); + } catch { + return { ok: false, error: `Grok 视频建任务返回非 JSON:${rawCreate.slice(0, 300)}` }; + } + const requestId = pickTaskId(data); if (!requestId) return { ok: false, error: `Grok 视频建任务未返回 request_id:${JSON.stringify(data).slice(0, 300)}` }; + grokDebugLog({ type: "grok_video_accepted", ...debugBase, request_id: requestId }); const pollUrl = joinUrl(base, "/v1/videos/" + encodeURIComponent(requestId)); const pollHeaders = { Authorization: "Bearer " + (llm.key || ""), Accept: "application/json", "User-Agent": "codex-market" }; let lastPoll = ""; + const recentPolls = []; for (let i = 0; i < 120; i++) { await sleep(5000); let pd; @@ -502,21 +659,38 @@ async function runGrokVideo(base, llm, params) { lastPoll = "请求异常:" + String((e && e.message) || e); continue; } + pushRecentPoll(recentPolls, JSON.stringify(pd)); const d = pd && pd.data && typeof pd.data === "object" && !Array.isArray(pd.data) ? pd.data : pd; const st = normalizeVideoStatus((d && (d.status || d.state)) || (pd && pd.status)); lastPoll = "status=" + st; if (st === "succeeded") { const url = findVideoUrl(pd); - if (!url) return { ok: false, error: `Grok 视频已完成但未找到视频地址:${JSON.stringify(pd).slice(0, 500)}` }; + if (!url) { + lastPoll = "status=succeeded,但暂未返回视频地址"; + continue; + } const b64 = await downloadAsBase64(url); + grokDebugLog({ type: "grok_video_done", ...debugBase, request_id: requestId, status: st, url }); return { ok: true, mime: "video/mp4", dataUrl: toDataUrl("video/mp4", b64), url, _model: model }; } if (st === "failed") { const err = (d && d.error && (d.error.message || d.error)) || (d && (d.fail_reason || d.message || d.reason)) || (pd && pd.message) || st; + grokDebugLog({ type: "grok_video_failed", ...debugBase, request_id: requestId, status: st, error: typeof err === "string" ? err : JSON.stringify(err) }); return { ok: false, error: "Grok 视频任务失败:" + (typeof err === "string" ? err : JSON.stringify(err)) }; } } - return { ok: false, error: "Grok 视频轮询 10 分钟未完成(request_id=" + requestId + ",最近一次轮询:" + (lastPoll || "无") + ")" }; + return { + ok: false, + error: + "Grok 视频轮询 10 分钟未完成(GET " + + pollUrl + + ",request_id=" + + requestId + + ",最近一次轮询:" + + (lastPoll || "无") + + (recentPolls.length ? ",最近原始返回:" + recentPolls.join(" | ") : "") + + ")", + }; } catch (e) { return { ok: false, error: "Grok 视频生成异常:" + String((e && e.message) || e) }; } diff --git a/electron/lib/provider_sync.js b/electron/lib/provider_sync.js index ce0bfcc..04700a3 100644 --- a/electron/lib/provider_sync.js +++ b/electron/lib/provider_sync.js @@ -234,8 +234,12 @@ function buildThreadUpsertSql(records, target, cols) { const updateSet = []; for (const c of desired) { if (c === "id") continue; - if (c === "has_user_event") updateSet.push(`${c}=CASE WHEN excluded.${c}=1 THEN 1 ELSE threads.${c} END`); - else if (["cwd", "first_user_message", "preview", "thread_source"].includes(c)) { + // 【增强】model_provider 强制更新为目标 provider(切账号核心逻辑) + if (c === "model_provider") { + updateSet.push(`${c}=${sql(target)}`); + } else if (c === "has_user_event") { + updateSet.push(`${c}=CASE WHEN excluded.${c}=1 THEN 1 ELSE threads.${c} END`); + } else if (["cwd", "first_user_message", "preview", "thread_source"].includes(c)) { updateSet.push(`${c}=CASE WHEN COALESCE(excluded.${c},'')<>'' THEN excluded.${c} ELSE threads.${c} END`); } else if (c === "title") { updateSet.push(`${c}=CASE WHEN COALESCE(threads.${c},'')='' OR threads.${c}='(无标题对话)' THEN excluded.${c} ELSE threads.${c} END`); @@ -252,6 +256,7 @@ function buildThreadUpsertSql(records, target, cols) { `ON CONFLICT(id) DO UPDATE SET ${updateSet.join(",")};` ); } + // 【增强】确保数据库中所有会话的 provider 都更新为目标(兜底逻辑) lines.push(`UPDATE threads SET model_provider=${sql(target)} WHERE COALESCE(model_provider,'')<>${sql(target)};`); lines.push("COMMIT;"); return lines.join("\n") + "\n"; @@ -288,6 +293,59 @@ function ts() { return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`; } +// 【增强】重建 session_index.jsonl:确保所有会话都在索引中(切账号后必要) +function rebuildSessionIndex(records, log = () => {}) { + if (!records || !records.length) { + log("索引重建:无会话记录,跳过"); + return { rebuilt: 0 }; + } + // 备份旧索引 + if (fs.existsSync(SESSION_INDEX)) { + const backup = SESSION_INDEX + ".bak-" + ts(); + try { + fs.copyFileSync(SESSION_INDEX, backup); + log(`已备份旧索引 → ${path.basename(backup)}`); + } catch (e) { + log(`索引备份失败:${e.message || e}`); + } + } + // 读取现有索引,保留用户重命名的标题 + const existingTitles = {}; + try { + const lines = fs.readFileSync(SESSION_INDEX, "utf-8").split("\n"); + for (const line of lines) { + if (!line.trim()) continue; + try { + const d = JSON.parse(line); + if (d.id && d.thread_name) existingTitles[d.id] = d.thread_name; + } catch {} + } + } catch {} + // 重建索引:每个会话一行 JSON + const lines = []; + for (const r of records) { + if (!r.id) continue; + const indexEntry = { + id: r.id, + thread_name: existingTitles[r.id] || r.title || "(无标题对话)", + project_path: r.cwd || null, + created_at: new Date(r.created_at_ms).toISOString(), + source: r.source || "vscode", + archived: r.archived ? true : false, + }; + lines.push(JSON.stringify(indexEntry)); + } + // 写入新索引 + try { + fs.writeFileSync(SESSION_INDEX, lines.join("\n") + "\n", "utf-8"); + log(`索引重建完成:${lines.length} 条会话`); + return { rebuilt: lines.length }; + } catch (e) { + log(`索引重建失败:${e.message || e}`); + return { rebuilt: 0, error: e.message || String(e) }; + } +} + async function syncProviderToCurrent(log = () => {}, onProgress = () => {}) { if (codexRunning()) throw new Error("检测到 Codex 正在运行,请先完全退出 Codex 再同步。"); const target = targetProvider(); @@ -394,6 +452,11 @@ async function syncProviderToCurrent(log = () => {}, onProgress = () => {}) { } } log(`数据库更新:provider ${provRows} 行、has_user_event ${ueRows} 行、新增线程 ${insertedRows} 行、项目索引 +${projectState.addedProjects}`); + + // 【增强】4) 重建 session_index.jsonl,确保所有会话都在索引中 + onProgress({ phase: "重建索引", done: 0, total: 0 }); + const indexResult = rebuildSessionIndex(records, log); + onProgress({ phase: "完成", done: 0, total: 0 }); return { @@ -407,6 +470,7 @@ async function syncProviderToCurrent(log = () => {}, onProgress = () => {}) { insertedRows, projectRows: projectState.addedProjects, projectlessRemoved: projectState.removedProjectless, + indexRebuilt: indexResult.rebuilt || 0, backup, }; } @@ -457,6 +521,11 @@ async function syncProviderToCurrent(log = () => {}, onProgress = () => {}) { } } log(`数据库更新:provider ${provRows} 行、has_user_event ${ueRows} 行、cwd ${cwdRows} 行`); + + // 【增强】4) 重建 session_index.jsonl,确保所有会话都在索引中 + onProgress({ phase: "重建索引", done: 0, total: 0 }); + const indexResult = rebuildSessionIndex(records, log); + onProgress({ phase: "完成", done: 0, total: 0 }); return { @@ -470,6 +539,7 @@ async function syncProviderToCurrent(log = () => {}, onProgress = () => {}) { insertedRows: 0, projectRows: projectState.addedProjects, projectlessRemoved: projectState.removedProjectless, + indexRebuilt: indexResult.rebuilt || 0, backup, }; } diff --git a/electron/lib/router.js b/electron/lib/router.js index 63b5b03..2e3ea25 100644 --- a/electron/lib/router.js +++ b/electron/lib/router.js @@ -304,6 +304,16 @@ function llmGroup() { return config.groups.find(usable) || null; } +// 供「语音输入」转写用:优先当前激活且勾选 STT 的分组,否则取任一勾选 STT 的分组。 +// 需配齐 baseUrl+key 且勾了 stt;返回的 group 带 sttModel(如 speech-01 / whisper-1)。 +function sttGroup() { + load(); + const ok = (g) => g && !g.isDefault && g.baseUrl && g.key && g.stt; + const act = config.groups.find((g) => g.id === config.activeId); + if (ok(act)) return act; + return config.groups.find(ok) || null; +} + // 拉取某个中转 API 的模型列表(试 /v1/models 再 /models) async function fetchModels(baseUrl, key) { if (!baseUrl) throw new Error("请先填 API 地址"); @@ -343,6 +353,64 @@ function writeCapture(name, data) { } catch {} } +function requestTextForIntent(req) { + const parts = []; + const walk = (v) => { + if (v == null) return; + if (typeof v === "string") { + parts.push(v); + return; + } + if (Array.isArray(v)) { + for (const it of v) walk(it); + return; + } + if (typeof v === "object") { + for (const key of ["text", "content", "input", "output", "arguments"]) walk(v[key]); + } + }; + walk(req && req.input); + return parts.join("\n"); +} + +function hasExplicitGenImageIntent(req) { + return /\$gen[\s_-]*image\b/i.test(requestTextForIntent(req)); +} + +function stripGenImageSkill(instructions) { + if (typeof instructions !== "string" || !instructions.includes("gen-image")) return instructions; + return instructions + .split(/\r?\n/) + .filter((line) => !/^\s*-\s+gen-image\s*:/.test(line)) + .join("\n"); +} + +function rewriteResponsesBody(body, group) { + if (!body.length) return body; + let j; + try { + j = JSON.parse(body.toString("utf-8")); + } catch { + return body; + } + if (!j || typeof j !== "object") return body; + let changed = false; + if (group.model && "model" in j && j.model !== group.model) { + j.model = group.model; + changed = true; + } + // 普通聊天分组没有 image-generation 权限时,上游会因 gen-image skill 声明误判为生图请求。 + // 只有用户显式输入 "$gen image" 才保留该 skill,让真正生图走脚本里的图片接口分组。 + if (!hasExplicitGenImageIntent(j)) { + const nextInstructions = stripGenImageSkill(j.instructions); + if (nextInstructions !== j.instructions) { + j.instructions = nextInstructions; + changed = true; + } + } + return changed ? Buffer.from(JSON.stringify(j), "utf-8") : body; +} + function handle(req, res) { const g = activeGroup(); if (!g) { @@ -360,16 +428,7 @@ function handle(req, res) { req.on("end", () => { let body = Buffer.concat(chunks); const doCapture = /responses/.test(req.url || ""); - // 分组指定了模型 → 覆盖请求体里的 model - if (g.model && body.length) { - try { - const j = JSON.parse(body.toString("utf-8")); - if (j && typeof j === "object" && "model" in j) { - j.model = g.model; - body = Buffer.from(JSON.stringify(j), "utf-8"); - } - } catch {} - } + if (doCapture) body = rewriteResponsesBody(body, g); // —— chat 模式:Responses ⇄ Chat Completions 协议转换(DeepSeek/GLM/Kimi 等)—— if (g.mode === "chat" && /responses/.test(req.url || "")) { let responsesReq = {}; @@ -577,6 +636,8 @@ function status() { hasKey: g.isDefault ? true : !!g.key, isDefault: !!g.isDefault, codexImage: !!g.codexImage, + stt: !!g.stt, + sttModel: g.sttModel || "", })), }; } @@ -880,5 +941,6 @@ module.exports = { groupById, usableGroups, llmGroup, + sttGroup, fetchModels, }; diff --git a/electron/lib/session_sync_enhanced.js b/electron/lib/session_sync_enhanced.js new file mode 100644 index 0000000..bc30765 --- /dev/null +++ b/electron/lib/session_sync_enhanced.js @@ -0,0 +1,187 @@ +// session_sync_enhanced.js —— 增强版会话同步:确保本地所有会话一个不剩地同步到新 Codex 账号。 +// 在 provider_sync.js 的基础上增强,确保: +// 1. 扫描所有 sessions/ 和 archived_sessions/ 下的会话 +// 2. 强制插入到当前账号的数据库(UPSERT 逻辑) +// 3. 重建 session_index.jsonl +// 4. 更新全局项目索引 +// 5. 提供详细的同步报告 + +const fs = require("fs"); +const path = require("path"); +const os = require("os"); +const providerSync = require("./provider_sync"); + +const CODEX_HOME = path.join(os.homedir(), ".codex"); +const SESSION_INDEX = path.join(CODEX_HOME, "session_index.jsonl"); + +// 完整同步方案:确保所有本地会话都进入新账号 +async function syncAllSessionsToNewAccount(opts = {}) { + const log = opts.log || (() => {}); + const onProgress = opts.onProgress || (() => {}); + + log("=== 开始全量会话同步 ==="); + log("目标:将本地所有会话同步到当前 Codex 账号"); + + // 1. 检查 Codex 是否在运行 + if (providerSync.codexRunning()) { + throw new Error("检测到 Codex 正在运行,请先完全退出 Codex 再同步。"); + } + + // 2. 获取当前 provider(从 config.toml) + const targetProvider = providerSync.targetProvider(); + log(`当前账号 provider:${targetProvider}`); + + // 3. 执行核心同步(provider_sync 已经做了 UPSERT) + log("开始同步本地会话到数据库..."); + const result = await providerSync.syncProviderToCurrent(log, onProgress); + + // 4. 重建 session_index.jsonl(确保索引完整) + log("重建会话索引..."); + const indexResult = rebuildSessionIndex(log); + + // 5. 生成同步报告 + const report = { + success: true, + provider: result.target, + totalSessions: result.totalConversations, + syncedToDatabase: result.insertedRows, + updatedProvider: result.provRows, + updatedUserEvent: result.ueRows, + indexRebuilt: indexResult.total, + projects: result.projectRows, + backup: result.backup, + details: { + changedFiles: result.changedFiles, + userEventSessions: result.userEvents, + archivedSessions: indexResult.archived, + activeSessions: indexResult.active, + } + }; + + log("\n=== 同步完成 ==="); + log(`✅ 总会话数:${report.totalSessions}`); + log(`✅ 新增到数据库:${report.syncedToDatabase} 条`); + log(`✅ 更新 provider:${report.updatedProvider} 条`); + log(`✅ 重建索引:${report.indexRebuilt} 条`); + log(`✅ 项目分组:${report.projects} 个`); + log(`✅ 备份位置:${report.backup}`); + log("\n现在可以启动 Codex,所有会话应该都能看到了。"); + + return report; +} + +// 重建 session_index.jsonl:扫描所有会话文件,生成完整索引 +function rebuildSessionIndex(log = () => {}) { + const sessions = require("./sessions"); + + // 列出所有会话(包括 archived) + const allConversations = sessions.listConversations({ + includeSubagent: true, + includeArchived: true, + }); + + log(`扫描到 ${allConversations.length} 个会话文件`); + + // 备份旧索引 + if (fs.existsSync(SESSION_INDEX)) { + const backup = SESSION_INDEX + ".bak-" + Date.now(); + fs.copyFileSync(SESSION_INDEX, backup); + log(`已备份旧索引:${path.basename(backup)}`); + } + + // 重建索引:每个会话一行 JSON + const lines = []; + let active = 0; + let archived = 0; + + for (const conv of allConversations) { + if (!conv.id) continue; + + const indexEntry = { + id: conv.id, + thread_name: conv.title || "(无标题对话)", + project_path: conv.cwd || null, + created_at: conv.ts || new Date().toISOString(), + source: conv.source || "vscode", + archived: conv.archived || false, + }; + + lines.push(JSON.stringify(indexEntry)); + + if (conv.archived) archived++; + else active++; + } + + // 写入新索引 + fs.writeFileSync(SESSION_INDEX, lines.join("\n") + "\n", "utf-8"); + log(`已重建索引:活跃 ${active} 条,归档 ${archived} 条`); + + return { total: lines.length, active, archived }; +} + +// 诊断:检查会话同步状态 +function diagnoseSessionSync(log = () => {}) { + const sessions = require("./sessions"); + + log("=== 会话同步诊断 ==="); + + // 1. 统计本地会话文件 + const allConversations = sessions.listConversations({ + includeSubagent: true, + includeArchived: true, + }); + const activeSessions = allConversations.filter(c => !c.archived); + const archivedSessions = allConversations.filter(c => c.archived); + + log(`本地会话文件:${allConversations.length} 个`); + log(` - 活跃:${activeSessions.length} 个`); + log(` - 归档:${archivedSessions.length} 个`); + + // 2. 检查数据库 + const dbFiles = fs.readdirSync(CODEX_HOME).filter(f => /^state_.*\.sqlite$/.test(f)); + log(`数据库文件:${dbFiles.length} 个`); + for (const db of dbFiles) { + log(` - ${db}`); + } + + // 3. 检查索引文件 + const indexExists = fs.existsSync(SESSION_INDEX); + log(`session_index.jsonl:${indexExists ? "存在" : "不存在"}`); + + if (indexExists) { + const indexContent = fs.readFileSync(SESSION_INDEX, "utf-8"); + const indexLines = indexContent.split("\n").filter(l => l.trim()); + log(` - 索引条目:${indexLines.length} 条`); + } + + // 4. 检查 provider + const currentProvider = providerSync.targetProvider(); + log(`当前 provider:${currentProvider}`); + + // 5. 检查会话文件中的 provider 分布 + const providerStats = {}; + for (const conv of allConversations) { + const p = conv.provider || "(未设置)"; + providerStats[p] = (providerStats[p] || 0) + 1; + } + log("会话文件 provider 分布:"); + for (const [p, count] of Object.entries(providerStats)) { + log(` - ${p}: ${count} 个`); + } + + return { + totalSessions: allConversations.length, + activeSessions: activeSessions.length, + archivedSessions: archivedSessions.length, + databases: dbFiles.length, + indexExists, + currentProvider, + providerStats, + }; +} + +module.exports = { + syncAllSessionsToNewAccount, + rebuildSessionIndex, + diagnoseSessionSync, +}; diff --git a/electron/lib/voice_cdp.js b/electron/lib/voice_cdp.js new file mode 100644 index 0000000..ac3c199 --- /dev/null +++ b/electron/lib/voice_cdp.js @@ -0,0 +1,617 @@ +// voice_cdp.js —— Codex 语音输入注入:主进程侧。 +// 用 CDP(Chrome DevTools Protocol)连接带 --remote-debugging-port 的 Codex 桌面版, +// 往其页面注入麦克风按钮脚本,录音经 binding 回传 → 调中转站 Whisper 转写 → 文字回填 composer。 +// 不改 Codex 源码、不写补丁文件。无第三方依赖:CDP over WebSocket 用 Node 内置 net 手写最小 WS 客户端 +// (Electron 34 主进程的 Node 无全局 WebSocket);HTTP 用全局 fetch;multipart 用全局 FormData/Blob。 +// +// 机制参考 CodexPlusPlus 的 bridge.rs:Runtime.evaluate / Page.addScriptToEvaluateOnNewDocument / +// Runtime.addBinding + Runtime.bindingCalled。 +const { spawn, execFile } = require("child_process"); +const os = require("os"); +const path = require("path"); +const net = require("net"); +const crypto = require("crypto"); + +function dlog(...a) { try { console.log("[voice]", ...a); } catch {} } +const router = require("./router"); +const providerSync = require("./provider_sync"); +const { INJECT_SCRIPT } = require("./voice_inject"); + +const DEFAULT_PORT = 19222; // Codex 调试端口(避开 Chrome 默认 9222) +const BINDING_NAME = "__voiceBridge"; // 页面 → 主进程 的 CDP binding +const CDP_HTTP_TIMEOUT = 3000; // GET /json 超时 +const READY_POLL_INTERVAL = 500; // 轮询就绪间隔 +const READY_POLL_MAX_MS = 20000; // 轮询就绪最长等待 +const RECORD_MAX_MS = 60000; // 单次录音上限(注入脚本侧也限) + +// —— 状态机 —— +// unknown / checking / not_running / running_no_port / restarting / connecting / +// injecting / connected / disconnected / error +let state = { + phase: "unknown", + port: DEFAULT_PORT, + codexPath: "", + lastError: "", + target: null, // { id, url, title } +}; +let session = null; // 当前 CdpSession +let onStatusCb = null; + +function snapshot() { + const { phase, port, codexPath, lastError, model, target } = state; + return { phase, port, codexPath, lastError, model, target }; +} +function setPhase(phase, extra = {}) { + state = { ...state, phase, ...extra }; + if (onStatusCb) { + try { + onStatusCb(snapshot()); + } catch {} + } +} + +// —— 探测 Codex 可执行路径 —— +function codexExecutable() { + const home = os.homedir(); + if (process.platform === "darwin") { + const cands = [ + path.join(home, "Applications/ChatGPT.app/Contents/MacOS/ChatGPT"), + "/Applications/ChatGPT.app/Contents/MacOS/ChatGPT", + "/Applications/Codex.app/Contents/MacOS/Codex", + ]; + for (const p of cands) { + try { + if (require("fs").existsSync(p)) return p; + } catch {} + } + return ""; + } + if (process.platform === "win32") { + const local = process.env.LOCALAPPDATA || ""; + const pf = process.env["ProgramFiles"] || "C:\\Program Files"; + const cands = [ + local && path.join(local, "Programs\\ChatGPT\\ChatGPT.exe"), + path.join(pf, "ChatGPT\\ChatGPT.exe"), + ].filter(Boolean); + for (const p of cands) { + try { + if (require("fs").existsSync(p)) return p; + } catch {} + } + return ""; + } + return ""; +} + +// —— HTTP:GET /json 取 targets —— +async function listTargets(port) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), CDP_HTTP_TIMEOUT); + try { + const res = await fetch(`http://127.0.0.1:${port}/json`, { signal: ctrl.signal }); + if (!res.ok) return []; + const arr = await res.json(); + return Array.isArray(arr) ? arr : []; + } catch { + return []; + } finally { + clearTimeout(t); + } +} + +// —— 挑可注入的 Codex 主页面 target —— +// Codex 可能有多个 page:主对话页 app://-/index.html,以及 avatar-overlay / global-dictation +// 等带 ?initialRoute= 的子路由覆盖层。优先选「无 query 的主入口」,次选普通 codex 页,排除覆盖层。 +function pickTarget(targets) { + let main = null; // url 恰为 app://-/index.html(无 query) + let fallback = null; // 其它非覆盖层 codex 页 + for (const t of targets) { + if (t.type !== "page") continue; + const ws = t.webSocketDebuggerUrl; + if (!ws) continue; + const url = (t.url || "").toLowerCase(); + const title = (t.title || "").toLowerCase(); + // 覆盖层:带 initialRoute 的子路由(avatar-overlay / global-dictation 等) + const isOverlayRoute = url.includes("initialroute="); + const isCodex = + url.startsWith("app://-/index.html") || url.includes("index.html") || title.includes("codex"); + if (!isCodex) continue; + if (isOverlayRoute) continue; // 覆盖层不注入 + if (url === "app://-/index.html" || url === "app://-/index.html/") { + main = main || t; + } else { + fallback = fallback || t; + } + } + return main || fallback; +} + +// —— 最小 WebSocket 客户端(Node 内置 net,无需依赖;仅支持 CDP 需要的文本帧)—— +// 解析 ws://host:port/path,TCP 连接 → HTTP 升级握手(带 Sec-WebSocket-Key)→ 文本帧收发。 +// 客户端发送必须掩码;CDP 服务端发送不掩码。帧可能分片到达,用缓冲区拼。 +function wsConnect(wsUrl) { + const u = new URL(wsUrl); + const host = u.hostname; + const port = u.port || (u.protocol === "wss:" ? "443" : "80"); + const pathStr = u.pathname + u.search; + const key = crypto.randomBytes(16).toString("base64"); + + const socket = net.connect({ host, port: Number(port) }); + const events = { open: [], message: [], close: [], error: [] }; + const emit = (name, arg) => (events[name] || []).forEach((cb) => cb(arg)); + + // —— 发送一帧文本(客户端必须掩码)—— + function sendFrame(str) { + const payload = Buffer.from(str, "utf8"); + const len = payload.length; + // opcode 1 = 文本;FIN=1 + let header; + const mask = crypto.randomBytes(4); + if (len < 126) { + header = Buffer.alloc(6); + header[0] = 0x81; + header[1] = 0x80 | len; + mask.copy(header, 2); + } else if (len < 65536) { + header = Buffer.alloc(8); + header[0] = 0x81; + header[1] = 0x80 | 126; + header.writeUInt16BE(len, 2); + mask.copy(header, 4); + } else { + header = Buffer.alloc(14); + header[0] = 0x81; + header[1] = 0x80 | 127; + header.writeUInt32BE(0, 2); + header.writeUInt32BE(len, 6); + mask.copy(header, 10); + } + const masked = Buffer.alloc(len); + for (let i = 0; i < len; i++) masked[i] = payload[i] ^ mask[i & 3]; + socket.write(Buffer.concat([header, masked])); + } + + // —— 接收缓冲 + 帧解析 —— + let buf = Buffer.alloc(0); + let handshakeDone = false; + function parseFrames() { + while (true) { + if (buf.length < 2) return; + const b0 = buf[0]; + const b1 = buf[1]; + const opcode = b0 & 0x0f; + const masked = (b1 & 0x80) !== 0; + let payloadLen = b1 & 0x7f; + let offset = 2; + if (payloadLen === 126) { + if (buf.length < 4) return; + payloadLen = buf.readUInt16BE(2); + offset = 4; + } else if (payloadLen === 127) { + if (buf.length < 10) return; + // 高 32 位忽略(CDP 帧不会那么大) + payloadLen = Number(buf.readUInt32BE(6)); + offset = 10; + } + let maskKey = null; + if (masked) { + if (buf.length < offset + 4) return; + maskKey = buf.slice(offset, offset + 4); + offset += 4; + } + if (buf.length < offset + payloadLen) return; + let payload = buf.slice(offset, offset + payloadLen); + if (masked) { + const un = Buffer.alloc(payload.length); + for (let i = 0; i < payload.length; i++) un[i] = payload[i] ^ maskKey[i & 3]; + payload = un; + } + buf = buf.slice(offset + payloadLen); + // 只处理文本(1)/ping(9)/pong(10)/关闭(8) + if (opcode === 0x8) { + // 关闭帧 + emit("close"); + try { + socket.end(); + } catch {} + return; + } else if (opcode === 0x9) { + // ping → 回 pong(无掩码服务端不要求,但回时按服务端→客户端本可不掩码;此处回文本帧不规范,CDP 不发 ping) + continue; + } else if (opcode === 0xa) { + continue; // pong,忽略 + } else { + // 文本帧(含分片首帧 opcode 0 也按文本拼;CDP 单帧文本足够,简单按单帧处理) + emit("message", payload.toString("utf8")); + } + } + } + + socket.on("connect", () => { + const req = + `GET ${pathStr} HTTP/1.1\r\n` + + `Host: ${host}:${port}\r\n` + + `Upgrade: websocket\r\n` + + `Connection: Upgrade\r\n` + + `Sec-WebSocket-Key: ${key}\r\n` + + `Sec-WebSocket-Version: 13\r\n\r\n`; + socket.write(req); + }); + socket.on("data", (chunk) => { + buf = Buffer.concat([buf, chunk]); + if (!handshakeDone) { + const idx = buf.indexOf("\r\n\r\n"); + if (idx < 0) return; + const head = buf.slice(0, idx).toString("utf8"); + buf = buf.slice(idx + 4); + if (!/101/.test(head.split("\r\n")[0])) { + emit("error", new Error("WS 握手失败: " + head.split("\r\n")[0])); + try { + socket.destroy(); + } catch {} + return; + } + handshakeDone = true; + emit("open"); + parseFrames(); + } else { + parseFrames(); + } + }); + socket.on("close", () => emit("close")); + socket.on("error", (e) => emit("error", e)); + + return { + send: sendFrame, + close: () => { + try { + socket.destroy(); + } catch {} + }, + on: (name, cb) => { + (events[name] = events[name] || []).push(cb); + }, + _socket: socket, + }; +} + +// —— CDP WebSocket 会话 —— +function openSession(wsUrl, onBindingCalled) { + const pending = new Map(); // id → {resolve, reject} + const handlers = new Map(); // method → Set + let nextId = 1; + let closed = false; + const ws = wsConnect(wsUrl); + + function send(method, params = {}) { + return new Promise((resolve, reject) => { + if (closed) return reject(new Error("CDP 会话已关闭")); + const id = nextId++; + pending.set(id, { resolve, reject }); + ws.send(JSON.stringify({ id, method, params })); + }); + } + function on(method, cb) { + if (!handlers.has(method)) handlers.set(method, new Set()); + handlers.get(method).add(cb); + } + function close() { + closed = true; + try { + ws.close(); + } catch {} + } + + ws.on("message", (data) => { + let msg; + try { + msg = JSON.parse(data); + } catch { + return; + } + if (msg.id != null) { + const p = pending.get(msg.id); + if (!p) return; + pending.delete(msg.id); + if (msg.error) p.reject(new Error(msg.error.message || "CDP error")); + else p.resolve(msg.result); + return; + } + if (msg.method) { + if (msg.method === "Runtime.bindingCalled" && onBindingCalled) { + try { + onBindingCalled(msg.params); + } catch {} + } + const set = handlers.get(msg.method); + if (set) for (const cb of set) cb(msg.params); + } + }); + ws.on("close", () => { + closed = true; + for (const { reject } of pending.values()) { + try { + reject(new Error("CDP 会话关闭")); + } catch {} + } + pending.clear(); + }); + + return new Promise((resolve, reject) => { + ws.on("open", () => resolve({ send, on, close, ws })); + ws.on("error", (e) => reject(new Error("CDP 连接失败:" + (e && e.message ? e.message : "ws error")))); + }); +} + +// —— 转写:中转站 /v1/audio/transcriptions(用 sttGroup:勾选了 STT 的分组)—— +async function runStt(audioB64, mime) { + const g = router.sttGroup(); + if (!g || !g.baseUrl || !g.key) throw new Error("未配置支持语音转文字的中转分组(在路由中转里勾选「支持语音转文字」)"); + const base = (g.baseUrl || "").replace(/\/+$/, ""); + const model = g.sttModel || "whisper-1"; + const buf = Buffer.from(audioB64, "base64"); + const ext = mime && mime.includes("webm") ? "webm" : mime && mime.includes("mp4") ? "mp4" : "wav"; + const form = new FormData(); + form.append("file", new Blob([buf], { type: mime || "audio/webm" }), `voice.${ext}`); + form.append("model", model); + form.append("response_format", "json"); + const res = await fetch(base + "/v1/audio/transcriptions", { + method: "POST", + headers: { Authorization: "Bearer " + g.key }, + body: form, + }); + dlog("转写请求 →", base + "/v1/audio/transcriptions", "model=" + model, "size=" + buf.length, "HTTP " + res.status); + if (!res.ok) { + const detail = await res.text().catch(() => ""); + throw new Error(`转写接口 HTTP ${res.status}:${detail.slice(0, 300)}`); + } + const j = await res.json(); + return (j && (j.text || "")).trim(); +} + +// —— 主进程收到页面 binding 回传:处理转写并把文字送回页面 —— +async function handleBinding(params) { + const name = params && params.name; + if (name !== BINDING_NAME) return; + let payload; + try { + payload = JSON.parse(params.payload); + } catch { + return; + } + if (!payload || payload.kind !== "transcribe") return; + const { id, audioB64, mime } = payload; + dlog("binding 收到转写请求 id=" + id, "mime=" + mime, "audioB64长度=" + (audioB64?audioB64.length:0)); + setPhase("transcribing"); + let text = ""; + let err = ""; + try { + text = await runStt(audioB64, mime); + dlog("转写结果:", JSON.stringify(text).slice(0, 120)); + } catch (e) { + err = String((e && e.message) || e); + dlog("转写失败:", err); + } + setPhase(connectedPhase()); + // 把结果回调给页面 + const expr = `window.__voiceInjectHandle && window.__voiceInjectHandle(${JSON.stringify( + { id, text, err } + )})`; + try { + if (session) await session.send("Runtime.evaluate", { expression: expr, awaitPromise: false }); + } catch {} +} + +function connectedPhase() { + return session ? "connected" : "disconnected"; +} + +// —— 注入:addBinding + addScriptToEvaluateOnNewDocument + 立即 evaluate —— +async function inject(sessionObj) { + await sessionObj.send("Runtime.enable"); + try { + await sessionObj.send("Runtime.removeBinding", { name: BINDING_NAME }); + } catch {} + await sessionObj.send("Runtime.addBinding", { name: BINDING_NAME }); + await sessionObj.send("Page.enable"); + await sessionObj.send("Page.addScriptToEvaluateOnNewDocument", { source: INJECT_SCRIPT }); + await sessionObj.send("Runtime.evaluate", { expression: INJECT_SCRIPT, awaitPromise: false }); +} + +// —— 连接到已带端口的 Codex —— +async function connectToTarget(port) { + const targets = await listTargets(port); + const tgt = pickTarget(targets); + if (!tgt) return false; + state.target = { id: tgt.id, url: tgt.url, title: tgt.title }; + setPhase("connecting"); + session = await openSession(tgt.webSocketDebuggerUrl, handleBinding); + session.on("Runtime.bindingCalled", handleBinding); + session.ws.on("close", () => { + session = null; + setPhase("disconnected"); + }); + setPhase("injecting"); + await inject(session); + setPhase("connected", { target: state.target }); + return true; +} + +// —— 轮询直到出现 page target(重启后用)—— +async function waitForReady(port) { + const t0 = Date.now(); + while (Date.now() - t0 < READY_POLL_MAX_MS) { + const targets = await listTargets(port); + const tgt = pickTarget(targets); + if (tgt) return tgt; + await new Promise((r) => setTimeout(r, READY_POLL_INTERVAL)); + } + return null; +} + +// —— 启动接管:杀当前 Codex → 带端口重启 —— +function killCodex() { + return new Promise((resolve) => { + if (process.platform === "darwin") { + execFile("osascript", ["-e", 'tell application "ChatGPT" to quit'], () => { + // 兜底:osascript 没杀掉就 pkill + execFile("pgrep", ["-f", "ChatGPT.app/Contents/MacOS/ChatGPT"], (_e, out) => { + if (out && out.trim()) { + execFile("pkill", ["-f", "ChatGPT.app/Contents/MacOS/ChatGPT"], () => + setTimeout(resolve, 800) + ); + } else resolve(); + }); + }); + } else if (process.platform === "win32") { + execFile("taskkill", ["/IM", "ChatGPT.exe", "/F"], () => + setTimeout(resolve, 800) + ); + } else resolve(); + }); +} +function spawnCodex(exePath, port) { + const child = spawn(exePath, [`--remote-debugging-port=${port}`], { + detached: true, + stdio: "ignore", + }); + try { + child.unref(); + } catch {} +} + +// —— 对外:status / connect / disconnect —— +function status() { + return snapshot(); +} +function onStatus(cb) { + onStatusCb = cb; +} + +// connect(onStatusCb):流程见计划。forceRestart=true 时直接重启(前端已确认)。 +async function connect(cb, opts = {}) { + if (cb) onStatusCb = cb; + const port = opts.port || DEFAULT_PORT; + state.port = port; + setPhase("checking"); + // 1. 端口已开? + const ok = await connectToTarget(port); + if (ok) return snapshot(); + // 2. 判断 Codex 是否在跑 + const running = providerSync.codexRunning ? providerSync.codexRunning() : false; + if (!running && !opts.forceRestart) { + // 没在跑也没开端口:直接尝试拉起带端口的实例(无需杀进程) + const exe = codexExecutable(); + if (!exe) { + setPhase("error", { lastError: "未找到 Codex 可执行文件" }); + return snapshot(); + } + state.codexPath = exe; + setPhase("restarting", { codexPath: exe }); + spawnCodex(exe, port); + const tgt = await waitForReady(port); + if (!tgt) { + setPhase("error", { lastError: "Codex 启动后未开放调试端口,请重试" }); + return snapshot(); + } + const ok2 = await connectToTarget(port); + if (!ok2) setPhase("error", { lastError: "已启动但未找到可注入页面" }); + return snapshot(); + } + // 3. 在跑但没端口:需用户在前端确认后 forceRestart 重启 + if (running && !opts.forceRestart) { + state.codexPath = codexExecutable(); + setPhase("running_no_port", { codexPath: state.codexPath }); + return snapshot(); // 前端据此弹"重启确认" + } + // 4. forceRestart:杀进程 → 重启 + const exe = codexExecutable(); + if (!exe) { + setPhase("error", { lastError: "未找到 Codex 可执行文件" }); + return snapshot(); + } + state.codexPath = exe; + setPhase("restarting"); + await killCodex(); + spawnCodex(exe, port); + const tgt = await waitForReady(port); + if (!tgt) { + setPhase("error", { lastError: "重启 Codex 后未开放调试端口" }); + return snapshot(); + } + const ok3 = await connectToTarget(port); + if (!ok3) setPhase("error", { lastError: "已启动但未找到可注入页面" }); + return snapshot(); +} + +function disconnect() { + if (session) { + try { + session.close(); + } catch {} + session = null; + } + setPhase("disconnected"); + return snapshot(); +} + +// 当前语音转写配置(来自路由里勾选 STT 的分组),供前端只读展示。 +function sttInfo() { + const g = router.sttGroup(); + if (!g) return { ready: false, groupName: "", model: "" }; + return { + ready: true, + groupName: g.name || "", + model: g.sttModel || "whisper-1", + baseUrl: (g.baseUrl || "").replace(/\/+$/, ""), + }; +} + +// 诊断:用每个非默认分组的真 key 探测 /v1/audio/transcriptions 是否存在。 +// 404 = 路径不存在(不支持STT);400/401/4xx-body非404/200 = 路径存在(支持,鉴权/参数问题另算)。 +async function probeStt() { + const groups = router.usableGroups ? router.usableGroups() : []; + const out = []; + const body = Buffer.from([0x1a, 0x45, 0xdf, 0xa3]); // 假 webm 头,只为触发路径判断 + for (const g of groups) { + const base = (g.baseUrl || "").replace(/\/+$/, ""); + if (!base || !g.key) { out.push({ name: g.name, baseUrl: base, status: 0, note: "缺地址或密钥" }); continue; } + try { + const form = new FormData(); + form.append("file", new Blob([body], { type: "audio/webm" }), "v.webm"); + form.append("model", g.sttModel || "whisper-1"); + const res = await fetch(base + "/v1/audio/transcriptions", { + method: "POST", + headers: { Authorization: "Bearer " + g.key }, + body: form, + }); + const text = await res.text().catch(() => ""); + const notFound = res.status === 404 || /404 page not found|not found/i.test(text.slice(0, 60)); + out.push({ + name: g.name, + baseUrl: base, + status: res.status, + supported: !notFound, + note: text.slice(0, 120).replace(/\s+/g, " "), + }); + } catch (e) { + out.push({ name: g.name, baseUrl: base, status: 0, supported: false, note: String((e && e.message) || e).slice(0, 120) }); + } + } + return out; +} + +module.exports = { + status, + connect, + disconnect, + onStatus, + sttInfo, + probeStt, + // 供临时实测脚本复用 + _listTargets: listTargets, + _pickTarget: pickTarget, + _openSession: openSession, + _inject: inject, + DEFAULT_PORT, +}; diff --git a/electron/lib/voice_inject.js b/electron/lib/voice_inject.js new file mode 100644 index 0000000..f042b4b --- /dev/null +++ b/electron/lib/voice_inject.js @@ -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" + ? '' + : st === "transcribing" + ? '' + : ''; + btn.innerHTML = '' + icon + ''; + 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 }; diff --git a/electron/main.js b/electron/main.js index 6129ade..6e6b42a 100644 --- a/electron/main.js +++ b/electron/main.js @@ -15,6 +15,7 @@ const sessions = require("./lib/sessions"); const providerSync = require("./lib/provider_sync"); const router = require("./lib/router"); const canvas = require("./lib/canvas"); +const voice = require("./lib/voice_cdp"); const proxy = require("./lib/proxy"); const memory = require("./lib/memory"); const { extractZip } = require("./lib/zip"); @@ -729,8 +730,7 @@ function skillCatalog() { async function skillInstall(skill) { const url = skill.origin; if (!url) throw new Error("该技能没有可用的下载地址"); - const safeName = String(skill.name || "skill") - .replace(/[^\w一-龥.-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80) || "skill"; + const safeName = safeSkillDir(skill.name); const storeDir = path.join(SKILL_STORE, safeName); const codexLink = path.join(codex.CODEX_HOME, "skills", safeName); log(`下载技能 ${skill.name} …`); @@ -850,9 +850,10 @@ function readSkillMeta(skillDir, fallbackName) { return meta; } -// 把名称规整成磁盘技能目录名(与 skillInstall 的 safeName / renderer 的 safeSkillDir 同算法) +// 把名称规整成磁盘技能目录名(与 renderer 的 safeSkillDir 同算法)。统一小写,避免同一技能的大小写变体重复安装、软链名与实体名错位。 function safeSkillDir(name) { return (String(name || "skill") + .toLowerCase() .replace(/[^\w一-龥.-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80)) || "skill"; } @@ -1330,6 +1331,19 @@ ipcMain.handle("canvas-run", (e, { kind, params }) => ipcMain.handle("canvas-groups", () => canvas.groups()); ipcMain.handle("canvas-models", (_e, { groupId }) => canvas.listModels(groupId)); +// —— Codex 语音输入注入(CDP)—— +ipcMain.handle("voice-status", () => voice.status()); +ipcMain.handle("voice-connect", (e, opts) => + voice.connect((s) => { + try { + e.sender.send("voice-status", s); + } catch {} + }, opts || {}) +); +ipcMain.handle("voice-disconnect", () => voice.disconnect()); +ipcMain.handle("voice-stt-info", () => voice.sttInfo()); +ipcMain.handle("voice-probe-stt", async () => voice.probeStt()); + // —— 全局 / 系统代理 —— ipcMain.handle("proxy-status", async () => proxy.status()); ipcMain.handle("proxy-get-config", async () => proxy.getConfig()); diff --git a/electron/preload.js b/electron/preload.js index 68dabde..b040ac2 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -90,6 +90,17 @@ contextBridge.exposeInMainWorld("api", { ipcRenderer.on("canvas-progress", h); return () => ipcRenderer.removeListener("canvas-progress", h); }, + // —— Codex 语音输入注入(CDP 连 Codex,注入麦克风按钮)—— + voiceStatus: () => ipcRenderer.invoke("voice-status"), + voiceConnect: (opts) => ipcRenderer.invoke("voice-connect", opts || {}), + voiceDisconnect: () => ipcRenderer.invoke("voice-disconnect"), + voiceSttInfo: () => ipcRenderer.invoke("voice-stt-info"), + voiceProbeStt: () => ipcRenderer.invoke("voice-probe-stt"), + onVoiceStatus: (cb) => { + const h = (_e, s) => cb(s); + ipcRenderer.on("voice-status", h); + return () => ipcRenderer.removeListener("voice-status", h); + }, // —— 全局 / 系统代理 —— proxyStatus: () => ipcRenderer.invoke("proxy-status"), proxyGetConfig: () => ipcRenderer.invoke("proxy-get-config"), diff --git a/package-lock.json b/package-lock.json index 7f7fd93..b0a212f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codex-market", - "version": "1.5.10", + "version": "1.5.14", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codex-market", - "version": "1.5.10", + "version": "1.5.14", "license": "MIT", "dependencies": { "electron-updater": "^6.8.9", diff --git a/package.json b/package.json index 0a452d0..5ccfc86 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,11 @@ { "name": "codex-market", - "version": "1.5.10", + "version": "1.5.14", "description": "TD工具箱 / 技能商店 / 备份工具 —— 跨平台桌面客户端", "author": "Codex Market", "license": "MIT", "main": "electron/bootstrap.js", - "hotContentVersion": "1.5.10", + "hotContentVersion": "1.5.14", "scripts": { "start": "electron .", "dist": "electron-builder", diff --git a/renderer/app.js b/renderer/app.js index 942eb11..c3605fd 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -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, "
"); + } 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) {
-
${esc(g.name)} ${badges}${g.codexImage ? ' 🎨 生图' : ""}
+
${esc(g.name)} ${badges}${g.codexImage ? ' 🎨 生图' : ""}${g.stt ? ' 🎤 转文字' + (g.sttModel ? " " + esc(g.sttModel) : "") + '' : ""}
${esc(g.baseUrl)}${g.model ? " · 模型 " + esc(g.model) : ""}${!g.isDefault && g.mode === "chat" ? " · 🔄转换" : ""}${g.hasKey ? "" : " · ⚠️无密钥"}
${actions}
@@ -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; diff --git a/renderer/index.html b/renderer/index.html index 565ce3a..574dde2 100644 --- a/renderer/index.html +++ b/renderer/index.html @@ -102,6 +102,11 @@
自动把历史会话摘要成「长期记忆」并写入项目 HANDOFF,让 AI 跨会话记住上下文。可按项目开关,检索每次的决策 / 下一步 / 涉及文件。
+
+
🎤
语音输入
+
给 Codex 输入框注入麦克风按钮,说话即转文字填入。需由工具箱重启 Codex 一次以启用调试通道(登录态保留)。
+
+
@@ -115,6 +120,50 @@ + +
+
+
+

语音输入

+

由工具箱用 CDP 连接 Codex 桌面版,往输入框注入麦克风按钮;录音经中转站 Whisper 转文字并自动填入。

+
+
+ + 未连接 + + +
+
+
+
+
状态
+
点击「连接」开始。若 Codex 正在运行,会提示你确认重启 Codex 以启用调试端口。
+
+
+
转写分组 / 模型
+
+ 当前转写来源: + — + · + — + + +
+
+
在「路由中转」里勾选分组的「支持语音转文字」并填转写模型(如 whisper-1 / speech-01)。优先用当前激活分组,否则用任一勾选 STT 的分组。
+
+
+
使用说明
+
    +
  1. 点击「连接」。若 Codex 在运行且未带调试端口,会弹窗提示重启(当前窗口会关闭一次,登录态保留)。
  2. +
  3. 连接成功后,Codex 输入框旁出现 🎤 按钮。点击 → 授权麦克风权限 → 说话 → 再次点击停止。
  4. +
  5. 转写完成后文字自动填入输入框。首次需在「系统设置 → 隐私 → 麦克风」允许 ChatGPT。
  6. +
  7. macOS 误杀进程风险已规避:仅在确认后重启。Codex 升级后若按钮消失,可能需等待工具箱适配。
  8. +
+
+
+
+
@@ -296,6 +345,14 @@ 在 Codex 中用于生图(写入 gen-image 技能,需支持 OpenAI 图片接口) + +
默认模型(可选,填了则强制用它)
diff --git a/renderer/styles.css b/renderer/styles.css index d1e0ee7..50cf0ef 100644 --- a/renderer/styles.css +++ b/renderer/styles.css @@ -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; } diff --git a/test-sync.js b/test-sync.js new file mode 100644 index 0000000..ee171b3 --- /dev/null +++ b/test-sync.js @@ -0,0 +1,46 @@ +// test-sync.js —— 测试增强后的会话同步功能 +const providerSync = require("./electron/lib/provider_sync"); + +console.log("=== 会话同步测试 ===\n"); + +// 1. 检查 Codex 是否在运行 +console.log("1. 检查 Codex 运行状态..."); +const isRunning = providerSync.codexRunning(); +if (isRunning) { + console.error("❌ Codex 正在运行,请先完全退出 Codex(Cmd+Q 或 Quit)再测试"); + process.exit(1); +} +console.log("✅ Codex 未运行,可以同步\n"); + +// 2. 检查当前 provider +console.log("2. 检查当前 provider..."); +const targetProvider = providerSync.targetProvider(); +console.log(`✅ 当前 provider:${targetProvider}\n`); + +// 3. 执行同步 +console.log("3. 开始同步会话..."); +const log = (msg) => console.log(` ${msg}`); +const onProgress = (p) => { + if (p.phase) console.log(` [进度] ${p.phase} ${p.done || ""}/${p.total || ""}`); +}; + +providerSync.syncProviderToCurrent(log, onProgress) + .then((result) => { + console.log("\n=== 同步完成 ==="); + console.log(`✅ 目标 provider: ${result.target}`); + console.log(`✅ 总会话数: ${result.totalConversations}`); + console.log(`✅ 修改文件: ${result.changedFiles}`); + console.log(`✅ 用户消息会话: ${result.userEvents}`); + console.log(`✅ 更新 provider: ${result.provRows} 行`); + console.log(`✅ 更新 has_user_event: ${result.ueRows} 行`); + console.log(`✅ 新增线程: ${result.insertedRows} 行`); + console.log(`✅ 项目分组: ${result.projectRows} 个`); + console.log(`✅ 索引重建: ${result.indexRebuilt} 条`); + console.log(`✅ 备份位置: ${result.backup}`); + console.log("\n现在可以打开 Codex,所有会话应该都能在侧边栏看到了 🎉"); + }) + .catch((err) => { + console.error("\n❌ 同步失败:", err.message || err); + console.error(err.stack); + process.exit(1); + });