feat: 路由不再开机自动接管 + 保存分组即写直连配置 + 官方登录完整备份 (v1.5.16)

- 启动不再自动 router.start():只想用其它功能的用户不再被接管
- 新增 applyDirectToCodex():保存 responses 中转分组即把直连配置写进
  config.toml+auth.json(非本地路由),重启 Codex 即用;本地路由降级为
  '启用本地路由(高级)'显式选项
- chat 协议转换分组(DeepSeek/GLM/Kimi)保存时只提示需启用路由,不自动起
- 新增 snapshotOfficialLogin():首次接管前完整快照官方 auth.json(含OAuth
  tokens)+config.toml(router.json加密存+落盘副本);restoreDirect 优先用它
  100%还原官方登录态,并清理 td-direct 残段
- setActive 按 isRunning 分流:路由在跑走本地路由秒切,否则写直连需重启
- 隔离测试:登录态21/21 + Mac custom回归9/9 全绿
- 版本 1.5.15 → 1.5.16

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
mac
2026-07-16 22:36:56 +08:00
parent 3f27682e78
commit 45ee76e3c8
10 changed files with 215 additions and 54 deletions
+4 -5
View File
@@ -1,6 +1,6 @@
<!-- codex-memory:start -->
## 项目记忆(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` 查证再答。
<!-- codex-memory:end -->
+10 -21
View File
@@ -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
+169 -12
View File
@@ -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,
+11 -7
View File
@@ -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();
+1
View File
@@ -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"),
+2 -2
View File
@@ -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",
+2 -2
View File
@@ -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",
+14 -4
View File
@@ -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 {
+1 -1
View File
@@ -300,7 +300,7 @@
<div><code id="router-addr">—</code> <span class="pc-badge" id="router-state">未启动</span></div>
</div>
<div class="btn-group">
<button id="router-apply" class="btn primary">一键配置到 Codex</button>
<button id="router-apply" class="btn primary">启用本地路由(高级)</button>
<button id="router-apply-claude" class="btn">接入 Claude Code</button>
<button id="router-toggle" class="btn">启动</button>
</div>
+1
View File
@@ -217,6 +217,7 @@ h3 { margin: 0; font-size: 16px; }
max-width: 80%; font-size: 13px; }
.toast.ok { border-color: var(--ok); }
.toast.err { border-color: var(--danger); }
.toast.warn { border-color: #f5a623; }
/* 卡片内按钮忙碌态 */
.installing { pointer-events: none; opacity: .7; }