diff --git a/AGENTS.md b/AGENTS.md index 4b962d8..872d70f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ ## 项目记忆(Codex 插件市场自动维护 · 回答前据此 · 勿手动编辑本段) -**当前状态**:用户想重开 Claude Code 会话并延续之前记录。已说明可在原项目目录用 `claude --continue`/`-c` 接最近会话,用 `claude --resume`/`-r` 或 `/resume` 手动选择历史会话;会话按项目目录保存。 +**当前状态**:当前仅确认助手位于项目目录 `/Users/mac/Documents/备份数据/ProjectDown/codex-market-app`,尚未开始具体代码任务。可继续处理代码、查历史记忆或恢复 Claude/Codex 会话。 **已定决策**: - 指定历史会话用 claude --resume - 最近会话用 claude --continue @@ -15,10 +15,10 @@ - 同时发布热更和完整包 - 非显式生图请求剥离gen-image声明 **下一步**: -- 进入原项目目录 -- 运行恢复命令继续会话 -**关键文件**:~/.claude/projects/.../*.jsonl +- 等待用户指定具体任务 +**关键文件**:/Users/mac/Documents/备份数据/ProjectDown/codex-market-app **历史话题(需其中细节时用下面命令检索)**: +- 2026-07-16 项目会话恢复 - 2026-07-16 Claude Code 会话恢复 - 2026-07-16 今日新闻搜索交接 - 2026-07-16 发布1.5.14完整包 @@ -30,6 +30,5 @@ - 2026-07-15 生成魔兽世界宣传海报 - 2026-07-15 Codex skill软链改造 - 2026-07-14 列出本地 skills -- 2026-07-14 Claude CLI 中转配置排查 > 回答涉及本段未列出的历史细节前,先运行 `rg -n "关键词" /Users/mac/.codex/memory/31c67c70b625.md` 查证再答。 diff --git a/HANDOFF.md b/HANDOFF.md index 32bf024..2fc19a4 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -3,7 +3,16 @@ > 核心记忆已写进本项目 `AGENTS.md`(Codex 每次会话自动读)。本文件是更详细的近期快照,供需要时参考;关键信息不依赖本文件。 -## 最近会话 · 2026-07-16 10:37 · Claude Code 会话恢复 +## 最近会话 · 2026-07-16 14:32 · 项目会话恢复 + +当前仅确认助手位于项目目录 `/Users/mac/Documents/备份数据/ProjectDown/codex-market-app`,尚未开始具体代码任务。可继续处理代码、查历史记忆或恢复 Claude/Codex 会话。 + +**下一步**: +- 等待用户指定具体任务 + +**涉及文件**:/Users/mac/Documents/备份数据/ProjectDown/codex-market-app + +## 更早会话 · 2026-07-16 10:37 · Claude Code 会话恢复 用户想重开 Claude Code 会话并延续之前记录。已说明可在原项目目录用 `claude --continue`/`-c` 接最近会话,用 `claude --resume`/`-r` 或 `/resume` 手动选择历史会话;会话按项目目录保存。 @@ -49,23 +58,3 @@ - 若仍403检查所选生图分组权限 **涉及文件**:electron/lib/router.js、package.json、package-lock.json、scripts/publish-hot.js、electron-builder.yml - -## 更早会话 · 2026-07-16 01:57 · 生图 skill 与聊天分组冲突 - -已定位:安装生图 skill 后,普通聊天请求会携带生图相关上下文,当前聊天分组未开图片权限而被 /responses 返回 403。推荐方案是普通聊天不暴露生图 skill,显式 $gen-image 时由本地拦截并直接调用 gen-image 脚本与生图分组。 - -**已确定的决策**: -- Grok 视频不是流式,是异步轮询 -- Grok 失败曾因成品视频直连超时 -- 已修 Grok 轮询与代理下载逻辑 -- 生图 403 来自聊天分组权限 -- 推荐 $gen-image 本地直执行 - -**下一步**: -- 实现 $gen-image 本地命令拦截 -- 普通聊天禁用生图 skill 注入 -- 调用 gen_image.py 走生图 .env -- 返回生成图片路径或预览 -- 必要时加错误提示与日志 - -**涉及文件**:/Users/mac/Library/Containers/com.tencent.qq/Data/Downloads/GROK_VIDEO_API_CN.md、/Users/mac/Documents/备份数据/ProjectDown/electron/lib/canvas.js、/Users/mac/Documents/备份数据/ProjectDown/hot/1.5.13/electron/lib/canvas.js、/var/folders/xk/qrv10_rx12jg91fn322xlwr00000gn/T/codex-clipboard-f13f4be1-4b94-4878-927c-2a80e0e67b47.png diff --git a/electron/lib/router.js b/electron/lib/router.js index 2e3ea25..a35f615 100644 --- a/electron/lib/router.js +++ b/electron/lib/router.js @@ -12,7 +12,8 @@ const translate = require("./translate"); const CODEX_HOME = path.join(os.homedir(), ".codex"); const CLAUDE_HOME = path.join(os.homedir(), ".claude"); -const PROVIDER_ID = "local-router"; // config.toml 里的 provider 名 +const PROVIDER_ID = "local-router"; // config.toml 里的 provider 名(走本地路由进程时用) +const DIRECT_PROVIDER_ID = "td-direct"; // config.toml 里的 provider 名(直连中转、不经路由时用) function cfgPath() { return path.join(app.getPath("userData"), "router.json"); @@ -46,7 +47,14 @@ function load() { try { const raw = JSON.parse(fs.readFileSync(cfgPath(), "utf-8")); raw.routerKey = raw.routerKey ? dec(raw.routerKey) : defaultConfig().routerKey; - raw.groups = (raw.groups || []).map((g) => ({ ...g, key: dec(g.key) })); + raw.groups = (raw.groups || []).map((g) => { + const out = { ...g, key: dec(g.key) }; + // officialBackup.authJson 含 OAuth tokens,落盘时加密,读时解密 + if (out.officialBackup && out.officialBackup.authJson) { + out.officialBackup = { ...out.officialBackup, authJson: dec(out.officialBackup.authJson) }; + } + return out; + }); config = { ...defaultConfig(), ...raw }; } catch { config = defaultConfig(); @@ -58,7 +66,13 @@ function save() { const out = { ...config, routerKey: enc(config.routerKey), - groups: config.groups.map((g) => ({ ...g, key: enc(g.key) })), + groups: config.groups.map((g) => { + const o = { ...g, key: enc(g.key) }; + if (o.officialBackup && o.officialBackup.authJson) { + o.officialBackup = { ...o.officialBackup, authJson: enc(o.officialBackup.authJson) }; + } + return o; + }), }; const p = cfgPath(); fs.mkdirSync(path.dirname(p), { recursive: true }); @@ -139,6 +153,36 @@ function backupConfig(text) { fs.writeFileSync(path.join(dir, "config.toml." + Date.now() + ".bak"), text || ""); } catch {} } +// 首次接管 Codex 配置前,把官方登录信息(含 OAuth tokens 的 auth.json + 当前 config.toml) +// 完整快照进默认分组 def.officialBackup,并落一份磁盘副本。幂等:只在第一次真正执行。 +// 保证任何时候「切回默认」都能 100% 还原回官方登录态。 +function snapshotOfficialLogin(def) { + if (!def) return; + if (def.officialBackup && def.officialBackup.done) return; // 幂等守卫 + let configToml = ""; + let authJson = ""; + try { configToml = fs.readFileSync(path.join(CODEX_HOME, "config.toml"), "utf-8"); } catch {} + try { authJson = fs.readFileSync(path.join(CODEX_HOME, "auth.json"), "utf-8"); } catch {} + // 磁盘副本(灾备):仅当不存在时写,避免二次接管覆盖原始官方备份 + try { + const dir = path.join(CODEX_HOME, "backups_state", "router", "official-login"); + fs.mkdirSync(dir, { recursive: true }); + const cfgOrig = path.join(dir, "config.toml.orig"); + const authOrig = path.join(dir, "auth.json.orig"); + if (configToml && !fs.existsSync(cfgOrig)) fs.writeFileSync(cfgOrig, configToml, "utf-8"); + if (authJson && !fs.existsSync(authOrig)) { + fs.writeFileSync(authOrig, authJson, "utf-8"); + try { fs.chmodSync(authOrig, 0o600); } catch {} + } + } catch {} + def.officialBackup = { + done: true, + ts: Date.now(), + configToml, + authJson, + hadTokens: isLoggedIn(), + }; +} // 首次使用时,把当前(原始)Codex 配置快照为不可删除的「默认」分组。 // 铁律:默认分组必须代表「我的账号 · 直连」的登录态,绝不能是接入中转的状态, // 否则「切回默认」会把 Codex 重新指向中转,用户就再也复原不回自己账号了。 @@ -147,6 +191,7 @@ function ensureDefault() { const loggedIn = isLoggedIn(); const existing = config.groups.find((g) => g.isDefault); if (existing) { + snapshotOfficialLogin(existing); // 幂等:确保已装用户也补上官方登录完整备份 // 自愈:已用自己账号登录,但历史版本把默认分组错误地快照成了中转 provider → // 纠正为「纯登录态」,保证「切回默认」永远能干净复原自己账号。 if (loggedIn && (existing.modelProvider || existing.providerBlock || existing.authKey)) { @@ -169,6 +214,7 @@ function ensureDefault() { authKey: "", origModel: currentRootModel(), // 记住原始根级 model(新版桌面登录态通常无 → ""),复原时精确还原 }); + snapshotOfficialLogin(config.groups[0]); if (!config.activeId) config.activeId = "default"; save(); return; @@ -186,6 +232,7 @@ function ensureDefault() { authKey: readAuthKey(), origModel: currentRootModel(), }); + snapshotOfficialLogin(config.groups[0]); if (!config.activeId) config.activeId = "default"; save(); } @@ -246,8 +293,9 @@ function restoreDirect() { const cfgFile = path.join(CODEX_HOME, "config.toml"); let text = readConfigToml(); backupConfig(text); - // 先移除本地路由段(无论哪种复原都要清掉) + // 先移除本地路由段 + 直连中转段(无论哪种复原都要清掉,避免残留污染) text = removeTomlSection(text, `model_providers.${PROVIDER_ID}`); + text = removeTomlSection(text, `model_providers.${DIRECT_PROVIDER_ID}`); if (def.modelProvider) { // 有原始直连 provider(纯 API key 用户,如 Mac 的 custom):设根键 + 追加原始段,避免重复键。 // 此分支完全保持旧行为(不动根级 model 行),确保已在用的 Mac 环境零回归。 @@ -267,6 +315,14 @@ function restoreDirect() { text = text.replace(/\n{3,}/g, "\n\n"); fs.writeFileSync(cfgFile, text, "utf-8"); const af = path.join(CODEX_HOME, "auth.json"); + // 优先用官方登录完整备份整体还原(含 OAuth tokens)→ 100% 复原官方登录态 + if (def.officialBackup && def.officialBackup.done && def.officialBackup.hadTokens && def.officialBackup.authJson) { + try { + fs.writeFileSync(af, def.officialBackup.authJson, "utf-8"); + fs.chmodSync(af, 0o600); + return; + } catch {} + } try { const a = JSON.parse(fs.readFileSync(af, "utf-8")); // 登录态:清空 OPENAI_API_KEY、保留 tokens → Codex 用 OAuth 直连;纯 key 用户则还原原始 key @@ -727,7 +783,32 @@ function upsertGroup(g) { } save(); try { syncGenImage(); } catch {} - return status(); + // 保存中转分组后:把它的直连中转配置写进 Codex config.toml(不经路由、不起进程), + // 并设为激活分组。用户重启 Codex 即用当前分组。只有显式「启用本地路由」才走路由进程。 + const st = status(); + if (cur && !cur.isDefault) { + // 路由进程正在跑(用户处于「启用路由」高级模式)→ 保持走本地路由,不改成直连 + if (isRunning()) { + st._directSkipped = true; + st._directReason = "本地路由运行中,保存的分组走路由生效(切分组无需重启)"; + } else if (!cur.baseUrl || !cur.key) { + st._directSkipped = true; + st._directReason = "该分组缺少 API 地址或密钥,未写入 Codex 配置"; + } else if (cur.mode === "chat") { + st._directSkipped = true; + st._directNeedRouter = true; + st._directReason = "该分组为 Chat 协议转换模式,需启用本地路由才能接入 Codex"; + } else { + try { + const r = applyDirectToCodex(cur.id); + if (r.ok) { st._directApplied = true; st.activeId = config.activeId; } + else { st._directSkipped = true; st._directReason = r.error || "写入失败"; } + } catch (e) { + st._directError = String((e && e.message) || e); + } + } + } + return st; } function deleteGroup(id) { load(); @@ -744,24 +825,34 @@ function setActive(id) { const target = config.groups.find((g) => g.id === id); if (!target) return status(); const before = currentModelProvider(); + const wasTakenOver = before === PROVIDER_ID || before === DIRECT_PROVIDER_ID; let needsRestart = false; let mode = "proxy"; if (target.isDefault) { // 切回默认 = 还原原始配置,Codex 直连自己账号(需重启 Codex) restoreDirect(); mode = "direct"; - needsRestart = before === PROVIDER_ID; // 之前在走路由 → 需重启 - } else { - // 切到中转分组:确保 config.toml 指向本地路由 + needsRestart = wasTakenOver; // 之前在走路由或直连中转 → 需重启 + } else if (isRunning()) { + // 路由进程在跑(高级模式):确保 config.toml 指向本地路由,秒切无需重启 + mode = "proxy"; if (before !== PROVIDER_ID) { applyToCodex(); - needsRestart = true; // 从直连切到路由 → 需重启一次 + needsRestart = true; // 从直连/默认切到路由 → 需重启一次 } - // 若分组指定了模型:写模型目录窗口 + 设 config 模型,让 Codex 按真实窗口压缩 if (target.model) { applyModelCatalog(target); setConfigModel(target.model); } + } else { + // 默认模式:直连中转,改 config.toml 指向该分组地址(必然需重启 Codex) + mode = "direct"; + const r = applyDirectToCodex(id); + // applyDirectToCodex 内部已 setActive + save;chat/缺 key 则不写配置 + needsRestart = !!r.ok && !r.restored; + config.activeId = id; + save(); + return { ...status(), _needsRestart: needsRestart, _mode: mode, _directSkipped: r.skipped, _directReason: r.error }; } config.activeId = id; save(); @@ -837,8 +928,73 @@ function applyToCodex() { return { ok: true }; } -// —— 自动接入 Claude Code —— 写 ~/.claude/settings.json 的 env:ANTHROPIC_BASE_URL + ANTHROPIC_AUTH_TOKEN -// Claude Code 会把 settings.json 里 env 的键当环境变量注入每个会话,从而走本地路由。 +// —— 直连中转写配置(不经本地路由,不起进程)—— +// 把指定分组的中转 base_url/key 直接写进 config.toml + auth.json,Codex 直连中转站。 +// 用户保存分组即调用;重启 Codex 生效。chat 模式分组需协议转换、无法直连 → 拒绝。 +function applyDirectToCodex(groupId) { + load(); + const g = config.groups.find((x) => x.id === (groupId || config.activeId)); + if (!g) return { ok: false, error: "分组不存在" }; + if (g.isDefault) { + restoreDirect(); // 默认分组语义 = 回到官方登录直连 + config.activeId = g.id; + save(); + return { ok: true, restored: true }; + } + if (!g.baseUrl || !g.key) { + return { ok: false, skipped: true, error: "该分组缺少 API 地址或密钥,未写入 Codex 配置" }; + } + if (g.mode === "chat") { + return { ok: false, skipped: true, needRouter: true, error: "该分组为 Chat 协议转换模式,直连 Codex 不支持,需启用本地路由" }; + } + + // 接管前先确保官方登录已完整备份(幂等) + const def = config.groups.find((x) => x.isDefault); + if (def) snapshotOfficialLogin(def); + + const cfgFile = path.join(CODEX_HOME, "config.toml"); + let text = ""; + try { text = fs.readFileSync(cfgFile, "utf-8"); } catch {} + backupConfig(text); + + // 清理其他接管痕迹:本地路由段、上次直连段、默认分组原始 provider 段 + text = removeTomlSection(text, `model_providers.${PROVIDER_ID}`); + text = removeTomlSection(text, `model_providers.${DIRECT_PROVIDER_ID}`); + if (def && def.modelProvider && def.modelProvider !== DIRECT_PROVIDER_ID) { + text = removeTomlSection(text, `model_providers.${def.modelProvider}`); + } + // 替换/插入根键 → 指向直连 provider + if (/^\s*model_provider\s*=/m.test(text)) { + text = text.replace(/^\s*model_provider\s*=.*$/m, `model_provider = "${DIRECT_PROVIDER_ID}"`); + } else { + text = `model_provider = "${DIRECT_PROVIDER_ID}"\n` + text; + } + // 追加 [model_providers.td-direct] 段:base_url = 分组真实中转地址(非 127.0.0.1) + const base = g.baseUrl.replace(/\/+$/, ""); + const block = `[model_providers.${DIRECT_PROVIDER_ID}]\nname = "${(g.name || "中转直连").replace(/"/g, "")}"\nbase_url = "${base}"\nwire_api = "responses"\nrequires_openai_auth = true\n`; + text = text.trimEnd() + "\n\n" + block; + text = text.replace(/\n{3,}/g, "\n\n"); + fs.writeFileSync(cfgFile, text, "utf-8"); + + // auth.json 写分组真实 key(Codex 直连中转站时用它作 Bearer) + const authFile = path.join(CODEX_HOME, "auth.json"); + let auth = {}; + try { auth = JSON.parse(fs.readFileSync(authFile, "utf-8")); } catch {} + auth.OPENAI_API_KEY = g.key; + try { + fs.writeFileSync(authFile, JSON.stringify(auth, null, 2), "utf-8"); + fs.chmodSync(authFile, 0o600); + } catch {} + + // 分组指定模型 → 写模型目录窗口 + 设根级 model + if (g.model) { + applyModelCatalog(g); + setConfigModel(g.model); + } + config.activeId = g.id; + save(); + return { ok: true, direct: true, group: g.name }; +} function claudeSettingsPath() { return path.join(CLAUDE_HOME, "settings.json"); } @@ -932,6 +1088,7 @@ module.exports = { setActive, setPort, applyToCodex, + applyDirectToCodex, codexSnippet, applyToClaudeCode, restoreClaudeCode, diff --git a/electron/main.js b/electron/main.js index 6e6b42a..4315710 100644 --- a/electron/main.js +++ b/electron/main.js @@ -213,15 +213,13 @@ app.whenReady().then(async () => { if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send("memory-changed"); }, }); - // 有分组就自动启动本地路由 + // 启动时不再自动接管/启动本地路由:用户可能只想用其它功能。 + // 保存分组已直接写 Codex 直连配置;只有用户显式「启用本地路由」才 start() 路由进程。 + // 仅刷新一次托盘菜单以反映当前分组/状态(不 start)。 try { - const st = router.status(); - if (st.groups.length) { - await router.start(); - rebuildTrayMenu(); - } + rebuildTrayMenu(); } catch (e) { - log(`本地路由启动失败:${e.message || e}`); + log(`刷新托盘失败:${e.message || e}`); } try { const r = migrateLegacySkills(); @@ -1303,6 +1301,12 @@ ipcMain.handle("router-apply-codex", async () => { rebuildTrayMenu(); return { ...r, status: router.status() }; }); +// 把指定分组的直连中转配置写进 Codex(不经路由、不起进程) +ipcMain.handle("router-apply-direct", async (_e, { id } = {}) => { + const r = router.applyDirectToCodex(id); + rebuildTrayMenu(); + return { ...r, status: router.status() }; +}); ipcMain.handle("router-snippet", async () => router.codexSnippet()); ipcMain.handle("router-apply-claude", async () => { const r = router.applyToClaudeCode(); diff --git a/electron/preload.js b/electron/preload.js index b040ac2..43489e9 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -71,6 +71,7 @@ contextBridge.exposeInMainWorld("api", { routerStart: () => ipcRenderer.invoke("router-start"), routerStop: () => ipcRenderer.invoke("router-stop"), routerApplyCodex: () => ipcRenderer.invoke("router-apply-codex"), + routerApplyDirect: (id) => ipcRenderer.invoke("router-apply-direct", { id }), routerApplyClaude: () => ipcRenderer.invoke("router-apply-claude"), routerRestoreClaude: () => ipcRenderer.invoke("router-restore-claude"), routerSnippet: () => ipcRenderer.invoke("router-snippet"), diff --git a/package-lock.json b/package-lock.json index fa498d5..bf3f7f2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codex-market", - "version": "1.5.15", + "version": "1.5.16", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codex-market", - "version": "1.5.15", + "version": "1.5.16", "license": "MIT", "dependencies": { "electron-updater": "^6.8.9", diff --git a/package.json b/package.json index ac13502..2a1bfdd 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,11 @@ { "name": "codex-market", - "version": "1.5.15", + "version": "1.5.16", "description": "TD工具箱 / 技能商店 / 备份工具 —— 跨平台桌面客户端", "author": "Codex Market", "license": "MIT", "main": "electron/bootstrap.js", - "hotContentVersion": "1.5.15", + "hotContentVersion": "1.5.16", "scripts": { "start": "electron .", "dist": "electron-builder", diff --git a/renderer/app.js b/renderer/app.js index c3605fd..a892130 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -1414,9 +1414,19 @@ $("#grp-save").addEventListener("click", async () => { // 编辑留空时不传 key 字段,避免覆盖 if (grpEditing && !key) delete group.key; try { - renderRouter(await apic.routerUpsertGroup(group)); + const st = await apic.routerUpsertGroup(group); + renderRouter(st); closeGrpModal(); - toast("已保存分组 ✅", "ok"); + // 保存后根据是否已把直连中转配置写进 Codex 给出对应提示 + if (st._directApplied) { + toast("已保存并写入 Codex 配置 ✅ 重启 Codex 即用当前分组", "ok"); + } else if (st._directError) { + toast("已保存,但写入 Codex 配置失败:" + st._directError, "err"); + } else if (st._directSkipped) { + toast("已保存分组。" + (st._directReason || "该分组需启用本地路由或补全密钥后才能接入 Codex"), "warn"); + } else { + toast("已保存分组 ✅", "ok"); + } } catch (e) { toast("保存失败:" + (e.message || e), "err"); } @@ -1473,13 +1483,13 @@ $("#router-save-port").addEventListener("click", async () => { } }); $("#router-apply").addEventListener("click", async (ev) => { - if (!confirm("将修改 Codex 的 config.toml,指向本地路由(会自动备份)。\n注意:启用后账号切换请统一在本软件切,不要再用 cc-switch,以免冲突。\n\n确认?")) return; + if (!confirm("【高级】启用本地路由:将把 Codex 的 config.toml 改为指向本地路由并启动路由进程(会自动备份)。\n\n普通使用无需此项——保存分组时已把中转配置直接写进 Codex,重启即用。\n仅当你需要「切分组免重启」或使用 Chat 协议转换分组(如 DeepSeek/GLM/Kimi)时才需要启用路由。\n\n注意:启用后账号切换请统一在本软件切,不要再用 cc-switch,以免冲突。\n\n确认启用?")) return; const btn = ev.currentTarget; busy(btn, true, "配置中…"); try { const r = await apic.routerApplyCodex(); renderRouter(r.status); - toast("已配置到 Codex ✅ 重启 Codex 后即可通过本地路由,之后切分组无需重启", "ok"); + toast("已启用本地路由 ✅ 重启 Codex 后即可通过路由,之后切分组无需重启", "ok"); } catch (e) { toast("配置失败:" + (e.message || e), "err"); } finally { diff --git a/renderer/index.html b/renderer/index.html index 574dde2..310c56b 100644 --- a/renderer/index.html +++ b/renderer/index.html @@ -300,7 +300,7 @@
— 未启动