feat: enhance session sync and add voice input feature
- 增强会话同步:确保所有会话完全同步到新账号 - 强制更新 model_provider 字段 - 添加 session_index.jsonl 重建功能 - 确保数据库 UPSERT 逻辑正确处理所有情况 - 新增语音输入功能(voice_cdp.js, voice_inject.js) - 测试脚本和增强模块 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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
|
||||||
|
|
||||||
|
---
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# Feature Requests
|
||||||
|
|
||||||
|
Capabilities requested by the user.
|
||||||
|
|
||||||
|
---
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# Learnings
|
||||||
|
|
||||||
|
Corrections, insights, and knowledge gaps captured during development.
|
||||||
|
|
||||||
|
**Categories**: correction | insight | knowledge_gap | best_practice
|
||||||
|
|
||||||
|
---
|
||||||
@@ -1,36 +1,35 @@
|
|||||||
<!-- codex-memory:start -->
|
<!-- codex-memory:start -->
|
||||||
## 项目记忆(Codex 插件市场自动维护 · 回答前据此 · 勿手动编辑本段)
|
## 项目记忆(Codex 插件市场自动维护 · 回答前据此 · 勿手动编辑本段)
|
||||||
**当前状态**:已定位并修复 Codex 会话记录/项目分组同步不全问题:同步器会回填 threads 可见性与项目索引,支持 WAL,并修复 fork 会话 session_meta 覆盖导致部分会话不可见;临时数据验证 292 条线程、19 个项目分组均可恢复。用户最新要求是提交代码、推送 git 并发布热更包,尚未完成。
|
**当前状态**:用户想重开 Claude Code 会话并延续之前记录。已说明可在原项目目录用 `claude --continue`/`-c` 接最近会话,用 `claude --resume`/`-r` 或 `/resume` 手动选择历史会话;会话按项目目录保存。
|
||||||
**已定决策**:
|
**已定决策**:
|
||||||
- 不提交无关 AGENTS/HANDOFF 改动
|
- 指定历史会话用 claude --resume
|
||||||
- 不修改真实 ~/.codex 做验证
|
- 最近会话用 claude --continue
|
||||||
- 项目分组从 threads.cwd 回填
|
- 优先在原项目目录恢复会话
|
||||||
- has_user_event 用全文标记兜底
|
- 不在输出中暴露任何 API key
|
||||||
- 缺失侧栏来自 threads 索引不完整
|
- Tavily 不可用时用网页搜索兜底
|
||||||
- 备份/导入需包含 archived_sessions
|
- 不破坏 Homebrew 系统 Python
|
||||||
- session_meta 只采用首个,避免后续覆盖
|
- 日期按 2026-07-16
|
||||||
- 优先用系统 sqlite3 处理 WAL
|
- 查询 topic 设为 news
|
||||||
- 侧栏依赖 threads 与项目索引
|
- 使用 Tavily skill 搜索新闻
|
||||||
- 全量会话读取包含归档和 subagent
|
- latest兼容下载保留但设为预发布
|
||||||
- 媒体预览保留dataUrl和原始url
|
- 同时发布热更和完整包
|
||||||
|
- 非显式生图请求剥离gen-image声明
|
||||||
**下一步**:
|
**下一步**:
|
||||||
- 检查 git diff 和远端
|
- 进入原项目目录
|
||||||
- 只提交相关代码改动
|
- 运行恢复命令继续会话
|
||||||
- 推送源码仓库
|
**关键文件**:~/.claude/projects/.../*.jsonl
|
||||||
- 运行热更发布脚本
|
|
||||||
**关键文件**:/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
|
|
||||||
**历史话题(需其中细节时用下面命令检索)**:
|
**历史话题(需其中细节时用下面命令检索)**:
|
||||||
- 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 工具箱新增Grok视频与全量会话同步
|
||||||
- 2026-07-15 生成魔兽世界宣传海报
|
- 2026-07-15 生成魔兽世界宣传海报
|
||||||
- 2026-07-15 Codex skill软链改造
|
- 2026-07-15 Codex skill软链改造
|
||||||
- 2026-07-14 列出本地 skills
|
- 2026-07-14 列出本地 skills
|
||||||
- 2026-07-14 Claude CLI 中转配置排查
|
- 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` 查证再答。
|
> 回答涉及本段未列出的历史细节前,先运行 `rg -n "关键词" /Users/mac/.codex/memory/31c67c70b625.md` 查证再答。
|
||||||
<!-- codex-memory:end -->
|
<!-- codex-memory:end -->
|
||||||
|
|||||||
+39
-40
@@ -3,70 +3,69 @@
|
|||||||
|
|
||||||
> 核心记忆已写进本项目 `AGENTS.md`(Codex 每次会话自动读)。本文件是更详细的近期快照,供需要时参考;关键信息不依赖本文件。
|
> 核心记忆已写进本项目 `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
|
- 最近会话用 claude --continue
|
||||||
- 只认第一段 session_meta
|
- 指定历史会话用 claude --resume
|
||||||
- 不提交无关 AGENTS/HANDOFF 改动
|
|
||||||
|
|
||||||
**下一步**:
|
**下一步**:
|
||||||
- 检查 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
|
- 不破坏 Homebrew 系统 Python
|
||||||
- Grok多图参考不支持
|
- Tavily 不可用时用网页搜索兜底
|
||||||
- 媒体预览保留dataUrl和原始url
|
- 不在输出中暴露任何 API key
|
||||||
- 全量会话包含归档和subagent
|
|
||||||
|
|
||||||
**下一步**:
|
**下一步**:
|
||||||
- 打开应用验证全量按钮与同步流程
|
- 如需 Tavily,配置 TAVILY_API_KEY
|
||||||
- 检查Grok节点实际UI体验
|
- 用 venv 安装 tavily-python
|
||||||
- 如需可优化长任务94%提示
|
- 可继续补充分类新闻来源
|
||||||
|
|
||||||
**涉及文件**:/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。
|
||||||
|
|
||||||
**已确定的决策**:
|
**已确定的决策**:
|
||||||
- 使用原始提示词不扩写
|
- 非显式生图请求剥离gen-image声明
|
||||||
- 横版海报尺寸1536x1024
|
- 版本统一为1.5.14
|
||||||
- 通过本地gen-image脚本生成
|
- 同时发布热更和完整包
|
||||||
|
- 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 实体放工具箱仓库
|
- Grok 视频不是流式,是异步轮询
|
||||||
- Codex 侧只保留软链
|
- Grok 失败曾因成品视频直连超时
|
||||||
- 卸载需同时删除源实体
|
- 已修 Grok 轮询与代理下载逻辑
|
||||||
- openai-* 插件按系统插件保护
|
- 生图 403 来自聊天分组权限
|
||||||
- 自动迁移跳过 openai-*
|
- 推荐 $gen-image 本地直执行
|
||||||
|
|
||||||
**下一步**:
|
**下一步**:
|
||||||
- 确认 ontology 是否误删
|
- 实现 $gen-image 本地命令拦截
|
||||||
- 确认 Proactive-Agent 是否误删
|
- 普通聊天禁用生图 skill 注入
|
||||||
- 测试新装 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
|
||||||
|
|||||||
+184
-10
@@ -2,6 +2,12 @@
|
|||||||
// 统一在主进程发起 image/video/tts 请求并把成品下载转 base64 data URL 返回渲染层。
|
// 统一在主进程发起 image/video/tts 请求并把成品下载转 base64 data URL 返回渲染层。
|
||||||
// key 只在主进程流转;渲染层被 CSP 拦截的远程 URL 一律由这里下载转 base64。
|
// key 只在主进程流转;渲染层被 CSP 拦截的远程 URL 一律由这里下载转 base64。
|
||||||
const router = require("./router");
|
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 解析出主机名(失败则原样返回)——
|
// —— 从 baseUrl 解析出主机名(失败则原样返回)——
|
||||||
function hostOf(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 字符串 ——
|
// —— 下载任意远程资源 → base64 字符串 ——
|
||||||
async function downloadAsBase64(url) {
|
async function downloadAsBase64(url) {
|
||||||
const res = await fetch(url);
|
const proxyUrl = systemProxyUrl();
|
||||||
if (!res.ok) throw new Error("下载失败 HTTP " + res.status);
|
let proxyErr = "";
|
||||||
const buf = Buffer.from(await res.arrayBuffer());
|
if (proxyUrl) {
|
||||||
return buf.toString("base64");
|
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 ——
|
// —— 组装 data URL ——
|
||||||
@@ -290,7 +400,8 @@ async function runTts(base, llm, params) {
|
|||||||
// —— 递归深挖视频地址(覆盖 result_url、data.content.video_url、data.data.content.video_url 等嵌套)——
|
// —— 递归深挖视频地址(覆盖 result_url、data.content.video_url、data.data.content.video_url 等嵌套)——
|
||||||
// port 自 提示词提取/server.js
|
// port 自 提示词提取/server.js
|
||||||
function findVideoUrl(o) {
|
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)) {
|
if (Array.isArray(o)) {
|
||||||
for (const v of o) {
|
for (const v of o) {
|
||||||
const u = findVideoUrl(v);
|
const u = findVideoUrl(v);
|
||||||
@@ -324,6 +435,27 @@ function joinUrl(base, p) {
|
|||||||
return trimSlash(base) + (String(p || "").startsWith("/") ? p : "/" + 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)——
|
// —— Seedance/TokenDun 视频:异步任务制(建任务 → 轮询 → 下载成品转 base64)——
|
||||||
// mode: "text"(文生视频,只用 prompt) | "image"(图生视频)。model 须用户填写。
|
// mode: "text"(文生视频,只用 prompt) | "image"(图生视频)。model 须用户填写。
|
||||||
// 图生视频素材两条来路:params.imageUrl(手填公网URL,优先) 或 params.image(上游 base64)。
|
// 图生视频素材两条来路: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 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, {
|
const cres = await fetch(createUrl, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: headers(llm.key),
|
headers: headers(llm.key),
|
||||||
@@ -479,15 +626,25 @@ async function runGrokVideo(base, llm, params) {
|
|||||||
});
|
});
|
||||||
if (!cres.ok) {
|
if (!cres.ok) {
|
||||||
const detail = await cres.text().catch(() => "");
|
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();
|
let data;
|
||||||
const requestId = data.request_id || data.id || (data.data && (data.data.request_id || data.data.id));
|
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)}` };
|
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 pollUrl = joinUrl(base, "/v1/videos/" + encodeURIComponent(requestId));
|
||||||
const pollHeaders = { Authorization: "Bearer " + (llm.key || ""), Accept: "application/json", "User-Agent": "codex-market" };
|
const pollHeaders = { Authorization: "Bearer " + (llm.key || ""), Accept: "application/json", "User-Agent": "codex-market" };
|
||||||
let lastPoll = "";
|
let lastPoll = "";
|
||||||
|
const recentPolls = [];
|
||||||
for (let i = 0; i < 120; i++) {
|
for (let i = 0; i < 120; i++) {
|
||||||
await sleep(5000);
|
await sleep(5000);
|
||||||
let pd;
|
let pd;
|
||||||
@@ -502,21 +659,38 @@ async function runGrokVideo(base, llm, params) {
|
|||||||
lastPoll = "请求异常:" + String((e && e.message) || e);
|
lastPoll = "请求异常:" + String((e && e.message) || e);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
pushRecentPoll(recentPolls, JSON.stringify(pd));
|
||||||
const d = pd && pd.data && typeof pd.data === "object" && !Array.isArray(pd.data) ? pd.data : 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));
|
const st = normalizeVideoStatus((d && (d.status || d.state)) || (pd && pd.status));
|
||||||
lastPoll = "status=" + st;
|
lastPoll = "status=" + st;
|
||||||
if (st === "succeeded") {
|
if (st === "succeeded") {
|
||||||
const url = findVideoUrl(pd);
|
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);
|
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 };
|
return { ok: true, mime: "video/mp4", dataUrl: toDataUrl("video/mp4", b64), url, _model: model };
|
||||||
}
|
}
|
||||||
if (st === "failed") {
|
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;
|
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 视频任务失败:" + (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) {
|
} catch (e) {
|
||||||
return { ok: false, error: "Grok 视频生成异常:" + String((e && e.message) || e) };
|
return { ok: false, error: "Grok 视频生成异常:" + String((e && e.message) || e) };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -234,8 +234,12 @@ function buildThreadUpsertSql(records, target, cols) {
|
|||||||
const updateSet = [];
|
const updateSet = [];
|
||||||
for (const c of desired) {
|
for (const c of desired) {
|
||||||
if (c === "id") continue;
|
if (c === "id") continue;
|
||||||
if (c === "has_user_event") updateSet.push(`${c}=CASE WHEN excluded.${c}=1 THEN 1 ELSE threads.${c} END`);
|
// 【增强】model_provider 强制更新为目标 provider(切账号核心逻辑)
|
||||||
else if (["cwd", "first_user_message", "preview", "thread_source"].includes(c)) {
|
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`);
|
updateSet.push(`${c}=CASE WHEN COALESCE(excluded.${c},'')<>'' THEN excluded.${c} ELSE threads.${c} END`);
|
||||||
} else if (c === "title") {
|
} else if (c === "title") {
|
||||||
updateSet.push(`${c}=CASE WHEN COALESCE(threads.${c},'')='' OR threads.${c}='(无标题对话)' THEN excluded.${c} ELSE threads.${c} END`);
|
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(",")};`
|
`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(`UPDATE threads SET model_provider=${sql(target)} WHERE COALESCE(model_provider,'')<>${sql(target)};`);
|
||||||
lines.push("COMMIT;");
|
lines.push("COMMIT;");
|
||||||
return lines.join("\n") + "\n";
|
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())}`;
|
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 = () => {}) {
|
async function syncProviderToCurrent(log = () => {}, onProgress = () => {}) {
|
||||||
if (codexRunning()) throw new Error("检测到 Codex 正在运行,请先完全退出 Codex 再同步。");
|
if (codexRunning()) throw new Error("检测到 Codex 正在运行,请先完全退出 Codex 再同步。");
|
||||||
const target = targetProvider();
|
const target = targetProvider();
|
||||||
@@ -394,6 +452,11 @@ async function syncProviderToCurrent(log = () => {}, onProgress = () => {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
log(`数据库更新:provider ${provRows} 行、has_user_event ${ueRows} 行、新增线程 ${insertedRows} 行、项目索引 +${projectState.addedProjects}`);
|
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 });
|
onProgress({ phase: "完成", done: 0, total: 0 });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -407,6 +470,7 @@ async function syncProviderToCurrent(log = () => {}, onProgress = () => {}) {
|
|||||||
insertedRows,
|
insertedRows,
|
||||||
projectRows: projectState.addedProjects,
|
projectRows: projectState.addedProjects,
|
||||||
projectlessRemoved: projectState.removedProjectless,
|
projectlessRemoved: projectState.removedProjectless,
|
||||||
|
indexRebuilt: indexResult.rebuilt || 0,
|
||||||
backup,
|
backup,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -457,6 +521,11 @@ async function syncProviderToCurrent(log = () => {}, onProgress = () => {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
log(`数据库更新:provider ${provRows} 行、has_user_event ${ueRows} 行、cwd ${cwdRows} 行`);
|
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 });
|
onProgress({ phase: "完成", done: 0, total: 0 });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -470,6 +539,7 @@ async function syncProviderToCurrent(log = () => {}, onProgress = () => {}) {
|
|||||||
insertedRows: 0,
|
insertedRows: 0,
|
||||||
projectRows: projectState.addedProjects,
|
projectRows: projectState.addedProjects,
|
||||||
projectlessRemoved: projectState.removedProjectless,
|
projectlessRemoved: projectState.removedProjectless,
|
||||||
|
indexRebuilt: indexResult.rebuilt || 0,
|
||||||
backup,
|
backup,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+72
-10
@@ -304,6 +304,16 @@ function llmGroup() {
|
|||||||
return config.groups.find(usable) || null;
|
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)
|
// 拉取某个中转 API 的模型列表(试 /v1/models 再 /models)
|
||||||
async function fetchModels(baseUrl, key) {
|
async function fetchModels(baseUrl, key) {
|
||||||
if (!baseUrl) throw new Error("请先填 API 地址");
|
if (!baseUrl) throw new Error("请先填 API 地址");
|
||||||
@@ -343,6 +353,64 @@ function writeCapture(name, data) {
|
|||||||
} catch {}
|
} 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) {
|
function handle(req, res) {
|
||||||
const g = activeGroup();
|
const g = activeGroup();
|
||||||
if (!g) {
|
if (!g) {
|
||||||
@@ -360,16 +428,7 @@ function handle(req, res) {
|
|||||||
req.on("end", () => {
|
req.on("end", () => {
|
||||||
let body = Buffer.concat(chunks);
|
let body = Buffer.concat(chunks);
|
||||||
const doCapture = /responses/.test(req.url || "");
|
const doCapture = /responses/.test(req.url || "");
|
||||||
// 分组指定了模型 → 覆盖请求体里的 model
|
if (doCapture) body = rewriteResponsesBody(body, g);
|
||||||
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 {}
|
|
||||||
}
|
|
||||||
// —— chat 模式:Responses ⇄ Chat Completions 协议转换(DeepSeek/GLM/Kimi 等)——
|
// —— chat 模式:Responses ⇄ Chat Completions 协议转换(DeepSeek/GLM/Kimi 等)——
|
||||||
if (g.mode === "chat" && /responses/.test(req.url || "")) {
|
if (g.mode === "chat" && /responses/.test(req.url || "")) {
|
||||||
let responsesReq = {};
|
let responsesReq = {};
|
||||||
@@ -577,6 +636,8 @@ function status() {
|
|||||||
hasKey: g.isDefault ? true : !!g.key,
|
hasKey: g.isDefault ? true : !!g.key,
|
||||||
isDefault: !!g.isDefault,
|
isDefault: !!g.isDefault,
|
||||||
codexImage: !!g.codexImage,
|
codexImage: !!g.codexImage,
|
||||||
|
stt: !!g.stt,
|
||||||
|
sttModel: g.sttModel || "",
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -880,5 +941,6 @@ module.exports = {
|
|||||||
groupById,
|
groupById,
|
||||||
usableGroups,
|
usableGroups,
|
||||||
llmGroup,
|
llmGroup,
|
||||||
|
sttGroup,
|
||||||
fetchModels,
|
fetchModels,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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<cb>
|
||||||
|
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,
|
||||||
|
};
|
||||||
@@ -0,0 +1,350 @@
|
|||||||
|
// voice_inject.js —— 注入到 Codex 页面(app://-/index.html)的脚本,以字符串形式导出。
|
||||||
|
// 由 voice_cdp.js 经 CDP Page.addScriptToEvaluateOnNewDocument(常驻) + Runtime.evaluate(立即) 注入。
|
||||||
|
// 职责:在 .composer-footer 插麦克风按钮;点击录音→MediaRecorder 出 Blob→base64 经
|
||||||
|
// window.__voiceBridge(CDP binding) 回传主进程转写→回传文字→execCommand('insertText') 写回 composer。
|
||||||
|
// 写回手法参考 CodexPlusPlus 粘贴修复(renderer-inject.js 约第 8952 行):document.execCommand('insertText', false, text)。
|
||||||
|
|
||||||
|
// 注意:脚本体用普通模板字符串包裹;内部避免出现 ${(已无)与裸反引号(内部的模板字符串反引号需转义为 \`)。
|
||||||
|
const INJECT_SCRIPT = `
|
||||||
|
(() => {
|
||||||
|
const VER = "voice-inject-v6";
|
||||||
|
if (window.__voiceInjectInstalled === VER) return;
|
||||||
|
// 版本变了或首次:清理旧按钮/旧 observer,重新安装
|
||||||
|
if (window.__voiceInjectObserver) { try { window.__voiceInjectObserver.disconnect(); } catch {} }
|
||||||
|
const oldBtn = document.getElementById("codex-voice-input-btn");
|
||||||
|
if (oldBtn) oldBtn.remove();
|
||||||
|
window.__voiceInjectInstalled = VER;
|
||||||
|
|
||||||
|
const TAG = "[VoiceInput]";
|
||||||
|
const BTN_ID = "codex-voice-input-btn";
|
||||||
|
const STYLE_ID = "codex-voice-input-style-v6";
|
||||||
|
|
||||||
|
// —— 样式 ——
|
||||||
|
if (!document.getElementById(STYLE_ID)) {
|
||||||
|
const st = document.createElement("style");
|
||||||
|
st.id = STYLE_ID;
|
||||||
|
st.textContent = \`
|
||||||
|
.codex-voice-input-btn{
|
||||||
|
display:inline-flex;align-items:center;justify-content:center;flex:none;
|
||||||
|
width:32px;height:32px;padding:0;border:0;border-radius:999px;
|
||||||
|
background:rgba(17,24,39,.04);color:var(--text-secondary,#6b7280);
|
||||||
|
cursor:pointer;line-height:1;transition:background .15s,color .15s,box-shadow .15s,transform .15s;
|
||||||
|
-webkit-app-region:no-drag;
|
||||||
|
}
|
||||||
|
.codex-voice-input-btn:hover{
|
||||||
|
background:rgba(17,24,39,.08);color:var(--text-primary,#111827);
|
||||||
|
box-shadow:0 1px 4px rgba(15,23,42,.08);
|
||||||
|
}
|
||||||
|
.codex-voice-input-btn:active{transform:scale(.96);}
|
||||||
|
.codex-voice-input-btn:focus-visible{outline:2px solid rgba(59,130,246,.55);outline-offset:2px;}
|
||||||
|
.codex-voice-input-btn .cv-ico{width:17px;height:17px;display:block;}
|
||||||
|
.codex-voice-input-btn .cv-ico svg{width:100%;height:100%;display:block;stroke:currentColor;}
|
||||||
|
.codex-voice-input-btn[data-state="recording"]{
|
||||||
|
background:rgba(239,68,68,.12);color:#ef4444;
|
||||||
|
animation:codexVoicePulse 1.1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.codex-voice-input-btn[data-state="transcribing"]{background:rgba(59,130,246,.1);color:#2563eb;cursor:wait;}
|
||||||
|
.codex-voice-input-btn[data-state="transcribing"] .cv-ico{animation:codexVoiceSpin .8s linear infinite;}
|
||||||
|
.codex-voice-input-btn[data-state="error"]{background:rgba(239,68,68,.1);color:#dc2626;}
|
||||||
|
@media (prefers-color-scheme:dark){
|
||||||
|
.codex-voice-input-btn{background:rgba(255,255,255,.05);color:var(--text-secondary,#a1a1aa);}
|
||||||
|
.codex-voice-input-btn:hover{background:rgba(255,255,255,.1);color:var(--text-primary,#f4f4f5);box-shadow:none;}
|
||||||
|
}
|
||||||
|
@keyframes codexVoicePulse{0%,100%{box-shadow:0 0 0 0 rgba(239,68,68,.42)}50%{box-shadow:0 0 0 7px rgba(239,68,68,0)}}
|
||||||
|
@keyframes codexVoiceSpin{to{transform:rotate(360deg)}}
|
||||||
|
.codex-voice-toast{
|
||||||
|
position:fixed;left:50%;bottom:96px;transform:translateX(-50%);
|
||||||
|
background:rgba(24,24,27,.96);color:#f3f4f6;padding:9px 16px;border-radius:10px;
|
||||||
|
font:13px system-ui,sans-serif;z-index:999999;box-shadow:0 8px 30px rgba(0,0,0,.5);
|
||||||
|
max-width:80vw;
|
||||||
|
}
|
||||||
|
\`;
|
||||||
|
(document.head || document.documentElement).appendChild(st);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toast(msg, ms = 2400) {
|
||||||
|
const t = document.createElement("div");
|
||||||
|
t.className = "codex-voice-toast";
|
||||||
|
t.textContent = msg;
|
||||||
|
document.body.appendChild(t);
|
||||||
|
setTimeout(() => t.remove(), ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
// —— binding 通道(window.__voiceBridge 由主进程 CDP addBinding 注入)——
|
||||||
|
const pending = new Map();
|
||||||
|
let seq = 0;
|
||||||
|
window.__voiceInjectHandle = (msg) => {
|
||||||
|
let p;
|
||||||
|
try {
|
||||||
|
p = typeof msg === "string" ? JSON.parse(msg) : msg;
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const entry = pending.get(p.id);
|
||||||
|
if (!entry) return;
|
||||||
|
pending.delete(p.id);
|
||||||
|
if (p.err) entry.reject(new Error(p.err));
|
||||||
|
else entry.resolve(p.text || "");
|
||||||
|
};
|
||||||
|
function sendToBackend(audioB64, mime) {
|
||||||
|
const id = String(++seq);
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
pending.set(id, { resolve, reject });
|
||||||
|
if (typeof window.__voiceBridge !== "function") {
|
||||||
|
pending.delete(id);
|
||||||
|
reject(new Error("语音通道未就绪,请重新连接工具箱"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
window.__voiceBridge(JSON.stringify({ id, kind: "transcribe", audioB64, mime }));
|
||||||
|
} catch (e) {
|
||||||
|
pending.delete(id);
|
||||||
|
reject(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// —— 找底部输入工具栏:从编辑器往上找含 footer 的工具行容器 ——
|
||||||
|
function findToolbar() {
|
||||||
|
const rich = document.querySelector("DIV.ProseMirror, [contenteditable], textarea");
|
||||||
|
if (!rich) return null;
|
||||||
|
let p = rich;
|
||||||
|
for (let i = 0; i < 6 && p; i++) {
|
||||||
|
const foot = p.querySelector("[class*='_footer_'], [class*='composer-footer'], footer.composer, [class*='footer'][class*='composer']");
|
||||||
|
if (foot && foot.querySelectorAll("button, [role='button']").length >= 2) return foot;
|
||||||
|
p = p.parentElement;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
function findEditor() {
|
||||||
|
return (
|
||||||
|
document.querySelector("DIV.ProseMirror") ||
|
||||||
|
document.querySelector("[contenteditable='true'],[contenteditable='']") ||
|
||||||
|
document.querySelector("textarea")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// —— 录音 ——
|
||||||
|
let mediaRecorder = null;
|
||||||
|
let chunks = [];
|
||||||
|
let curBtn = null;
|
||||||
|
let stopTimer = null;
|
||||||
|
|
||||||
|
function setBtnState(btn, st) {
|
||||||
|
if (!btn) return;
|
||||||
|
btn.dataset.state = st || "";
|
||||||
|
const label =
|
||||||
|
st === "recording" ? "停止录音" :
|
||||||
|
st === "transcribing" ? "转写中…" :
|
||||||
|
st === "error" ? "语音输入出错" :
|
||||||
|
"语音输入";
|
||||||
|
const icon =
|
||||||
|
st === "recording"
|
||||||
|
? '<svg viewBox="0 0 24 24" fill="none" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="8" y="8" width="8" height="8" rx="1.5"></rect></svg>'
|
||||||
|
: st === "transcribing"
|
||||||
|
? '<svg viewBox="0 0 24 24" fill="none" stroke-width="2.3" stroke-linecap="round" aria-hidden="true"><path d="M12 3a9 9 0 1 1-8.2 5.3"></path></svg>'
|
||||||
|
: '<svg viewBox="0 0 24 24" fill="none" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3a3 3 0 0 0-3 3v5a3 3 0 0 0 6 0V6a3 3 0 0 0-3-3Z"></path><path d="M19 10v1a7 7 0 0 1-14 0v-1"></path><path d="M12 18v3"></path><path d="M8 21h8"></path></svg>';
|
||||||
|
btn.innerHTML = '<span class="cv-ico">' + icon + '</span>';
|
||||||
|
btn.title = label;
|
||||||
|
btn.setAttribute("aria-label", label);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startRecording(btn) {
|
||||||
|
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
||||||
|
toast("此环境不支持麦克风");
|
||||||
|
setBtnState(btn, "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let stream;
|
||||||
|
try {
|
||||||
|
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||||
|
} catch (e) {
|
||||||
|
const name = (e && e.name) || "";
|
||||||
|
if (name === "NotAllowedError" || name === "SecurityError") {
|
||||||
|
toast("麦克风被拒绝:系统设置 → 隐私 → 麦克风 → 允许 ChatGPT", 4200);
|
||||||
|
} else {
|
||||||
|
toast("无法访问麦克风:" + (e && e.message ? e.message : name));
|
||||||
|
}
|
||||||
|
setBtnState(btn, "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
curBtn = btn;
|
||||||
|
chunks = [];
|
||||||
|
const mime = MediaRecorder.isTypeSupported("audio/webm")
|
||||||
|
? "audio/webm"
|
||||||
|
: MediaRecorder.isTypeSupported("audio/mp4")
|
||||||
|
? "audio/mp4"
|
||||||
|
: "";
|
||||||
|
try {
|
||||||
|
mediaRecorder = new MediaRecorder(stream, mime ? { mimeType: mime } : undefined);
|
||||||
|
} catch (e) {
|
||||||
|
toast("录音器初始化失败");
|
||||||
|
setBtnState(btn, "error");
|
||||||
|
stream.getTracks().forEach((t) => t.stop());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
mediaRecorder.ondataavailable = (e) => {
|
||||||
|
if (e.data && e.data.size) chunks.push(e.data);
|
||||||
|
};
|
||||||
|
mediaRecorder.onstop = () => {
|
||||||
|
stream.getTracks().forEach((t) => t.stop());
|
||||||
|
const blob = new Blob(chunks, { type: mime || "audio/webm" });
|
||||||
|
if (blob.size < 800) {
|
||||||
|
toast("未录到声音");
|
||||||
|
setBtnState(btn, "");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBtnState(btn, "transcribing");
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = async () => {
|
||||||
|
const dataUrl = String(reader.result || "");
|
||||||
|
const comma = dataUrl.indexOf(",");
|
||||||
|
const b64 = comma >= 0 ? dataUrl.slice(comma + 1) : dataUrl;
|
||||||
|
try {
|
||||||
|
const text = await sendToBackend(b64, mime || "audio/webm");
|
||||||
|
if (text) writeBack(text);
|
||||||
|
else toast("转写结果为空");
|
||||||
|
} catch (err) {
|
||||||
|
toast("转写失败:" + (err && err.message ? err.message : err), 4200);
|
||||||
|
setBtnState(btn, "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBtnState(btn, "");
|
||||||
|
};
|
||||||
|
reader.onerror = () => {
|
||||||
|
toast("音频读取失败");
|
||||||
|
setBtnState(btn, "error");
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(blob);
|
||||||
|
};
|
||||||
|
mediaRecorder.start();
|
||||||
|
setBtnState(btn, "recording");
|
||||||
|
stopTimer = setTimeout(() => stopRecording(), 60000);
|
||||||
|
}
|
||||||
|
function stopRecording() {
|
||||||
|
if (stopTimer) {
|
||||||
|
clearTimeout(stopTimer);
|
||||||
|
stopTimer = null;
|
||||||
|
}
|
||||||
|
if (mediaRecorder && mediaRecorder.state !== "inactive") {
|
||||||
|
try {
|
||||||
|
mediaRecorder.stop();
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeBack(text) {
|
||||||
|
const ed = findEditor();
|
||||||
|
if (!ed) {
|
||||||
|
toast("未找到输入框,文字已无法填入", 3600);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
ed.focus();
|
||||||
|
} catch {}
|
||||||
|
const inserted = document.execCommand("insertText", false, text);
|
||||||
|
if (!inserted) {
|
||||||
|
// 兜底:textarea / input 用 setRangeText
|
||||||
|
if (ed.tagName === "TEXTAREA" || ed.tagName === "INPUT") {
|
||||||
|
const s = ed.selectionStart || 0;
|
||||||
|
const e = ed.selectionEnd || 0;
|
||||||
|
const v = ed.value || "";
|
||||||
|
ed.value = v.slice(0, s) + text + v.slice(e);
|
||||||
|
const pos = s + text.length;
|
||||||
|
try {
|
||||||
|
ed.setSelectionRange(pos, pos);
|
||||||
|
} catch {}
|
||||||
|
ed.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
|
} else {
|
||||||
|
// contenteditable 兜底
|
||||||
|
try {
|
||||||
|
const sel = window.getSelection();
|
||||||
|
const range = sel && sel.rangeCount ? sel.getRangeAt(0) : null;
|
||||||
|
if (range) {
|
||||||
|
range.deleteContents();
|
||||||
|
range.insertNode(document.createTextNode(text));
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onBtnClick(btn) {
|
||||||
|
if (mediaRecorder && mediaRecorder.state === "recording") {
|
||||||
|
stopRecording();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
startRecording(btn);
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeButton() {
|
||||||
|
const b = document.createElement("button");
|
||||||
|
b.type = "button";
|
||||||
|
b.id = BTN_ID;
|
||||||
|
b.className = "codex-voice-input-btn";
|
||||||
|
b.setAttribute("aria-label", "语音输入");
|
||||||
|
b.addEventListener("click", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
onBtnClick(b);
|
||||||
|
});
|
||||||
|
setBtnState(b, "");
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 在工具栏里找「发送按钮」:默认取工具栏最后一个 button(Codex 里发送在最右)。
|
||||||
|
// 新版 Codex 会把模型选择和发送按钮包在右侧 action 容器里,语音按钮要挂到
|
||||||
|
// 发送按钮的直接父容器,才能落在「模型选择」与「发送」之间。
|
||||||
|
function findSendButton(toolbar) {
|
||||||
|
const btns = toolbar.querySelectorAll("button, [role='button']");
|
||||||
|
if (!btns || !btns.length) return null;
|
||||||
|
return btns[btns.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
function containsNode(parent, child) {
|
||||||
|
return !!(parent && child && (parent === child || parent.contains(child)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let firstMount = true;
|
||||||
|
function tryMount() {
|
||||||
|
if (!firstMount && document.getElementById(BTN_ID)) return; // 已在,避免频繁重建丢焦点
|
||||||
|
firstMount = false;
|
||||||
|
const existing = document.getElementById(BTN_ID);
|
||||||
|
if (existing) existing.remove(); // 重新注入时刷新一次,确保新样式/位置
|
||||||
|
const toolbar = findToolbar();
|
||||||
|
if (!toolbar) { firstMount = true; return; } // 工具栏还没出来,下次再试
|
||||||
|
const sendBtn = findSendButton(toolbar);
|
||||||
|
const btn = makeButton();
|
||||||
|
const sendParent = sendBtn && sendBtn.parentElement;
|
||||||
|
if (sendParent && containsNode(toolbar, sendParent)) {
|
||||||
|
sendParent.insertBefore(btn, sendBtn); // 插在发送按钮前,保持在右侧 action 组内
|
||||||
|
} else {
|
||||||
|
toolbar.appendChild(btn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// —— MutationObserver:composer/页面重建后重挂 ——
|
||||||
|
let mo = null;
|
||||||
|
function startObserver() {
|
||||||
|
if (mo) return;
|
||||||
|
mo = new MutationObserver(() => {
|
||||||
|
if (!document.getElementById(BTN_ID)) { firstMount = true; tryMount(); }
|
||||||
|
});
|
||||||
|
window.__voiceInjectObserver = mo; // 供重新安装时 disconnect
|
||||||
|
const root = document.body || document.documentElement;
|
||||||
|
if (root) mo.observe(root, { childList: true, subtree: true });
|
||||||
|
// 首次尝试
|
||||||
|
tryMount();
|
||||||
|
}
|
||||||
|
function ensureStarted() {
|
||||||
|
if (document.readyState === "loading") {
|
||||||
|
document.addEventListener("DOMContentLoaded", startObserver, { once: true });
|
||||||
|
} else {
|
||||||
|
startObserver();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ensureStarted();
|
||||||
|
})();
|
||||||
|
`;
|
||||||
|
|
||||||
|
module.exports = { INJECT_SCRIPT };
|
||||||
+17
-3
@@ -15,6 +15,7 @@ const sessions = require("./lib/sessions");
|
|||||||
const providerSync = require("./lib/provider_sync");
|
const providerSync = require("./lib/provider_sync");
|
||||||
const router = require("./lib/router");
|
const router = require("./lib/router");
|
||||||
const canvas = require("./lib/canvas");
|
const canvas = require("./lib/canvas");
|
||||||
|
const voice = require("./lib/voice_cdp");
|
||||||
const proxy = require("./lib/proxy");
|
const proxy = require("./lib/proxy");
|
||||||
const memory = require("./lib/memory");
|
const memory = require("./lib/memory");
|
||||||
const { extractZip } = require("./lib/zip");
|
const { extractZip } = require("./lib/zip");
|
||||||
@@ -729,8 +730,7 @@ function skillCatalog() {
|
|||||||
async function skillInstall(skill) {
|
async function skillInstall(skill) {
|
||||||
const url = skill.origin;
|
const url = skill.origin;
|
||||||
if (!url) throw new Error("该技能没有可用的下载地址");
|
if (!url) throw new Error("该技能没有可用的下载地址");
|
||||||
const safeName = String(skill.name || "skill")
|
const safeName = safeSkillDir(skill.name);
|
||||||
.replace(/[^\w一-龥.-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80) || "skill";
|
|
||||||
const storeDir = path.join(SKILL_STORE, safeName);
|
const storeDir = path.join(SKILL_STORE, safeName);
|
||||||
const codexLink = path.join(codex.CODEX_HOME, "skills", safeName);
|
const codexLink = path.join(codex.CODEX_HOME, "skills", safeName);
|
||||||
log(`下载技能 ${skill.name} …`);
|
log(`下载技能 ${skill.name} …`);
|
||||||
@@ -850,9 +850,10 @@ function readSkillMeta(skillDir, fallbackName) {
|
|||||||
return meta;
|
return meta;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 把名称规整成磁盘技能目录名(与 skillInstall 的 safeName / renderer 的 safeSkillDir 同算法)
|
// 把名称规整成磁盘技能目录名(与 renderer 的 safeSkillDir 同算法)。统一小写,避免同一技能的大小写变体重复安装、软链名与实体名错位。
|
||||||
function safeSkillDir(name) {
|
function safeSkillDir(name) {
|
||||||
return (String(name || "skill")
|
return (String(name || "skill")
|
||||||
|
.toLowerCase()
|
||||||
.replace(/[^\w一-龥.-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80)) || "skill";
|
.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-groups", () => canvas.groups());
|
||||||
ipcMain.handle("canvas-models", (_e, { groupId }) => canvas.listModels(groupId));
|
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-status", async () => proxy.status());
|
||||||
ipcMain.handle("proxy-get-config", async () => proxy.getConfig());
|
ipcMain.handle("proxy-get-config", async () => proxy.getConfig());
|
||||||
|
|||||||
@@ -90,6 +90,17 @@ contextBridge.exposeInMainWorld("api", {
|
|||||||
ipcRenderer.on("canvas-progress", h);
|
ipcRenderer.on("canvas-progress", h);
|
||||||
return () => ipcRenderer.removeListener("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"),
|
proxyStatus: () => ipcRenderer.invoke("proxy-status"),
|
||||||
proxyGetConfig: () => ipcRenderer.invoke("proxy-get-config"),
|
proxyGetConfig: () => ipcRenderer.invoke("proxy-get-config"),
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "codex-market",
|
"name": "codex-market",
|
||||||
"version": "1.5.10",
|
"version": "1.5.14",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "codex-market",
|
"name": "codex-market",
|
||||||
"version": "1.5.10",
|
"version": "1.5.14",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"electron-updater": "^6.8.9",
|
"electron-updater": "^6.8.9",
|
||||||
|
|||||||
+2
-2
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "codex-market",
|
"name": "codex-market",
|
||||||
"version": "1.5.10",
|
"version": "1.5.14",
|
||||||
"description": "TD工具箱 / 技能商店 / 备份工具 —— 跨平台桌面客户端",
|
"description": "TD工具箱 / 技能商店 / 备份工具 —— 跨平台桌面客户端",
|
||||||
"author": "Codex Market",
|
"author": "Codex Market",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"main": "electron/bootstrap.js",
|
"main": "electron/bootstrap.js",
|
||||||
"hotContentVersion": "1.5.10",
|
"hotContentVersion": "1.5.14",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "electron .",
|
"start": "electron .",
|
||||||
"dist": "electron-builder",
|
"dist": "electron-builder",
|
||||||
|
|||||||
+142
-2
@@ -71,6 +71,7 @@ $$("[data-open-panel]").forEach((btn) => {
|
|||||||
activatePanel(name);
|
activatePanel(name);
|
||||||
if (name === "sessions" && !sessLoaded) sessLoad();
|
if (name === "sessions" && !sessLoaded) sessLoad();
|
||||||
if (name === "memory" && !memLoaded) memLoad();
|
if (name === "memory" && !memLoaded) memLoad();
|
||||||
|
if (name === "voice" && !voiceLoaded) voiceLoad();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
// 内置工具面板「← 返回插件市场」
|
// 内置工具面板「← 返回插件市场」
|
||||||
@@ -98,6 +99,140 @@ apic.onLog((msg) => {
|
|||||||
$("#log-panel").scrollTop = $("#log-panel").scrollHeight;
|
$("#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 installedSkills = []; // 磁盘实况(~/.codex/skills 扫描结果)
|
||||||
let installedDirs = new Set(); // 已安装技能的目录名,用于精确标注“已安装”
|
let installedDirs = new Set(); // 已安装技能的目录名,用于精确标注“已安装”
|
||||||
|
|
||||||
// 与主进程 skillInstall 的目录命名保持一致(安装到 ~/.codex/skills/<此名>)
|
// 与主进程 skillInstall 的目录命名保持一致(安装到 ~/.codex/skills/<此名>)。统一小写,避免大小写变体导致"已安装"判断错位。
|
||||||
function safeSkillDir(name) {
|
function safeSkillDir(name) {
|
||||||
return (String(name || "skill")
|
return (String(name || "skill")
|
||||||
|
.toLowerCase()
|
||||||
.replace(/[^\w一-龥.-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80)) || "skill";
|
.replace(/[^\w一-龥.-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80)) || "skill";
|
||||||
}
|
}
|
||||||
async function loadInstalledSkills() {
|
async function loadInstalledSkills() {
|
||||||
@@ -1187,7 +1323,7 @@ function renderRouter(st) {
|
|||||||
<div class="cl-item">
|
<div class="cl-item">
|
||||||
<input type="radio" name="active-grp" ${g.id === st.activeId ? "checked" : ""} data-active="${g.id}" />
|
<input type="radio" name="active-grp" ${g.id === st.activeId ? "checked" : ""} data-active="${g.id}" />
|
||||||
<div>
|
<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 class="cl-src">${esc(g.baseUrl)}${g.model ? " · 模型 " + esc(g.model) : ""}${!g.isDefault && g.mode === "chat" ? " · 🔄转换" : ""}${g.hasKey ? "" : " · ⚠️无密钥"}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="btn-group" style="margin-left:auto">${actions}</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-key").placeholder = g && g.hasKey ? "(已保存,留空则不修改)" : "sk-…";
|
||||||
$("#grp-model").value = g ? g.model || "" : "";
|
$("#grp-model").value = g ? g.model || "" : "";
|
||||||
$("#grp-codex-image").checked = !!(g && g.codexImage);
|
$("#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 : "";
|
$("#grp-ctx").value = g && g.contextWindow ? g.contextWindow : "";
|
||||||
const sel = $("#grp-model-list");
|
const sel = $("#grp-model-list");
|
||||||
sel.classList.add("hidden");
|
sel.classList.add("hidden");
|
||||||
@@ -1266,6 +1404,8 @@ $("#grp-save").addEventListener("click", async () => {
|
|||||||
mode: $("#grp-mode").value,
|
mode: $("#grp-mode").value,
|
||||||
contextWindow: $("#grp-ctx").value.trim(),
|
contextWindow: $("#grp-ctx").value.trim(),
|
||||||
codexImage: $("#grp-codex-image").checked,
|
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;
|
if (grpEditing) group.id = grpEditing.id;
|
||||||
const key = $("#grp-key").value;
|
const key = $("#grp-key").value;
|
||||||
|
|||||||
@@ -102,6 +102,11 @@
|
|||||||
<div class="tool-desc">自动把历史会话摘要成「长期记忆」并写入项目 HANDOFF,让 AI 跨会话记住上下文。可按项目开关,检索每次的决策 / 下一步 / 涉及文件。</div>
|
<div class="tool-desc">自动把历史会话摘要成「长期记忆」并写入项目 HANDOFF,让 AI 跨会话记住上下文。可按项目开关,检索每次的决策 / 下一步 / 涉及文件。</div>
|
||||||
<div class="tool-foot"><button class="btn primary small" data-open-panel="memory">打开</button></div>
|
<div class="tool-foot"><button class="btn primary small" data-open-panel="memory">打开</button></div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
<!-- 已安装插件 -->
|
<!-- 已安装插件 -->
|
||||||
@@ -115,6 +120,50 @@
|
|||||||
<div id="plugin-empty" class="empty hidden"></div>
|
<div id="plugin-empty" class="empty hidden"></div>
|
||||||
</section>
|
</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 节点画布) -->
|
<!-- ②.5 创意画布(Drawflow 节点画布) -->
|
||||||
<section class="panel" id="tab-canvas">
|
<section class="panel" id="tab-canvas">
|
||||||
<div class="cv-toolbar">
|
<div class="cv-toolbar">
|
||||||
@@ -296,6 +345,14 @@
|
|||||||
<input id="grp-codex-image" type="checkbox" style="width:auto;margin:0" />
|
<input id="grp-codex-image" type="checkbox" style="width:auto;margin:0" />
|
||||||
<span style="margin:0">在 Codex 中用于生图(写入 gen-image 技能,需支持 OpenAI 图片接口)</span>
|
<span style="margin:0">在 Codex 中用于生图(写入 gen-image 技能,需支持 OpenAI 图片接口)</span>
|
||||||
</label>
|
</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">
|
<div class="field grow" style="margin-top:10px">
|
||||||
<span>默认模型(可选,填了则强制用它)</span>
|
<span>默认模型(可选,填了则强制用它)</span>
|
||||||
<div class="row" style="gap:8px; align-items:center">
|
<div class="row" style="gap:8px; align-items:center">
|
||||||
|
|||||||
@@ -651,3 +651,17 @@ body.cv-noscroll { overflow: hidden; }
|
|||||||
#tab-canvas.cv-fullscreen { position: fixed; inset: 0; z-index: 9999; margin: 0;
|
#tab-canvas.cv-fullscreen { position: fixed; inset: 0; z-index: 9999; margin: 0;
|
||||||
height: auto; padding: 14px 16px; background: var(--bg); }
|
height: auto; padding: 14px 16px; background: var(--bg); }
|
||||||
#tab-canvas.cv-fullscreen .cv-stage { min-height: 0; border-radius: 12px; }
|
#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; }
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user