feat: add desktop app hot update channel

This commit is contained in:
mac
2026-07-15 18:01:56 +08:00
parent 2dc15d99ad
commit 322807c68d
45 changed files with 292746 additions and 10 deletions
+15
View File
@@ -0,0 +1,15 @@
# 依赖 / 构建产物
node_modules/
dist/
dist-hot/
output/
.claude/
# sing-box 引擎二进制(太大,用 scripts/fetch-singbox.sh 拉取)
resources/singbox/*/sing-box
resources/singbox/*/sing-box.exe
# 杂项
.DS_Store
*.log
*.map
+36
View File
@@ -0,0 +1,36 @@
<!-- codex-memory:start -->
## 项目记忆(Codex 插件市场自动维护 · 回答前据此 · 勿手动编辑本段)
**当前状态**:用户在询问 Windows 下 Codex 内容存储路径后,提供启动报错截图并问原因;当前需要检查截图所指的 .codex 数据库及备份文件状态,判断是自动修复后的提示还是会反复损坏。
**已定决策**:
- 全量会话读取包含归档和 subagent
- 媒体预览保留dataUrl和原始url
- Grok多图参考不支持
- Grok作为新增节点,不改Seedance
- 导入后保持当前全量/默认模式
- 同步目标为当前账号
- 默认刷新仍保持轻量列表
- 密钥不写入项目源码
- 使用 /v1/videos/{id} 查询
- 使用 /v1/videos/generations 创建
- 默认模型为 grok-imagine-video
- 需按截图路径检查数据库状态
**下一步**:
- 查看截图中的具体报错路径
- 检查 .codex 数据库和备份文件
- 判断是否需恢复或清理损坏文件
**关键文件**:/var/folders/xk/qrv10_rx12jg91fn322xlwr00000gn/T/codex-clipboard-41e58e05-0211-4045-8cc5-1084d4f7f471.png、%USERPROFILE%\.codex、%USERPROFILE%\.codex\sessions、%USERPROFILE%\.codex\archived_sessions、%USERPROFILE%\.codex\history.jsonl、%USERPROFILE%\.codex\config.toml、%USERPROFILE%\.codex\auth.json
**历史话题(需其中细节时用下面命令检索)**:
- 2026-07-15 工具箱新增Grok视频与全量会话同步
- 2026-07-15 生成魔兽世界宣传海报
- 2026-07-15 Codex skill软链改造
- 2026-07-14 列出本地 skills
- 2026-07-14 Claude CLI 中转配置排查
- 2026-07-14 3x-ui订阅分流修复
- 2026-07-14 图片生成与规格调整
- 2026-07-14 生成蝴蝶图片并保存
- 2026-07-13 项目与画布功能分析
- 2026-07-13 网站安装数据库连接失败
- 2026-07-12 服务器BBR已开启
- 2026-07-12 服务器服务与域名配置交接
> 回答涉及本段未列出的历史细节前,先运行 `rg -n "关键词" /Users/mac/.codex/memory/31c67c70b625.md` 查证再答。
<!-- codex-memory:end -->
+154
View File
@@ -0,0 +1,154 @@
# 创意画布(Canvas Studio)集成契约 —— 冻结版
主控已勘定的集成事实,技术员必须严格遵守。目标:在插件市场旁新增「创意画布」,
Drawflow 卡片式节点画布,第一版跑通 **文生图 → 图生视频 → TTS配音** 链路,
全部走 tokendun 中转,**key 只在主进程**。
## 已知事实(不要重复调研)
- Electron 应用,**纯原生 JS,无 React/Vue,无构建步骤**:`renderer/index.html` 直接 `<script src="app.js">`。
- CSP(index.html 第 7 行):`default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; img-src 'self' data:;`
- 远程 http 图片/视频 URL 在渲染层**会被拦截** → 所有生成结果必须由**主进程下载并转 base64 data URL** 返回。
- 渲染层桥接对象:`window.api`(app.js 里别名 `apic`)。所有后端调用走 IPC。
- Tab 机制:`activatePanel(name)` 显示 `#tab-${name}`,激活 `.tab[data-tab=name]`。
- 中转分组已由 `electron/lib/router.js` 管理并**已导出** `llmGroup()`:
返回当前可用中转分组 `{ id, name, baseUrl, key, model, ... }`(无可用则 `null`)。
执行层**直接复用它**,不要新增中转配置。
- 设计变量在 `renderer/styles.css :root`:`--grad --accent --bg --card --card-2 --border --text --muted --ok --danger --radius --shadow`,含明/暗色。节点样式必须复用这些变量。
## 冻结的 Preload 契约(前后端都按这个来)
后端在 `electron/preload.js` 的 `window.api` 中新增:
```js
canvasStatus: () => ipcRenderer.invoke("canvas-status"),
// → { ready: boolean, provider: { name, model, host } | null, reason?: string }
canvasRun: (kind, params) => ipcRenderer.invoke("canvas-run", { kind, params }),
// kind ∈ "image" | "video" | "tts"
// → { ok: true, mime, dataUrl, meta } | { ok: false, error }
```
### canvasRun 各 kind 的 params 与端点(OpenAI 兼容)
- `image`:`{ prompt, size?, model? }`
→ `POST {base}/v1/images/generations`,body `{ model, prompt, size, response_format:"b64_json" }`
→ 兼容 `data[0].b64_json` 或 `data[0].url`(url 则主进程再 fetch 下载)。返回 `image/png` data URL。
- `tts`:`{ text, voice?, model?, format? }`
→ `POST {base}/v1/audio/speech`,body `{ model, input:text, voice:voice||"alloy", response_format:format||"mp3" }`
→ 响应是二进制音频 → 转 `audio/mpeg` data URL。
- `video`:`{ prompt, image?, model?, endpointPath?, extra? }`
→ 视频接口各中转不统一。默认尝试 `POST {base}{endpointPath||"/v1/videos"}`,
body `{ model, prompt, image, ...extra }`;若返回任务 id 则轮询(best-effort)。
**拿不到就返回 `{ ok:false, error }` 并说明「该中转视频接口待适配」,不要伪造成功。**
此节点标注为「实验/可配置」。
`meta` 建议含 `{ model, ms, host }` 便于前端展示。
## 【v2 增补 · 完善配置能力】——本轮必须实现
用户反馈:图像/视频**不能只用聊天模型**(会 404),必须能自填「模型 + 接口地址」,且尺寸/比例/视频模式要齐全。
### canvasRun 参数扩展(后端)
- **image** `{ prompt, model, size, endpointPath?, extra?, image?, refField? }`
- `model`:**用户填写的图像模型**(如 `gpt-image-1`/`dall-e-3`/`flux-pro`/`seedream-3` 等)。**为空则直接返回 `{ok:false,error:"请先填写图像模型"}`,不要回退聊天模型。**
- `size`:`"WxH"`(前端按 比例×清晰度 算好传来)。
- `endpointPath`:默认 `/v1/images/generations`,允许用户覆盖。
- **图生图参考图**:`image`(用户上传的 base64 dataURL)→ 主进程 `uploadMedia()` 传兰空图床换公网直链 → 塞进请求体字段 `refField`(默认 `image`,前端高级项可改)。上传失败直接返回 `{ok:false,error}`,不静默降级。流式与非流式两条 body 都带该字段。
- 错误信息里带上**实际请求的 URL 和 model**,方便排查(如 404 时提示 `POST {url} model={model} → 404`)。
- **video** `{ mode, prompt, model, image?, lastFrameImage?, referenceImages?[], referenceVideo?, ratio?, resolution?, duration?, generateAudio?, watermark?, cameraFixed?, seed?, endpointPath? }` —— **Seedance 2.0**(完整契约,port 自 提示词提取/server.js `buildVideoBody`)
- `mode`:`"text"` 文生视频 | `"first"` 首帧 | `"firstlast"` 首尾帧 | `"reference"` 多参考图 | `"video"` 参考视频。旧值 `"image"` 归一为 `"first"`。
- **素材**(主进程 `runVideo` 里经 `resolveMat`:`data:`→兰空图床外链,`http(s)`→原样):`image`(首帧)、`last_frame_image`、`reference_images[]`;**`reference_video` 仅接受公网URL**(图床不托管视频)。
- `input_mode` 仅在 mode≠text 时下发;素材按模式互斥;缺必填素材直接 `{ok:false,error}`。
- `metadata`:`ratio / resolution / generate_audio / watermark / camera_fixed / seed`(auto/-1/空不塞)。
- `model`:用户填写的视频模型(为空返回明确错误)。
- body 组装:`{ model, prompt, ...(mode==='image'?{image}:{}) , size, ratio, duration, ...extra }`(空字段不塞)。
- `endpointPath` 默认 `/v1/videos`,允许覆盖。仍严禁伪造成功。
- **tts** 增加 `model`(默认 `tts-1` 可保留但允许覆盖)、`format`、可选 `speed`。
### 新增 canvasModels(后端 + preload)
- preload:`canvasModels: () => ipcRenderer.invoke("canvas-models")`
- 主进程:调 `router.fetchModels(llm.baseUrl, llm.key)` 返回 `{ ok, models:[...] }`(无可用分组或失败返回 `{ok:false,error}`)。供前端填充「模型」下拉/datalist。
### 前端节点表单要求(每个能力节点都要能配置)
- **文生图节点**:提示词 + **模型输入框(带 datalist,可点“拉取模型”从 canvasModels 填充)** + **比例下拉**(1:1 / 16:9 / 9:16 / 4:3 / 3:4 / 3:2) + **清晰度下拉**(1K / 2K / 4K)(前端据此算出 `WxH`,长边:1K=1024,2K=2048,4K=4096,短边=长边×比例取整)+ 折叠的「高级」里放**接口地址**覆盖 + 运行 + 预览/报错。
- **视频节点**:**模式下拉(图生视频/文生视频)** + 提示词 + 模型输入框(datalist+拉取) + 比例(16:9/9:16/1:1/4:3) + 分辨率(480p/720p/1080p) + 时长(可选 4s/6s/8s) + 高级(接口地址) + 运行 + 预览/报错。图生视频模式吃上游图,文生视频不需要。
- **TTS 节点**:文本 + 模型输入框 + 音色 + 格式(mp3/wav/opus) + 运行 + 预览。
- 「拉取模型」按钮:调 `apic.canvasModels()`,把返回的 models 灌进所有模型输入框的 `<datalist>`;失败给 toast。
- 预置示例(cvSeedSample)里,模型字段留空并加占位提示(如「填入你中转的图像模型,如 flux-pro」),避免又用聊天模型 404。
所有 base 用 `router.llmGroup()` 的 `baseUrl`(去尾斜杠),鉴权 `Authorization: Bearer <key>`,
`User-Agent: "codex-market"`(与 router.fetchModels 一致)。用 Electron 内置全局 `fetch`。
## 文件归属(不得越界,避免冲突)
- 后端技术员:`electron/lib/canvas.js`(新建)、`electron/main.js`(加 require + 两个 ipc handler)、`electron/preload.js`(加两个方法)。
- 前端技术员:`renderer/index.html`、`renderer/app.js`、`renderer/styles.css`、`renderer/vendor/`(新建,放 drawflow)。
仅前端可改 index.html 的 CSP:新增 `media-src 'self' data: blob:;`(供音视频 data URL 播放),其余不动。
## 第一版节点集
1. 文本/提示词(输入源)
2. 文生图(image)——出图,输出可连到下游
3. 图生视频(video,实验)——吃上游图 + 提示词
4. TTS 配音(tts)——吃文本,出音频
每个节点自带「运行」按钮、内联结果预览、loading/错误态。节点间用 Drawflow 连线传递输出。
无可用中转分组时(canvasStatus.ready=false):画布置灰 + 提示「请先在『路由中转』配置并激活一个中转分组」。
## 【v3 增补 · 按节点选择中转分组】——本轮必须实现
用户希望:在「路由中转」建好多个分组(如"画图中转""视频中转"),打开画布时**自动加载全部分组**,每个能力节点用一个「分组」下拉自选走哪条中转渠道。设计:**分组=选哪条中转渠道(baseUrl+key),模型框=选该渠道上的具体模型。**
### router.js(主控已加,勿动)
已新增只读接口:`groupById(id)`(返回含解密 key 的分组或 null)、`usableGroups()`(非默认且配齐 baseUrl+key 的分组数组)。
### 后端(canvas.js / main.js / preload.js)
- 新增 `groups()`:基于 `router.usableGroups()` 返回 `{ ok:true, activeId, groups:[{id,name,model,host}] }`(host 由 baseUrl 解析;activeId 来自 router.status().activeId 或 llmGroup 的 id)。无可用分组 `{ok:false,error}`。
- `run({kind,params})`:若 `params.groupId` 非空 → 用 `router.groupById(groupId)` 解析渠道(baseUrl+key);否则回退 `router.llmGroup()`。解析到的分组缺 baseUrl/key → 返回明确错误。其余逻辑(模型必填、端点、body 组装)不变。
- `listModels(groupId)`:接受可选 groupId,有则用该分组的 baseUrl+key,否则 llmGroup。
- main.js:`ipcMain.handle("canvas-groups", () => canvas.groups());`;把 canvas-models 改为 `(_e,{groupId}) => canvas.listModels(groupId)`。
- preload.js:`canvasGroups: () => ipcRenderer.invoke("canvas-groups")`;`canvasModels: (groupId) => ipcRenderer.invoke("canvas-models",{groupId})`。
### 前端(renderer/*)
- 进入画布时调 `apic.canvasGroups()`,缓存分组列表。给**文生图/视频/TTS 每个节点**加一个「分组」下拉 `.cv-field-group`(option value=分组id,文案=`名称 · host`),默认选中 activeId。
- 节点运行时 params 带上 `groupId=选中的分组id`。
- 「拉取模型」改为**每节点一个「拉取」小按钮**(放模型框旁),调 `apic.canvasModels(该节点选中的groupId)`,填充该节点自己的 `<datalist id="cv-models-{nodeId}">`;模型框 `list` 指向它。(工具条原全局「拉取模型」可保留为拉取默认分组填充所有,或移除,二选一,别报错即可。)
- 选择分组后若模型框为空,可用该分组的 model 预填(来自 canvasGroups 数据)。
- 新增节点(cvAddNode)与预置示例(cvSeedSample)都要带分组下拉并默认选中 activeId;画布首次进入若分组数据未就绪,加载后要回填已存在节点的下拉。
- 无可用分组时:节点内提示「请先在『路由中转』添加中转分组」。
## 【v4 增补 · 流式图片生成 + 渐进预览】——本轮必须实现
用户要求图片流式生成(gpt-image 支持 partial image 流式;且 gpt-image 不接受 response_format)。
### 后端(canvas.js / main.js / preload.js)
- **image 改为 stream-first**:POST `{base}{endpointPath||"/v1/images/generations"}`,body `{ model, prompt, size, n:1, stream:true, partial_images:2, ...extra }`。**不要再发 `response_format`**(gpt-image 会报错;dall-e 系无 stream 时走回退分支即可)。
- 用 Electron 全局 fetch,读 `res.body`(web ReadableStream,`for await (const chunk of res.body)` 可迭代);`TextDecoder` 累积、按行切、解析 `data: {...}`(跳过 `data: [DONE]`)。
- 事件里凡带 `b64_json` 的都取出:`type` 含 `partial` → 渐进帧,回调 `onPartial(dataUrl)`;`type` 含 `completed` 或流结束时的最后一帧 → 最终图。返回最终 `{ok:true, mime:"image/png", dataUrl, meta}`。
- **回退**:若响应不是 SSE(Content-Type 非 text/event-stream 或首包不是 data:),或 stream 请求返回非 2xx → 退回原「非流式」POST(body 去掉 stream/partial_images,gpt-image 不加 response_format,其它模型可加 b64_json)解析 `data[0].b64_json|url`。错误信息仍带 URL+model。
- **run 签名**:`run({ kind, params }, onPartial)` — 多接一个可选回调;仅 image 流式用到。
- **main.js**:`canvas-run` handler 改为
`ipcMain.handle("canvas-run", (e, { kind, params }) => canvas.run({ kind, params }, (dataUrl) => { try { e.sender.send("canvas-progress", { runId: params && params.runId, kind, dataUrl }); } catch {} }));`
- **preload.js**:新增
`onCanvasProgress: (cb) => { const h = (_e, p) => cb(p); ipcRenderer.on("canvas-progress", h); return () => ipcRenderer.removeListener("canvas-progress", h); },`
### 前端(renderer/*)
- 运行前生成 `runId`(如 `nodeId + "-" + (++CANVAS.runSeq)`,Date.now 亦可),塞进 `params.runId`,并把 `nodeId` 与 `runId` 映射记下。
- 启动时订阅一次 `apic.onCanvasProgress(({runId, dataUrl}) => 用映射找到对应节点预览,把 <img> 的 src 换成 dataUrl)`,实现**边生成边刷新预览**(partial 帧陆续替换)。
- 最终 `canvasRun` resolve 的成品仍照常渲染覆盖;skeleton 在收到第一帧 partial 后即可替换为图片。
- 仅 image 节点用到渐进预览;video/tts 不变。
## 【v5 增补 · 接入 TokenDun/Seedance 真实视频接口】——本轮必须实现
参照 `/Users/mac/Documents/备份数据/ProjectDown/提示词提取/server.js`(Seedance 视频 API)。异步任务制:建任务→轮询→下载。gwBase = `{分组baseUrl}/v1`。
### 后端 runVideo 重写(canvas.js)——完全替换旧 /v1/videos 逻辑
- 端点:create = `{base}{endpointPath||"/v1/video/generations"}`;poll = `create + "/" + taskId`(GET)。鉴权 `Bearer <key>`(分组由 groupId 解析,同 v3)。
- **body 组装**(严格照 Seedance):
- `{ model, prompt }`(model 空→报错"请先填写视频模型")
- `seconds`:字符串,params.seconds 非空才加(值 "-1"=自动/"5"/"10")。
- 文生视频(mode!=='image'):不加 input_mode、不加 image。
- 图生视频(mode==='image'):`input_mode:"first"` + `image: params.imageUrl`。**imageUrl 必须是 http(s) 公网URL**;若为空或以 `data:` 开头 → 直接返回 `{ok:false,error:"图生视频需填公网图片URL(Seedance 不接受 base64/上游图)"}`,**不要把 base64 发出去**。
- `metadata`:{ ratio(≠auto才加), resolution(≠auto才加), generate_audio(bool 才加) },非空才挂到 body.metadata。
- **建任务**:POST create。2xx 才继续;非 2xx 返回 `视频建任务失败(状态):{detail前300}`。解析 taskId:`data.data?.task_id||data.data?.id||data.task_id||data.id||data.result?.task_id||data.result?.id`;无则报错。
- **轮询**:GET poll,每 5s 一次,最多 ~5 分钟(60 次)。用 `normalizeVideoStatus`(port 自 server.js);succeeded → `findVideoUrl`(port,深挖 video_url/result_url/url) → 下载转 base64 → `{ok:true,mime:"video/mp4",dataUrl,meta}`;failed/超时 → 明确错误。仍严禁伪造成功。
- port 两个纯函数 `normalizeVideoStatus`、`findVideoUrl` 到 canvas.js。全程 try/catch。
### 前端视频节点(renderer/*)
- 模式:文生视频 / 图生视频(保留)。
- **图生视频时显示「公网图片URL」输入框** `.cv-field-imgurl`(hint:Seedance 需公网图片URL,上游 base64 图无法直接用)。文生视频时隐藏。
- 比例(16:9/9:16/1:1/4:3) + 分辨率(480p/720p/1080p/自动) 保留。
- **时长改为**:自动(-1) / 5s / 10s(对应 seconds 值 "-1"/"5"/"10","自动"传 "-1" 或空)。
- **加「生成音频」勾选** `.cv-field-audio`(→ generateAudio bool)。
- 高级:接口地址(默认 /v1/video/generations)。
- 运行 params:`{ mode:'text'|'image', prompt, model, ratio, resolution, seconds, generateAudio, imageUrl(仅image), endpointPath, groupId }`。图生视频且 imageUrl 空/为 data: → 前端红字拦截。
+103
View File
@@ -0,0 +1,103 @@
# Codex 插件市场客户端 —— 开发契约(唯一事实来源)
Electron + Node。三名开发者按本文件分工,**接口/数据结构/文件归属必须严格遵守**,避免联调对不上。
## 分工与文件归属(互不重叠)
- **负责人(已完成)**:`package.json`、`electron/preload.js`、本契约、`build/` 图标。
- **开发 A(Node 后端逻辑)**:`electron/main.js` + `electron/lib/*.js`。
- **开发 B(前端 UI)**:`renderer/index.html`、`renderer/styles.css`、`renderer/app.js`。
- **开发 C(打包/跨平台)**:`electron-builder.yml`、`README.md`、`scripts/`(如需)。
> 任何人不得修改不属于自己的文件。preload.js 是契约边界,已冻结。
## 常量(写死在 electron/main.js 或 lib,前端不可见)
```
MARKET_URL = "https://sc.tokendun.cc"
MARKET_KEY = "2e8e96d10ea0c223cc5cc6439df00a97bb4543ca58a1e5be"
```
市场地址与密钥**绝不能出现在 renderer 前端代码里**,只能在主进程。
## 数据结构
```ts
// 本机/仓库里的一个可备份/可还原项
type Item = {
category: "插件 Plugin" | "技能 Skill",
source: string, // 如 openai-primary-runtime;技能为 "skills"
name: string,
rel: string, // 相对 ~/.codex,如 plugins/cache/openai-primary-runtime/presentations
size: number // 字节
}
// 市场条目
type Entry = {
id?: number, name: string, source: string, category: string,
rel: string, repo: string, branch: string,
web_url: string | null, size: number,
description: string | null, published_at?: string
}
```
## 前端可调用的 API(preload 已暴露为 window.api)
全部返回 Promise。出错时 reject(前端用 try/catch 显示错误)。
| 方法 | 入参 | 返回 | 说明 |
|---|---|---|---|
| `api.scanLocal()` | — | `Item[]` | 扫描本机 ~/.codex 的插件+技能 |
| `api.pickDir()` | — | `string \| null` | 弹系统选目录框 |
| `api.packLocal(rels, destDir)` | `string[], string` | `{path:string}` | 打包选中项到本地目录 |
| `api.upload(rels, repo, token, isPrivate)` | `string[],string,string,boolean` | `{owner:string, published:number}` | 打包→推 GitHub→自动发布市场 |
| `api.marketList()` | — | `Entry[]` | 拉官方市场列表 |
| `api.marketInstall(entry)` | `Entry` | `{ok:true}` | 从市场下载并装进 ~/.codex+启用 |
| `api.ghPull(repoInput, token)` | `string,string` | `Item[]` | clone 指定仓库并列出可还原项 |
| `api.ghRestore(rels, enable)` | `string[],boolean` | `{ok:true}` | 把上次 pull 的选中项还原到 ~/.codex |
| `api.getSecret(name)` | `"github"\|"registry"` | `string` | 取已存密钥(无则空串) |
| `api.setSecret(name, value)` | `string,string` | `{ok:true}` | 存密钥(safeStorage 加密) |
| `api.openExternal(url)` | `string` | — | 系统浏览器打开链接 |
| `api.onLog(cb)` | `(msg:string)=>void` | — | 订阅主进程日志流(安装/上传进度) |
## IPC 通道名(开发 A 在 main.js 用 ipcMain.handle 实现;开发 B 不直接用,只用 window.api)
`scan-local` `pick-dir` `pack-local` `upload` `market-list` `market-install`
`gh-pull` `gh-restore` `get-secret` `set-secret` `open-external`
日志:主进程 `webContents.send("log", msg)` → preload 转成 `api.onLog`。
## 关键逻辑要点(开发 A 必读,从现有 Python 版移植)
1. **扫描**:`~/.codex/plugins/cache/<source>/<plugin>/` 每个是插件;`~/.codex/skills/<skill>/` 每个是技能。跳过 `.` 开头目录。size 递归求和。
2. **打包排除**:拷贝时跳过隐私文件 `auth.json`、`config.toml`、`history.jsonl`、`*.sqlite*`、`*.log`、`*.bak`、`.DS_Store`、`installation_id`。
3. **打包产物**:staging 内保持相对结构;额外生成 `.gitignore`、`restore.sh`、`README.md`、`enabled-plugins.toml`(记录选中插件在本机 config.toml 里的启用状态)。
4. **上传**:用 isomorphic-git。先 `GET https://api.github.com/user`(Bearer token)拿 owner,并读响应头 `x-oauth-scopes` 校验含 `repo`/`public_repo`(缺失给明确错误:让用户用 classic token 勾 repo);`POST /user/repos` 建仓(422=已存在则忽略,403/404 继续尝试 push);git init/add/commit/push(force)。push 认证 `onAuth: () => ({ username: token })`。
5. **发布市场**:上传成功后,对选中的**插件**项(技能无 config 登记)构造 Entry:`repo=owner/reponame, branch=main, web_url=https://github.com/owner/repo/tree/main/<rel>`,`POST {MARKET_URL}/api/plugins`,头 `X-API-Key: MARKET_KEY`,体 `{plugins:[...]}`。
6. **克隆/安装**:isomorphic-git clone(私有仓库 onAuth 用 token)到临时目录 → 扫描 → 按 `entry.rel` 精确匹配 → 拷进 `~/.codex/<rel>` → 若是插件则在 `config.toml` 追加 `[plugins."name@source"]\nenabled = true`(已存在则不重复;改动前备份 config.toml)。
7. **仓库地址解析**:接受 `owner/repo`、`https://github.com/owner/repo(.git)`。
8. **密钥存储**:`safeStorage.encryptString` 加密后写入 `app.getPath("userData")/secrets.json`;读时解密。name 取 `github` / `registry`。
9. **市场 API_KEY**:安装(读)不需要 key;仅发布需要,用写死的 MARKET_KEY。
## 会话记忆(新增模块:electron/lib/memory.js)
自动把 Codex 会话浓缩成交接摘要:监视 `~/.codex/sessions`,会话静默 idleMinutes 后摘要
(优先走路由中转可用分组的 LLM,失败退化本地提取)→ 存 `userData/memory.db`(sql.js)
→ 写项目根目录 `HANDOFF.md` + 在 `AGENTS.md` 加带标记的引导段(`<!-- codex-memory:start/end -->`)。
配置在 `userData/memory.json`(enabled / disabledProjects / idleMinutes / backfillDays / handoffSessions)。
前端 API(preload 已暴露):
| 方法 | 入参 | 返回 | 说明 |
|---|---|---|---|
| `api.memoryStatus()` | — | `{enabled, engine, total, pending, idleMinutes, projects[]}` | 状态 + 项目列表 |
| `api.memoryList(opts)` | `{project?, q?, limit?}` | 摘要行数组 | 检索记忆库 |
| `api.memorySetEnabled(b)` | `boolean` | status | 总开关 |
| `api.memoryToggleProject(cwd, b)` | `string, boolean` | status | 项目级写入开关 |
| `api.memoryResummarize(path)` | rollout 路径 | `{ok}` | 手动重摘要一条 |
| `api.memoryDelete(id)` | 会话 id | `{ok}` | 删一条摘要 |
| `api.memoryOpenHandoff(cwd)` | 项目路径 | — | Finder 中显示 HANDOFF.md |
| `api.onMemoryChanged(cb)` | 回调 | 取消函数 | 摘要更新推送 |
IPC 通道:`memory-status` `memory-list` `memory-set-enabled` `memory-toggle-project`
`memory-resummarize` `memory-delete` `memory-open-handoff`;事件 `memory-changed`。
router.js 新增导出 `llmGroup()`(供内部功能取可调用的中转分组)。
## UI 设计方向(开发 B)
- 现代、精致、可直接对外发布的观感;深色为主、支持浅色;圆角卡片、柔和阴影、清晰层级。
- 主色建议靛蓝→紫渐变(与图标一致 #5B6CFF→#A855F7),强调按钮用该渐变。
- 两大区域:**① 插件市场**(卡片列表 + 搜索框 + 分类筛选 + 每卡「安装」按钮 + 安装态/进度);**② 备份与还原**(子区:从本机导出上传 / 从 GitHub 拉取还原)。可用侧边栏或顶部标签切换。
- 顶部品牌区(logo + 名称「Codex 插件市场」)。底部或抽屉式「运行日志」。
- 无外部 CDN(离线可用),字体用系统字体栈。图标可用内联 SVG。
- 所有后端调用走 `window.api.*`;**不得**在前端出现市场地址或密钥。
- 空状态、加载中、错误提示都要有像样的样式。
+67
View File
@@ -0,0 +1,67 @@
<!-- 本文件由「Codex 插件市场 · 会话记忆」自动生成与覆盖,请勿手动编辑 -->
# HANDOFF —— 会话交接快照(补充细节)
> 核心记忆已写进本项目 `AGENTS.md`(Codex 每次会话自动读)。本文件是更详细的近期快照,供需要时参考;关键信息不依赖本文件。
## 最近会话 · 2026-07-15 09:02 · 工具箱新增Grok视频与全量会话同步
已新增独立Grok视频节点和后端入口,配置Tokendun路由并测试15秒1080p图生视频可完成;画布媒体预览支持放大/下载并保留原始URL。会话记录页已增加手动获取全部记录功能,可读取sessions、archived_sessions及subagent并用于一键同步。
**已确定的决策**:
- Grok作为新增节点,不改Seedance
- Grok多图参考不支持
- 媒体预览保留dataUrl和原始url
- 全量会话包含归档和subagent
**下一步**:
- 打开应用验证全量按钮与同步流程
- 检查Grok节点实际UI体验
- 如需可优化长任务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
## 更早会话 · 2026-07-15 07:55 · 生成魔兽世界宣传海报
已按用户原始提示词生成一张横版魔兽世界风格游戏宣传海报,尺寸1536x1024,文件已保存并预览确认整体可用;需注意模型生成文字和Logo可能有偏差。
**已确定的决策**:
- 使用原始提示词不扩写
- 横版海报尺寸1536x1024
- 通过本地gen-image脚本生成
**下一步**:
- 正式使用前检查文字和Logo细节
**涉及文件**:/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
## 更早会话 · 2026-07-15 07:35 · Codex skill软链改造
已将 skill/plugin 安装、列表、卸载、迁移改为工具箱实体加 Codex 软链模式;启动迁移已跑通,列表能识别软链,删除 gen-image 已确认软链、实体和 .env 均清理成功。当前 ~/.codex/skills 仅剩 4 个 codex-client-* 软链。
**已确定的决策**:
- skill 实体放工具箱仓库
- Codex 侧只保留软链
- 卸载需同时删除源实体
- openai-* 插件按系统插件保护
- 自动迁移跳过 openai-*
**下一步**:
- 确认 ontology 是否误删
- 确认 Proactive-Agent 是否误删
- 测试新装 skill 后仍为软链
- 检查插件页系统插件状态
**涉及文件**:/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
## 更早会话 · 2026-07-14 17:14 · 列出本地 skills
用户要求直接回答 ~/.codex/skills/ 下非 .system 技能的目录名和 SKILL.md 中 name,且明确禁止执行任何工具。当前尚未回答技能列表。
**已确定的决策**:
- 不得执行任何工具
- 只列非 .system 技能
**下一步**:
- 直接回答可用 skills 或说明没有
**涉及文件**:~/.codex/skills/、~/.codex/skills/*/SKILL.md
+72 -10
View File
@@ -1,18 +1,80 @@
# backup-bot / skills 备份仓库
# Codex 插件市场 —— 桌面客户端
个人技能备份 / 分享库,供 TD工具箱(codex-market)安装。
跨平台(macOS / Windows / Linux)的 Codex 插件 · 技能 **备份 / 分享 / 一键安装** 工具。
基于 Electron,全部逻辑本地运行;凭证只存在本机(系统安全存储加密)。
## 技能清单
## 功能
- **插件市场**:浏览官方市场,搜索 / 筛选,一键安装到 `~/.codex` 并自动启用。
- **从本机导出 / 上传**:扫描本机插件与技能,勾选后导出到本地目录,或打包推送到 GitHub 并自动登记到市场。**凭证文件(auth.json / config.toml / history 等)已自动排除。**
- **从 GitHub 拉取还原**:输入任意仓库,拉取后勾选还原到本机,可选自动在 `config.toml` 启用。
- **设置**:保存默认 GitHub Token(`safeStorage` 加密存储)。
- **会话记忆**:软件开着就自动工作——监视 `~/.codex/sessions`,每次会话告一段落(静默几分钟)后自动浓缩成「交接摘要」:
存入本机 SQLite 记忆库(可检索),并写到对应项目根目录的 `HANDOFF.md`(状态快照),同时在项目 `AGENTS.md` 里加一段
「先读 HANDOFF」引导。之后**新开 Codex 会话说「继续」,就能接上之前聊的**,不用带着超长上下文。
摘要优先调用「路由中转」当前分组的 LLM,未配置或失败时退化为本地规则提取;可全局或按项目关闭。
| 技能 | 分类 | 说明 | 安装包 |
|------|------|------|--------|
| **gen-image · 生图** | 图片生成 | 通过大模型中转站调用 OpenAI 图片接口生图(文生图/图生图/局部重绘/透明背景),零依赖 | `categories/图片生成/28772__gen-image.zip` |
## 开发运行
```bash
npm install
npm start
```
## gen-image 用法
## 打包分发
```bash
# 当前平台
npm run dist
首次需在 skill 目录 `cp .env.example .env` 并填入中转站 `IMAGE_API_BASE_URL` / `IMAGE_API_KEY`,然后:
# 指定平台(需对应系统或已配置交叉编译)
npm run dist:mac # 产出 dist/*.dmg(x64 + arm64)
npm run dist:win # 产出 dist/*Setup.exe
```
产物在 `dist/`。打包配置见 `electron-builder.yml`,图标在 `build/`(icns/ico/png)。
## macOS 首次打开报「已损坏,移到废纸篓」
**这不是包坏了。** 本应用未购买 Apple 开发者证书、未做公证(notarization),
只有临时(ad-hoc)签名。macOS 对「从网上下载」的这类 app 会自动加隔离标记
(`com.apple.quarantine`) 并由 Gatekeeper 拦截,在 Apple 芯片上表现为「已损坏」。
**解决**:把 app 拖到「应用程序」后,在「终端」执行一次(去掉隔离标记):
```bash
./gen.sh "提示词" --size 1024x1024 --quality high # 文生图
./gen.sh "换成雪山背景" --edit input.png # 图生图
xattr -cr "/Applications/Codex 插件市场.app"
```
之后正常双击即可打开,只需做一次。(若提示权限不足,命令前加 `sudo ` 再回车输入开机密码。)
> 想彻底免掉这一步、让用户双击即开,需要 Apple Developer 账号($99/年)做
> Developer ID 签名 + 公证,届时在 `electron-builder.yml` 的 `mac` 段配置证书即可。
## GitHub Token 说明
上传功能需要 **classic** personal access token,并勾选最上面的 **repo** 复选框:
<https://github.com/settings/tokens>
- 传**公开**仓库:`public_repo` 权限即可。
- 传**私有**仓库:需要完整 `repo` 权限。
- fine-grained token 目前不受支持(推送会失败),请用 classic token。
安装公开仓库的插件无需任何 token;安装私有仓库需在「设置」里保存 token。
## 项目结构
```
electron/
main.js 主进程:窗口 + IPC + GitHub(isomorphic-git) + 市场 API
preload.js 契约边界,暴露 window.api(冻结)
lib/
codex.js 扫描 / 打包 / 还原 / config.toml 启用
secrets.js safeStorage 密钥存储
renderer/
index.html UI 结构
styles.css 深色为主 · 靛蓝→紫渐变
app.js 前端逻辑(只经 window.api.*)
build/ 打包图标
electron-builder.yml 打包配置
CONTRACT.md 前后端 / 分工契约(唯一事实来源)
```
## 隐私
- 市场地址与密钥只存在于主进程,不出现在前端代码。
- 打包上传时严格排除:`auth.json`、`config.toml`、`history.jsonl`、`*.sqlite*`、`*.log`、`*.bak`、`.DS_Store`、`installation_id`。
- 还原写入 `config.toml` 前会自动备份为 `config.toml.bak-exporter`。
+1438
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

+49
View File
@@ -0,0 +1,49 @@
#!/bin/bash
# 双击本文件即可修复 macOS「已损坏,移到废纸篓」——去掉隔离标记(quarantine)。
# 只需运行一次。原理:本应用为免费分发、未做 Apple 公证,被系统加了隔离标记。
APP_NAME="Codex 插件市场.app"
echo "==================================================="
echo " 修复 macOS「已损坏」提示 —— Codex 插件市场"
echo "==================================================="
echo
# 依次在常见位置查找已安装的 app
CANDIDATES=(
"/Applications/$APP_NAME"
"$HOME/Applications/$APP_NAME"
"$(cd "$(dirname "$0")" && pwd)/$APP_NAME" # 脚本旁边(DMG 内)
)
TARGET=""
for p in "${CANDIDATES[@]}"; do
if [ -d "$p" ]; then TARGET="$p"; break; fi
done
if [ -z "$TARGET" ]; then
echo "❌ 没找到「$APP_NAME」。"
echo " 请先把 app 拖到「应用程序」文件夹,再双击本脚本。"
echo
read -n 1 -s -r -p "按任意键关闭…"
exit 1
fi
echo "找到应用:$TARGET"
echo "正在去除隔离标记…"
if xattr -cr "$TARGET" 2>/dev/null; then
echo "✅ 完成!现在可以正常双击打开「$APP_NAME」了。"
else
echo "普通权限失败,尝试用管理员权限(可能需要输入开机密码)…"
if sudo xattr -cr "$TARGET"; then
echo "✅ 完成!现在可以正常双击打开了。"
else
echo "❌ 仍失败。请手动在终端执行:"
echo " sudo xattr -cr \"$TARGET\""
fi
fi
echo
read -n 1 -s -r -p "按任意键关闭…"
echo
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 322 KiB

+104
View File
@@ -0,0 +1,104 @@
appId: cc.tokendun.codexmarket
productName: TD工具箱
copyright: Copyright © 2026 Tokendun
directories:
output: dist
buildResources: build
# 无 Apple 证书时,electron-builder 会跳过签名、留下残缺封印;
# 此钩子在打 DMG 前对 mac app 做完整 ad-hoc 重签名,修复封印。
afterPack: scripts/afterPack.js
# 只打包运行必需的文件(源码 + 运行时依赖)
files:
- electron/**/*
- renderer/**/*
- build/icon.png
- build/icon.ico
- build/icon.icns
- package.json
- "!**/*.map"
# isomorphic-git 是纯 JS,无需解包 asar;保持默认 asar 打包即可
asar: true
# sql.js 的 wasm 需从 asar 解出,保证运行时可读取
asarUnpack:
- "node_modules/sql.js/**"
mac:
# 内置 sing-box 引擎(全局代理用)——按架构只打对应的一份
extraResources:
- from: "resources/singbox/darwin-${arch}"
to: "singbox/darwin-${arch}"
category: public.app-category.developer-tools
icon: build/icon.icns
# —— 未购买 Apple Developer 账号 ——
# 不设 identity:electron-builder 在无证书环境自动做 ad-hoc 签名,
# 会对 app 及内置 sing-box 完整重新封印(seal),保证用户去掉隔离标记后能稳定运行。
# 切勿设 identity: null——那会完全跳过签名、破坏 bundle 封印。
notarize: false
target:
- target: dmg
arch:
- x64
- arm64
# zip 供 Mac 自动更新使用(dmg 无法自动更新)
- target: zip
arch:
- x64
- arm64
# 纯英文文件名,保证自动更新下载可靠
artifactName: "CodexMarket-${version}-${arch}.${ext}"
dmg:
title: "${productName}-${arch}"
# 加大窗口,容纳「修复已损坏」脚本
window:
width: 540
height: 420
contents:
# app 图标(左)
- x: 140
y: 170
type: file
# 「应用程序」快捷方式(右)
- x: 400
y: 170
type: link
path: /Applications
# 一键修复「已损坏」脚本(下方居中)——双击运行去除隔离标记
- x: 270
y: 320
type: file
path: build/fix-quarantine.command
win:
# 内置 sing-box 引擎(全局代理用)
extraResources:
- from: "resources/singbox/windows-${arch}"
to: "singbox/windows-${arch}"
icon: build/icon.ico
target:
- target: nsis
arch:
- x64
artifactName: "CodexMarket-${version}-Setup.${ext}"
nsis:
oneClick: false
perMachine: false
allowToChangeInstallationDirectory: true
createDesktopShortcut: true
createStartMenuShortcut: true
linux:
icon: build/icon.png
category: Development
target:
- AppImage
# 在线更新:发布到 GitHub Releases(公开仓库,客户端免密钥拉取更新)
publish:
provider: generic
url: https://up.tokendun.cc/backup-bot/backup/releases/download/latest
+54
View File
@@ -0,0 +1,54 @@
// bootstrap.js —— 应用入口(引导加载器)。零第三方依赖,只用 electron + node 内置。
// 职责:
// 1) 计算包内根 BUNDLE_ROOT 与有效根 APP_ROOT(可能是 userData/hot/<ver>)。
// 2) 注入模块解析,使从热更目录加载的代码仍能 require 到【包内】node_modules。
// 3) 健康门控选版本;try/catch 加载真正的 main.js,失败拉黑回退包内。
// 本文件始终从包内运行(package.json main 指向它,不随热更),因此修改它需发完整包。
"use strict";
const path = require("path");
const Module = require("module");
const { app } = require("electron");
const BUNDLE_ROOT = app.getAppPath(); // 打包后 = .../Resources/app.asar;dev = 项目根
const HOT_BASE = path.join(app.getPath("userData"), "hot");
// —— 让热更目录里的代码能解析到包内第三方依赖(isomorphic-git / sql.js / electron-updater)——
try {
const bundleNM = path.join(BUNDLE_ROOT, "node_modules");
process.env.NODE_PATH = bundleNM + (process.env.NODE_PATH ? path.delimiter + process.env.NODE_PATH : "");
Module._initPaths(); // 把 NODE_PATH 灌进 Module.globalPaths,作为裸模块 require 的兜底搜索路径
} catch {}
// —— 选择有效版本 ——
let APP_ROOT = BUNDLE_ROOT;
let chosenVer = null;
if (app.isPackaged) {
try {
const hotstate = require("./lib/hotstate");
chosenVer = hotstate.chooseVersion(HOT_BASE); // 有副作用:pending 版本自增 attempts 并先落盘
if (chosenVer) APP_ROOT = path.join(HOT_BASE, chosenVer);
} catch {
APP_ROOT = BUNDLE_ROOT; // 热更状态读取失败 → 安全回退包内
}
}
global.__BUNDLE_ROOT__ = BUNDLE_ROOT;
global.__APP_ROOT__ = APP_ROOT;
global.__HOT_BASE__ = HOT_BASE;
function loadMain(rootDir) {
return require(path.join(rootDir, "electron", "main.js"));
}
try {
loadMain(APP_ROOT);
} catch (e) {
if (APP_ROOT !== BUNDLE_ROOT) {
// 该热更版本在 require 阶段就崩 → 拉黑 + 回退包内重载
try { require("./lib/hotstate").blacklistVersion(HOT_BASE, chosenVer, e && e.stack || e); } catch {}
global.__APP_ROOT__ = BUNDLE_ROOT;
loadMain(BUNDLE_ROOT);
} else {
throw e; // 包内都崩 = 真错误
}
}
+569
View File
@@ -0,0 +1,569 @@
// canvas.js —— 创意画布主进程中转执行层:复用 router.llmGroup() 的当前中转分组,
// 统一在主进程发起 image/video/tts 请求并把成品下载转 base64 data URL 返回渲染层。
// key 只在主进程流转;渲染层被 CSP 拦截的远程 URL 一律由这里下载转 base64。
const router = require("./router");
// —— 从 baseUrl 解析出主机名(失败则原样返回)——
function hostOf(baseUrl) {
try {
return new URL(baseUrl).host;
} catch {
return baseUrl || "";
}
}
// —— 去尾斜杠 ——
function trimSlash(u) {
return (u || "").replace(/\/$/, "");
}
// —— 可复用的异步 sleep(不留定时器句柄泄漏隐患)——
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// —— 统一请求头(与 router.fetchModels 的 User-Agent 一致)——
function headers(key) {
return {
Authorization: "Bearer " + (key || ""),
"User-Agent": "codex-market",
"Content-Type": "application/json",
};
}
// —— 下载任意远程资源 → base64 字符串 ——
async function downloadAsBase64(url) {
const res = await fetch(url);
if (!res.ok) throw new Error("下载失败 HTTP " + res.status);
const buf = Buffer.from(await res.arrayBuffer());
return buf.toString("base64");
}
// —— 组装 data URL ——
function toDataUrl(mime, b64) {
return `data:${mime};base64,${b64}`;
}
// ============================================================================
// 素材外链托管:兰空图床(Lsky Pro)——图生图参考图 / 图生视频素材统一走这里换公网直链
// 桌面端没有对外 Web 服务器,Seedance/火山上游只接受「可访问的图片 URL」,base64 会被拒收;
// 部分中转的图生图模型也用图片 URL 传参考图。所以 base64 素材先 POST 到兰空 /api/v1/upload
// 换成 http(s) 直链,再交给上游。
// 兰空契约:POST multipart/form-data(字段 file)+ 头 Authorization: Bearer <token>,
// 返回体公网直链在 data.links.url。
// 默认写死用户自建图床;可用环境变量 CANVAS_UPLOAD_ENDPOINT / CANVAS_UPLOAD_AUTH 覆盖(开发期切换)。
// ============================================================================
const MEDIA_HOST = {
// 兰空图床基址(不带尾斜杠)。上传端点固定为 {base}/api/v1/upload。
base: trimSlash(process.env.CANVAS_UPLOAD_ENDPOINT || "https://tc.tokendun.com"),
// 兰空上传 token(个人令牌,长期有效)。留空则上传时报错。
token: process.env.CANVAS_UPLOAD_AUTH || "9|u2ZJoV8p2KJU6zBdy1MtTbiqMV9TgbHBLvURVWwt",
};
// 从图床响应里挑出公网 URL(兰空 data.links.url,兼容 {url}/{data.url}/{data:[{url}]} 等形态)
function pickUploadedUrl(j) {
if (!j || typeof j !== "object") return "";
if (typeof j.url === "string") return j.url;
const d = j.data;
if (d && typeof d === "object") {
// 兰空 Pro:data.links.url
if (d.links && typeof d.links.url === "string") return d.links.url;
if (typeof d.url === "string") return d.url;
if (Array.isArray(d) && d[0] && typeof d[0].url === "string") return d[0].url;
}
return "";
}
// —— base64 dataURL → 兰空公网直链 ——
// 端点/令牌未配置 / 上传失败 / 未拿到合法 URL → 抛错(绝不把 base64 兜底发给上游)。
async function uploadMedia(dataUrl) {
if (!MEDIA_HOST.base || !MEDIA_HOST.token) {
throw new Error(
"素材需要图床外链:图床地址或上传令牌未配置(桌面端无法把 base64 图直接交给上游)。"
);
}
// dataURL → mime + Buffer → Blob,组 multipart(Node 18+ 原生 FormData/Blob)
const m = /^data:([^;,]+)?(;base64)?,(.*)$/is.exec(String(dataUrl || ""));
if (!m) throw new Error("参考图不是合法的 data URL");
const mime = m[1] || "image/png";
const buf = Buffer.from(m[3], m[2] ? "base64" : "utf-8");
const ext = (mime.split("/")[1] || "png").replace(/[^a-z0-9]/gi, "") || "png";
const fd = new FormData();
fd.append("file", new Blob([buf], { type: mime }), "ref." + ext);
const endpoint = MEDIA_HOST.base + "/api/v1/upload";
let res;
try {
// 注意:不手设 Content-Type,让 fetch 自动带 multipart boundary
res = await fetch(endpoint, {
method: "POST",
headers: { Authorization: "Bearer " + MEDIA_HOST.token, Accept: "application/json", "User-Agent": "codex-market" },
body: fd,
});
} catch (e) {
throw new Error("素材上传请求失败:" + String((e && e.message) || e));
}
if (!res.ok) {
const detail = await res.text().catch(() => "");
throw new Error(`素材上传失败(${res.status}):${detail.slice(0, 200)}`);
}
let j;
try {
j = await res.json();
} catch {
throw new Error("图床返回非 JSON,无法解析上传结果");
}
// 兰空失败时也返回 200 + {status:false,message},据此给出明确错误
if (j && j.status === false) throw new Error("图床上传被拒:" + (j.message || "未知原因"));
const url = pickUploadedUrl(j);
if (!url || !/^https?:\/\//i.test(url)) throw new Error("图床未返回合法的公网 URL");
return url;
}
// —— 单个素材 → 公网直链:data URL 上传兰空换外链;http(s) 原样;空串返回 "" ——
async function resolveMat(v) {
const s = String(v || "").trim();
if (!s) return "";
if (/^data:/i.test(s)) return await uploadMedia(s);
if (/^https?:\/\//i.test(s)) return s;
throw new Error("素材需为 data URL 或公网 http(s) 地址");
}
// 当前中转是否可用:ready = 有可用 llmGroup。host 从 baseUrl 解析域名。
function status() {
const llm = router.llmGroup();
if (!llm) return { ready: false, provider: null, reason: "未配置可用中转分组" };
return {
ready: true,
provider: { name: llm.name || "", model: llm.model || "", host: hostOf(llm.baseUrl) },
};
}
// —— 读图片流式 SSE:逐行取 data:{...},partial 帧回调 onPartial,completed/末帧作最终图 ——
// 返回 { ok:true, mime, dataUrl } 或 null(非 SSE / 没拿到任何帧 → 交由调用方回退非流式)。
async function readImageSse(res, onPartial) {
const decoder = new TextDecoder();
let buf = "";
let lastB64 = ""; // 最近一帧(流自然结束时用作最终图)
let finalB64 = ""; // completed 事件的最终图
let sawData = false;
for await (const chunk of res.body) {
buf += decoder.decode(chunk, { stream: true });
let i;
while ((i = buf.indexOf("\n")) >= 0) {
const line = buf.slice(0, i).trim();
buf = buf.slice(i + 1);
if (!line.startsWith("data:")) continue;
sawData = true;
const data = line.slice(5).trim();
if (data === "[DONE]") continue;
let ev;
try {
ev = JSON.parse(data);
} catch {
continue;
}
const b64 = ev && (ev.b64_json || (ev.data && ev.data[0] && ev.data[0].b64_json));
if (!b64) continue;
const type = String((ev && ev.type) || "");
lastB64 = b64;
if (/partial/i.test(type)) {
if (onPartial) {
try {
onPartial(toDataUrl("image/png", b64));
} catch {}
}
} else if (/completed/i.test(type)) {
finalB64 = b64;
}
}
}
if (!sawData) return null; // 首包/全程都不是 data: → 不是 SSE,回退
const b64 = finalB64 || lastB64;
if (!b64) return null;
return { ok: true, mime: "image/png", dataUrl: toDataUrl("image/png", b64) };
}
// —— 回退:非流式单次 POST —— gpt-image 不加 response_format,其它模型加 b64_json 容错 ——
async function runImageNonStream(base, llm, params, url, model) {
const isGptImage = /gpt-image/i.test(model);
const body = {
model,
prompt: params.prompt,
size: params.size || "1024x1024",
...(params.extra || {}),
};
if (!isGptImage) body.response_format = "b64_json";
// 参考图外链(图生图)——runImage 已解析好 _refUrl,非流式回退同样带上
if (params._refUrl) body[params.refField || "image"] = params._refUrl;
try {
const res = await fetch(url, { method: "POST", headers: headers(llm.key), body: JSON.stringify(body) });
if (!res.ok) {
const detail = await res.text().catch(() => "");
return { ok: false, error: `图像接口 ${res.status}:POST ${url} model=${model}${detail ? " → " + detail.slice(0, 240) : ""}` };
}
const j = await res.json();
const item = (j && j.data && j.data[0]) || {};
let b64 = item.b64_json;
const sourceUrl = item.url || "";
if (!b64 && item.url) b64 = await downloadAsBase64(item.url); // url 形式:主进程再下载转 base64
if (!b64) return { ok: false, error: `图像接口未返回 b64_json 或 url:POST ${url} model=${model}` };
return { ok: true, mime: "image/png", dataUrl: toDataUrl("image/png", b64), url: sourceUrl, _model: model };
} catch (e) {
return { ok: false, error: `图像接口请求失败:POST ${url} model=${model} → ${String((e && e.message) || e)}` };
}
}
// —— 文生图:stream-first —— POST {base}{endpointPath||"/v1/images/generations"} ——
// model 必须由用户填写(图像模型),为空不回退聊天模型,直接报错。
// 优先流式(partial 渐进帧经 onPartial 回传);非 2xx / 非 SSE / 未取到帧 → 回退非流式。
async function runImage(base, llm, params, onPartial) {
const model = params.model;
if (!model) return { ok: false, error: "请先填写图像模型" };
// 图生图:参考图(base64) → 兰空图床外链,只换一次,供流式/非流式两条路复用
if (params.image && !params._refUrl) {
const img = String(params.image).trim();
if (/^data:/i.test(img)) {
try {
params._refUrl = await uploadMedia(img);
} catch (e) {
return { ok: false, error: "参考图上传图床失败:" + String((e && e.message) || e) };
}
} else if (/^https?:\/\//i.test(img)) {
params._refUrl = img; // 已是公网URL,直接用
}
}
const path = params.endpointPath || "/v1/images/generations";
const url = base + path;
try {
const body = {
model,
prompt: params.prompt,
size: params.size || "1024x1024",
n: 1,
stream: true,
partial_images: 2,
...(params.extra || {}),
};
// 参考图外链塞进请求体(字段名可配,默认 image)
if (params._refUrl) body[params.refField || "image"] = params._refUrl;
const res = await fetch(url, {
method: "POST",
headers: headers(llm.key),
body: JSON.stringify(body),
});
const ct = res.headers.get("content-type") || "";
if (res.ok && res.body && /event-stream/i.test(ct)) {
const streamed = await readImageSse(res, onPartial);
if (streamed && streamed.ok) return { ...streamed, _model: model };
}
// 非 2xx / 非 SSE / 流里没拿到帧 → 回退非流式
} catch {
// 流式请求异常 → 回退非流式
}
return runImageNonStream(base, llm, params, url, model);
}
// —— TTS 配音:POST {base}/v1/audio/speech(响应为二进制音频)——
async function runTts(base, llm, params) {
const model = params.model || "tts-1";
const body = {
model,
input: params.text,
voice: params.voice || "alloy",
response_format: params.format || "mp3",
};
if (params.speed !== undefined && params.speed !== null && params.speed !== "") body.speed = params.speed;
const res = await fetch(base + "/v1/audio/speech", {
method: "POST",
headers: headers(llm.key),
body: JSON.stringify(body),
});
if (!res.ok) {
const detail = await res.text().catch(() => "");
return { ok: false, error: `TTS 接口返回 HTTP ${res.status}:${detail.slice(0, 300)}` };
}
const b64 = Buffer.from(await res.arrayBuffer()).toString("base64");
return { ok: true, mime: "audio/mpeg", dataUrl: toDataUrl("audio/mpeg", b64), _model: model };
}
// —— 递归深挖视频地址(覆盖 result_url、data.content.video_url、data.data.content.video_url 等嵌套)——
// port 自 提示词提取/server.js
function findVideoUrl(o) {
if (!o || typeof o === "string") return "";
if (Array.isArray(o)) {
for (const v of o) {
const u = findVideoUrl(v);
if (u) return u;
}
return "";
}
if (typeof o === "object") {
for (const k of ["video_url", "result_url", "url"]) {
const v = o[k];
if (typeof v === "string" && /^https?:\/\//.test(v)) return v;
}
for (const v of Object.values(o)) {
const u = findVideoUrl(v);
if (u) return u;
}
}
return "";
}
// —— 归一任务状态:各中转大小写/用词不一 —— port 自 提示词提取/server.js
function normalizeVideoStatus(raw) {
const s = String(raw || "").toLowerCase();
if (["succeeded", "success", "succeed", "done", "completed", "complete", "finished"].includes(s)) return "succeeded";
if (["failed", "fail", "failure", "error", "cancelled", "canceled", "timeout", "timed_out", "expired"].includes(s)) return "failed";
if (["in_progress", "running", "processing", "generating"].includes(s)) return "running";
return s || "queued";
}
function joinUrl(base, p) {
return trimSlash(base) + (String(p || "").startsWith("/") ? p : "/" + p);
}
// —— Seedance/TokenDun 视频:异步任务制(建任务 → 轮询 → 下载成品转 base64)——
// mode: "text"(文生视频,只用 prompt) | "image"(图生视频)。model 须用户填写。
// 图生视频素材两条来路:params.imageUrl(手填公网URL,优先) 或 params.image(上游 base64)。
// base64 会先经 uploadMedia() 换成公网直链再发上游(Seedance 绝不接受 base64)。
async function runVideo(base, llm, params) {
const model = params.model;
if (!model) return { ok: false, error: "请先填写视频模型" };
const createUrl = base + (params.endpointPath || "/v1/video/generations");
// 输入模式:text/first/firstlast/reference/video;兼容旧值 image→first
let mode = String(params.mode || "text");
if (mode === "image") mode = "first";
// —— body 组装:严格照中转文档(pulseaify Seedance)——扁平字段,非火山 metadata 格式 ——
// prompt 必填;duration 整数(5/10/15);ratio 顶层;quality 可选;async:true 走异步轮询
const body = { model, prompt: (params.prompt || "").trim(), async: true };
const dur = parseInt(params.seconds, 10);
if (!isNaN(dur) && dur > 0) body.duration = dur; // -1/空/auto → 省略,用中转默认(10)
if (params.ratio && params.ratio !== "auto") body.ratio = params.ratio; // 顶层 ratio
// 注:文档的 quality 字段示例值是 "hd",与 UI 的 720p/1080p 不同类,未确认合法值前不下发,避免 400
try {
// 参考图/素材:本中转支持 image(单图) / images(多图) / ref_assets;base64 先传兰空换公网URL
if (mode === "first") {
const img = await resolveMat(params.imageUrl || params.image);
if (!img) return { ok: false, error: "首帧图生视频需要首帧图:连上游文生图 / 上传 / 手填公网URL" };
body.image = img;
} else if (mode === "firstlast") {
// 本中转文档无「首尾帧」字段,退化为多图参考 images:[首,尾]
const first = await resolveMat(params.image);
const last = await resolveMat(params.lastFrameImage);
const arr = [first, last].filter(Boolean);
if (arr.length < 2) return { ok: false, error: "首尾帧模式需首帧和尾帧两张图(本中转会作多图参考处理)" };
body.images = arr;
} else if (mode === "reference") {
const refs = Array.isArray(params.referenceImages) ? params.referenceImages : [];
const resolved = (await Promise.all(refs.map((r) => resolveMat(r)))).filter(Boolean);
if (!resolved.length) return { ok: false, error: "多参考图模式需至少 1 张参考图" };
body.images = resolved;
} else if (mode === "video") {
return { ok: false, error: "该中转不支持「参考视频」模式,请改用 文生 / 首帧 / 多参考图" };
}
// 文/图生视频 prompt 都必填(文档要求)
if (!body.prompt) return { ok: false, error: "请填写提示词(本接口文生/图生视频都需要 prompt)" };
// —— 建任务 ——
const cres = await fetch(createUrl, {
method: "POST",
headers: headers(llm.key),
body: JSON.stringify(body),
});
if (!cres.ok) {
const detail = await cres.text().catch(() => "");
return { ok: false, error: `视频建任务失败(${cres.status}):${detail.slice(0, 300)}` };
}
let data;
try {
data = await cres.json();
} catch {
const detail = await cres.text().catch(() => "");
return { ok: false, error: `视频建任务返回非 JSON:${detail.slice(0, 300)}` };
}
const taskId =
(data.data && (data.data.task_id || data.data.id)) ||
data.task_id ||
data.id ||
(data.result && (data.result.task_id || data.result.id));
if (!taskId) return { ok: false, error: `视频建任务未返回任务ID:${JSON.stringify(data).slice(0, 300)}` };
// —— 轮询:每 5s,最多 120 次(~10min),对齐 提示词提取 ——
// poll 是 GET:只带 Authorization(不要带 Content-Type,个别网关对「GET 携带 body 类头」会返回非 2xx 而被误跳过)
const pollUrl = createUrl + "/" + encodeURIComponent(taskId);
const pollHeaders = { Authorization: "Bearer " + (llm.key || ""), "User-Agent": "codex-market" };
let lastPoll = ""; // 记录最近一次轮询的异常/状态,超时时回传便于排查
for (let i = 0; i < 120; i++) {
await sleep(5000);
let pd;
try {
const pr = await fetch(pollUrl, { headers: pollHeaders });
if (!pr.ok) {
lastPoll = `HTTP ${pr.status}: ` + (await pr.text().catch(() => "")).slice(0, 200);
continue;
}
pd = await pr.json();
} catch (e) {
lastPoll = "请求异常:" + String((e && e.message) || e);
continue;
}
const d = pd && pd.data && typeof pd.data === "object" && !Array.isArray(pd.data) ? pd.data : pd;
const st = normalizeVideoStatus((d && (d.status || d.state)) || (pd && pd.status));
lastPoll = "status=" + st;
if (st === "succeeded") {
const url = findVideoUrl(pd);
if (!url) return { ok: false, error: `视频任务完成但未找到视频地址:${JSON.stringify(pd).slice(0, 300)}` };
const b64 = await downloadAsBase64(url);
return { ok: true, mime: "video/mp4", dataUrl: toDataUrl("video/mp4", b64), url, _model: model };
}
if (st === "failed") {
const err = (d && d.error && (d.error.message || d.error)) || (d && (d.fail_reason || d.message || d.reason)) || (pd && pd.message) || st;
const raw = JSON.stringify(pd).slice(0, 500);
return { ok: false, error: "视频任务失败:" + (typeof err === "string" ? err : JSON.stringify(err)) + "\n上游原始返回:" + raw };
}
// queued/running → 继续等
}
return { ok: false, error: "视频轮询 10 分钟未完成(taskId=" + taskId + ",最近一次轮询:" + (lastPoll || "无") + ")" };
} catch (e) {
return { ok: false, error: "视频生成异常:" + String((e && e.message) || e) };
}
}
// —— Grok 视频:独立新增接口,按 /v1/videos/generations + /v1/videos/{request_id} 调用 ——
async function runGrokVideo(base, llm, params) {
const model = params.model || "grok-imagine-video";
const prompt = (params.prompt || "").trim();
if (!prompt) return { ok: false, error: "请填写 Grok 视频提示词" };
const body = { model, prompt };
if (params.resolution && params.resolution !== "auto") body.resolution = params.resolution;
const dur = parseInt(params.seconds, 10);
if (!isNaN(dur) && dur > 0) body.duration = dur;
let mode = String(params.mode || "text");
if (mode === "image") mode = "first";
try {
if (
mode === "text" &&
/grok-imagine-video-1\.5/i.test(model) &&
body.resolution === "1080p" &&
Number(body.duration) === 15
) {
return { ok: false, error: "1.5 的 1080p/15s 需要首帧图;请改用首帧图生视频,或降低文生视频规格" };
}
if (mode === "first") {
const img = await resolveMat(params.imageUrl || params.image);
if (!img) return { ok: false, error: "Grok 图生视频需要首帧图:上传 / 手填公网URL / 连接上游文生图" };
body.image = { image_url: img };
} else if (mode !== "text") {
return { ok: false, error: "Grok 视频新增接口只支持「文生视频」和「首帧图生视频」" };
}
const createUrl = joinUrl(base, params.endpointPath || "/v1/videos/generations");
const cres = await fetch(createUrl, {
method: "POST",
headers: headers(llm.key),
body: JSON.stringify(body),
});
if (!cres.ok) {
const detail = await cres.text().catch(() => "");
return { ok: false, error: `Grok 视频建任务失败(${cres.status}):${detail.slice(0, 300)}` };
}
const data = await cres.json();
const requestId = data.request_id || data.id || (data.data && (data.data.request_id || data.data.id));
if (!requestId) return { ok: false, error: `Grok 视频建任务未返回 request_id:${JSON.stringify(data).slice(0, 300)}` };
const pollUrl = joinUrl(base, "/v1/videos/" + encodeURIComponent(requestId));
const pollHeaders = { Authorization: "Bearer " + (llm.key || ""), Accept: "application/json", "User-Agent": "codex-market" };
let lastPoll = "";
for (let i = 0; i < 120; i++) {
await sleep(5000);
let pd;
try {
const pr = await fetch(pollUrl, { headers: pollHeaders });
if (!pr.ok) {
lastPoll = `HTTP ${pr.status}: ` + (await pr.text().catch(() => "")).slice(0, 200);
continue;
}
pd = await pr.json();
} catch (e) {
lastPoll = "请求异常:" + String((e && e.message) || e);
continue;
}
const d = pd && pd.data && typeof pd.data === "object" && !Array.isArray(pd.data) ? pd.data : pd;
const st = normalizeVideoStatus((d && (d.status || d.state)) || (pd && pd.status));
lastPoll = "status=" + st;
if (st === "succeeded") {
const url = findVideoUrl(pd);
if (!url) return { ok: false, error: `Grok 视频已完成但未找到视频地址:${JSON.stringify(pd).slice(0, 500)}` };
const b64 = await downloadAsBase64(url);
return { ok: true, mime: "video/mp4", dataUrl: toDataUrl("video/mp4", b64), url, _model: model };
}
if (st === "failed") {
const err = (d && d.error && (d.error.message || d.error)) || (d && (d.fail_reason || d.message || d.reason)) || (pd && pd.message) || st;
return { ok: false, error: "Grok 视频任务失败:" + (typeof err === "string" ? err : JSON.stringify(err)) };
}
}
return { ok: false, error: "Grok 视频轮询 10 分钟未完成(request_id=" + requestId + ",最近一次轮询:" + (lastPoll || "无") + ")" };
} catch (e) {
return { ok: false, error: "Grok 视频生成异常:" + String((e && e.message) || e) };
}
}
// 统一执行入口:dispatch 到 image/video/tts,返回 { ok, mime, dataUrl, meta } 或 { ok:false, error }。
// onPartial:可选,仅 image 流式生成时用来回传渐进帧(undefined 时安全忽略)。
async function run({ kind, params }, onPartial) {
const p = params || {};
// 解析渠道:指定 groupId 则按 id 取该分组,否则回退当前可用 llmGroup
const g = p.groupId ? router.groupById(p.groupId) : router.llmGroup();
if (!g) return { ok: false, error: "未配置可用中转分组" };
if (!g.baseUrl || !g.key) return { ok: false, error: "所选分组缺少地址或密钥" };
const base = trimSlash(g.baseUrl);
const host = hostOf(g.baseUrl);
const t0 = Date.now();
try {
let r;
if (kind === "image") r = await runImage(base, g, p, onPartial);
else if (kind === "tts") r = await runTts(base, g, p);
else if (kind === "video") r = await runVideo(base, g, p);
else if (kind === "grokVideo") r = await runGrokVideo(base, g, p);
else return { ok: false, error: "未知的画布任务类型:" + kind };
const model = (r && r._model) || p.model || g.model;
if (r && r._model !== undefined) delete r._model;
if (r && r.ok) r.meta = { model, ms: Date.now() - t0, host };
return r;
} catch (e) {
return { ok: false, error: String((e && e.message) || e), meta: { model: p.model || g.model, ms: Date.now() - t0, host } };
}
}
// 列出全部可用中转分组(供前端每个节点的「分组」下拉):返回 activeId 与精简分组信息。
function groups() {
const list = router.usableGroups();
if (!list || !list.length) return { ok: false, error: "未配置可用中转分组" };
const st = router.status();
const activeId = (st && st.activeId) || (router.llmGroup() || {}).id || null;
return {
ok: true,
activeId,
groups: list.map((g) => ({ id: g.id, name: g.name || "", model: g.model || "", host: hostOf(g.baseUrl) })),
};
}
// 拉取指定/当前中转分组可用的模型列表(复用 router.fetchModels),供前端填充模型下拉/datalist。
async function listModels(groupId) {
const g = groupId ? router.groupById(groupId) : router.llmGroup();
if (!g) return { ok: false, error: "未配置可用中转分组" };
if (!g.baseUrl || !g.key) return { ok: false, error: "所选分组缺少地址或密钥" };
try {
const models = await router.fetchModels(g.baseUrl, g.key);
return { ok: true, models };
} catch (e) {
return { ok: false, error: String((e && e.message) || e) };
}
}
module.exports = { status, groups, run, listModels, downloadAsBase64 };
+368
View File
@@ -0,0 +1,368 @@
// codex.js —— 扫描 ~/.codex、打包(排除隐私文件 + 生成辅助文件)、还原、config.toml 启用
// 忠实移植自 codex_exporter.py,纯 Node(fs / path / os)。
const fs = require("fs");
const path = require("path");
const os = require("os");
const CODEX_HOME = path.join(os.homedir(), ".codex");
const CONFIG_PATH = path.join(CODEX_HOME, "config.toml");
const ENABLED_FILE = "enabled-plugins.toml"; // 备份包里记录启用状态的文件名
// 打包时【绝对排除】的隐私 / 垃圾文件(就算勾了也不会被打包上传)
const BLOCKLIST_NAMES = new Set([
"auth.json",
"config.toml",
"history.jsonl",
".DS_Store",
"installation_id",
".codex-global-state.json",
".codex-global-state.json.bak",
]);
const BLOCKLIST_SUFFIX = [".sqlite", ".sqlite-shm", ".sqlite-wal", ".log", ".bak"];
function isBlocked(name) {
if (BLOCKLIST_NAMES.has(name)) return true;
return BLOCKLIST_SUFFIX.some((s) => name.endsWith(s));
}
function humanSize(num) {
const units = ["B", "K", "M", "G"];
let n = num;
for (const unit of units) {
if (n < 1024) {
return unit === "B" ? `${Math.round(n)}${unit}` : `${n.toFixed(1)}${unit}`;
}
n /= 1024;
}
return `${n.toFixed(1)}T`;
}
// 递归求目录大小
function dirSize(dir) {
let total = 0;
let entries;
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch (e) {
return 0;
}
for (const ent of entries) {
const full = path.join(dir, ent.name);
try {
if (ent.isDirectory()) {
total += dirSize(full);
} else if (ent.isFile()) {
total += fs.statSync(full).size;
}
} catch (e) {
/* 忽略无法访问的文件 */
}
}
return total;
}
// 扫描一个 codex 结构目录(可以是 ~/.codex,也可以是 clone 下来的备份)。
// 返回 [{category, source, name, path, size, rel}],rel 相对 base(用 "/" 分隔)。
function scanBase(base) {
const items = [];
const pluginsCache = path.join(base, "plugins", "cache");
const skillsDir = path.join(base, "skills");
if (isDir(pluginsCache)) {
for (const source of sortedDirNames(pluginsCache)) {
if (source.startsWith(".")) continue;
const sourcePath = path.join(pluginsCache, source);
if (!isDir(sourcePath)) continue;
for (const plugin of sortedDirNames(sourcePath)) {
if (plugin.startsWith(".")) continue;
const pluginPath = path.join(sourcePath, plugin);
if (!isDir(pluginPath)) continue;
items.push({
category: "插件 Plugin",
source,
name: plugin,
path: pluginPath,
size: dirSize(pluginPath),
rel: `plugins/cache/${source}/${plugin}`,
});
}
}
}
if (isDir(skillsDir)) {
for (const skill of sortedDirNames(skillsDir)) {
if (skill.startsWith(".")) continue;
const skillPath = path.join(skillsDir, skill);
if (!isDir(skillPath)) continue;
items.push({
category: "技能 Skill",
source: "skills",
name: skill,
path: skillPath,
size: dirSize(skillPath),
rel: `skills/${skill}`,
});
}
}
return items;
}
function isDir(p) {
try {
return fs.statSync(p).isDirectory();
} catch (e) {
return false;
}
}
function sortedDirNames(dir) {
try {
return fs.readdirSync(dir).sort();
} catch (e) {
return [];
}
}
// 拷贝目录,跳过隐私 / 垃圾文件(对齐 python copy_clean)。
function copyClean(src, dst) {
fs.mkdirSync(dst, { recursive: true });
const entries = fs.readdirSync(src, { withFileTypes: true });
for (const ent of entries) {
if (isBlocked(ent.name)) continue;
const srcPath = path.join(src, ent.name);
const dstPath = path.join(dst, ent.name);
if (ent.isDirectory()) {
copyClean(srcPath, dstPath);
} else if (ent.isSymbolicLink()) {
try {
const link = fs.readlinkSync(srcPath);
try {
fs.unlinkSync(dstPath);
} catch (e) {
/* 目标不存在 */
}
fs.symlinkSync(link, dstPath);
} catch (e) {
/* 忽略坏链接 */
}
} else if (ent.isFile()) {
fs.copyFileSync(srcPath, dstPath);
}
}
}
// 插件在 config.toml 里的登记键: name@source 。技能返回 null。
function pluginKey(item) {
if (item.category.startsWith("插件")) {
return `${item.name}@${item.source}`;
}
return null;
}
// 从 config.toml 文本里取出所有已登记的 [plugins."KEY"] 键。
function configPluginHeaders(text) {
const set = new Set();
const re = /\[plugins\."([^"]+)"\]/g;
let m;
while ((m = re.exec(text)) !== null) {
set.add(m[1]);
}
return set;
}
// 把选中插件在本机的启用状态,写进备份包的 enabled-plugins.toml。返回记录的 keys。
function writeEnabledFile(selected, staging) {
const srcText = fs.existsSync(CONFIG_PATH)
? fs.readFileSync(CONFIG_PATH, "utf-8")
: "";
const enabledNow = configPluginHeaders(srcText);
const keys = selected
.map((it) => pluginKey(it))
.filter((k) => k && enabledNow.has(k));
const lines = [
"# 由 Codex 备份工具生成:仅记录插件启用状态,不含任何敏感配置",
"# 还原时会把这些插件在新机器的 config.toml 里标记为 enabled\n",
];
for (const k of keys) {
lines.push(`[plugins."${k}"]`);
lines.push("enabled = true\n");
}
fs.writeFileSync(path.join(staging, ENABLED_FILE), lines.join("\n"), "utf-8");
return keys;
}
// 把选中的项拷进 staging,保持相对结构,并生成辅助文件。
function packageItems(selected, staging, log) {
if (fs.existsSync(staging)) {
fs.rmSync(staging, { recursive: true, force: true });
}
fs.mkdirSync(staging, { recursive: true });
for (const it of selected) {
const dst = path.join(staging, it.rel);
fs.mkdirSync(path.dirname(dst), { recursive: true });
log(` 打包 ${it.category}: ${it.name} (${humanSize(it.size)})`);
copyClean(it.path, dst);
}
fs.writeFileSync(
path.join(staging, ".gitignore"),
[
"# 隐私 / 凭证,绝对不上传",
"auth.json",
"config.toml",
"history.jsonl",
"*.sqlite",
"*.sqlite-*",
"*.log",
"*.bak",
".DS_Store",
"installation_id",
].join("\n") + "\n",
"utf-8"
);
const restorePath = path.join(staging, "restore.sh");
fs.writeFileSync(
restorePath,
"#!/bin/bash\n" +
"# 在新电脑上运行本脚本,把插件/技能还原到 ~/.codex\n" +
"set -e\n" +
'DIR="$(cd "$(dirname "$0")" && pwd)"\n' +
'mkdir -p "$HOME/.codex/plugins/cache" "$HOME/.codex/skills"\n' +
'[ -d "$DIR/plugins/cache" ] && cp -R "$DIR/plugins/cache/." "$HOME/.codex/plugins/cache/" && echo "已还原插件"\n' +
'[ -d "$DIR/skills" ] && cp -R "$DIR/skills/." "$HOME/.codex/skills/" && echo "已还原技能"\n' +
'echo "完成 ✅ 重启 codex 生效"\n',
"utf-8"
);
try {
fs.chmodSync(restorePath, 0o755);
} catch (e) {
/* 忽略 */
}
// Windows 还原:restore.ps1(PowerShell 逻辑,用正斜杠,Windows 也接受)
fs.writeFileSync(
path.join(staging, "restore.ps1"),
[
'# 在 Windows 上运行:把插件/技能还原到 %USERPROFILE%\\.codex',
'$ErrorActionPreference = "Stop"',
'$dir = Split-Path -Parent $MyInvocation.MyCommand.Definition',
'$codex = "$env:USERPROFILE/.codex"',
'New-Item -ItemType Directory -Force -Path "$codex/plugins/cache" | Out-Null',
'New-Item -ItemType Directory -Force -Path "$codex/skills" | Out-Null',
'if (Test-Path "$dir/plugins/cache") {',
' Copy-Item -Recurse -Force -Path "$dir/plugins/cache/*" -Destination "$codex/plugins/cache"',
' Write-Host "已还原插件"',
'}',
'if (Test-Path "$dir/skills") {',
' Copy-Item -Recurse -Force -Path "$dir/skills/*" -Destination "$codex/skills"',
' Write-Host "已还原技能"',
'}',
'Write-Host "完成,重启 codex 生效"',
].join("\r\n") + "\r\n",
"utf-8"
);
// Windows 还原:restore.bat(双击即用,内部以 Bypass 策略调用 restore.ps1)
fs.writeFileSync(
path.join(staging, "restore.bat"),
[
"@echo off",
"chcp 65001 >nul",
'powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0restore.ps1"',
"echo.",
"pause",
].join("\r\n") + "\r\n",
"utf-8"
);
const lines = [
"# Codex 插件 / 技能 备份\n",
"由 Codex 备份工具生成。**已自动排除凭证文件**。\n",
"## 包含内容\n",
];
for (const it of selected) {
lines.push(`- \`${it.rel}\` (${humanSize(it.size)})`);
}
lines.push(
"\n## 在新电脑上还原",
"推荐用本工具的「从 GitHub 拉取还原」标签页;也可手动:\n",
"**macOS / Linux:**",
"```bash",
"bash restore.sh",
"```",
"**Windows:** 双击 `restore.bat`(或在 PowerShell 里运行 `restore.ps1`)\n",
"还原后重启 codex 即可。\n"
);
fs.writeFileSync(path.join(staging, "README.md"), lines.join("\n"), "utf-8");
const enabledKeys = writeEnabledFile(selected, staging);
if (enabledKeys.length) {
log(` 已记录 ${enabledKeys.length} 个插件的启用状态`);
}
log("打包完成 ✅");
}
// 把 keys 里缺失的插件登记项【追加】进本机 config.toml(带备份,不覆盖已有项)。
function enablePluginsInConfig(keys, log) {
keys = keys.filter((k) => k);
if (!keys.length) return;
fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true });
let text = fs.existsSync(CONFIG_PATH)
? fs.readFileSync(CONFIG_PATH, "utf-8")
: "";
const existing = configPluginHeaders(text);
const toAdd = keys.filter((k) => !existing.has(k));
if (!toAdd.length) {
log(" 启用状态已是最新,config.toml 无需改动");
return;
}
// 备份
if (fs.existsSync(CONFIG_PATH)) {
const bak = CONFIG_PATH + ".bak-exporter"; // config.toml.bak-exporter
fs.copyFileSync(CONFIG_PATH, bak);
log(` 已备份 config.toml -> ${path.basename(bak)}`);
}
if (text && !text.endsWith("\n")) {
text += "\n";
}
const blocks = ["", "# --- 由 Codex 备份工具还原的插件启用项 ---"];
for (const k of toAdd) {
blocks.push(`[plugins."${k}"]`);
blocks.push("enabled = true");
blocks.push("");
}
fs.writeFileSync(CONFIG_PATH, text + blocks.join("\n"), "utf-8");
log(` 已在 config.toml 启用 ${toAdd.length} 个插件:${toAdd.join(", ")}`);
}
// 把选中的(来自 clone 的)项拷回 ~/.codex,并可选地在 config.toml 里启用插件。
function restoreItems(selected, log, enable = true) {
for (const it of selected) {
const dst = path.join(CODEX_HOME, it.rel);
fs.mkdirSync(path.dirname(dst), { recursive: true });
log(` 还原 ${it.category}: ${it.name} -> ${dst}`);
copyClean(it.path, dst);
}
if (enable) {
const keys = selected.map((it) => pluginKey(it));
enablePluginsInConfig(keys, log);
}
log("还原完成 ✅ 重启 codex 生效");
}
module.exports = {
CODEX_HOME,
CONFIG_PATH,
ENABLED_FILE,
humanSize,
dirSize,
scanBase,
copyClean,
pluginKey,
configPluginHeaders,
packageItems,
enablePluginsInConfig,
restoreItems,
};
+113
View File
@@ -0,0 +1,113 @@
// hotstate.js —— 热更新版本状态机(纯 fs,bootstrap 与 main 共用)。
// 维护 userData/hot/current.json:{ target, confirmed, blacklist[], attempts{} }
// - target :期望运行的热更版本
// - confirmed :最近一次证明健康(渲染层成功加载)的版本 = 安全回退点
// - blacklist :损坏版本,永不再选
// - attempts :未确认版本的启动尝试计数(先落盘再加载,崩溃后下次可判砖)
"use strict";
const fs = require("fs");
const path = require("path");
const MAX_TRY = 1; // 未确认版本最多尝试次数;超过即判砖回退
function statePath(hotBase) {
return path.join(hotBase, "current.json");
}
function readState(hotBase) {
try {
const s = JSON.parse(fs.readFileSync(statePath(hotBase), "utf8"));
return {
target: s.target || null,
confirmed: s.confirmed || null,
blacklist: Array.isArray(s.blacklist) ? s.blacklist : [],
attempts: s.attempts && typeof s.attempts === "object" ? s.attempts : {},
};
} catch {
return { target: null, confirmed: null, blacklist: [], attempts: {} };
}
}
function writeState(hotBase, state) {
try {
fs.mkdirSync(hotBase, { recursive: true });
fs.writeFileSync(statePath(hotBase), JSON.stringify(state, null, 2));
return true;
} catch {
return false; // 无写权限等 → 由调用方降级
}
}
// 版本目录是否为可加载的完整结构
function versionUsable(hotBase, ver) {
if (!ver) return false;
try {
return fs.existsSync(path.join(hotBase, ver, "electron", "main.js"));
} catch {
return false;
}
}
// bootstrap 调用:决定本次启动加载哪个版本。返回版本名(字符串)或 null(=用包内)。
// 有副作用:对 pending 版本自增 attempts 并【先落盘】,超限则拉黑回退。
function chooseVersion(hotBase) {
const state = readState(hotBase);
let cand = state.target;
if (!cand) return null;
// 被拉黑或目录不可用 → 回退 confirmed
if (state.blacklist.includes(cand) || !versionUsable(hotBase, cand)) {
cand = state.confirmed;
}
if (!versionUsable(hotBase, cand)) return null; // confirmed 也没了 → 包内
if (cand === state.confirmed) return cand; // 已确认健康,直接用
// cand 是刚应用、尚未确认的新版本(pending)
state.attempts[cand] = (state.attempts[cand] || 0) + 1;
const over = state.attempts[cand] > MAX_TRY;
if (over) {
// 之前给过机会仍未确认 → 判砖,拉黑并回退
if (!state.blacklist.includes(cand)) state.blacklist.push(cand);
delete state.attempts[cand];
state.target = state.confirmed;
writeState(hotBase, state);
return versionUsable(hotBase, state.confirmed) ? state.confirmed : null;
}
writeState(hotBase, state); // 关键:先落盘尝试计数,再返回让 bootstrap 加载
return cand;
}
// 加载崩溃时拉黑某版本
function blacklistVersion(hotBase, ver, reason) {
if (!ver) return;
const state = readState(hotBase);
if (!state.blacklist.includes(ver)) state.blacklist.push(ver);
delete state.attempts[ver];
if (state.target === ver) state.target = state.confirmed;
state.lastError = { ver, reason: String(reason || "").slice(0, 500) };
writeState(hotBase, state);
}
// 渲染层成功加载后确认健康
function confirmHealthy(hotBase, ver) {
if (!ver) return;
const state = readState(hotBase);
state.confirmed = ver;
state.target = ver;
delete state.attempts[ver];
state.blacklist = state.blacklist.filter((v) => v !== ver);
writeState(hotBase, state);
}
// 应用新版本后设为 target(等重启生效)
function setTarget(hotBase, ver) {
const state = readState(hotBase);
state.target = ver;
writeState(hotBase, state);
}
// 当前“有效内容版本”:优先已确认,其次包内(由调用方给 fallback)
function effectiveVersion(hotBase) {
const state = readState(hotBase);
return state.confirmed || null;
}
module.exports = {
MAX_TRY, readState, writeState, versionUsable,
chooseVersion, blacklistVersion, confirmHealthy, setTarget, effectiveVersion,
};
+137
View File
@@ -0,0 +1,137 @@
// hotupdate.js —— 热更新:拉取 manifest、下载、校验、解压应用(复用 lib/zip.js)。
"use strict";
const fs = require("fs");
const path = require("path");
const crypto = require("crypto");
const { extractZip } = require("./zip");
const hotstate = require("./hotstate");
const UA = "codex-market";
const OWNER = "backup-bot";
const REPO = "backup";
const API = `https://up.tokendun.cc/api/v1/repos/${OWNER}/${REPO}`;
const HOT_TAG = "hot";
// 可选 ed25519 公钥(SPKI PEM)。留空则只做 sha256 校验;填入后强制校验签名。
const HOT_PUBKEY_PEM = "";
// 语义版本比较:a>b→1, a<b→-1, 相等→0
function cmp(a, b) {
const pa = String(a || "0").replace(/^v/, "").split(".").map((n) => parseInt(n, 10) || 0);
const pb = String(b || "0").replace(/^v/, "").split(".").map((n) => parseInt(n, 10) || 0);
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
if ((pa[i] || 0) > (pb[i] || 0)) return 1;
if ((pa[i] || 0) < (pb[i] || 0)) return -1;
}
return 0;
}
async function fetchJson(url, ms = 8000) {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), ms);
try {
const res = await fetch(url, { headers: { "User-Agent": UA }, signal: ctrl.signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.json();
} finally { clearTimeout(t); }
}
// 拉取 hot release 里的 hotupdate.json;失败返回 null(断网/无 release 时静默)
async function fetchManifest() {
let rel;
try {
rel = await fetchJson(`${API}/releases/tags/${HOT_TAG}`);
} catch { return null; }
const assets = (rel && rel.assets) || [];
const mAsset = assets.find((a) => a.name === "hotupdate.json");
if (!mAsset) return null;
try {
const m = await fetchJson(mAsset.browser_download_url || `${API.replace("/api/v1/repos", "")}/`);
// 若 bundleUrl 缺失,尝试从同 release 的 asset 里按 contentVersion 找 bundle
if (m && !m.bundleUrl && m.contentVersion) {
const b = assets.find((a) => a.name === `bundle-${m.contentVersion}.zip`);
if (b) m.bundleUrl = b.browser_download_url;
}
return m;
} catch { return null; }
}
// 评估是否有可用热更 / 是否需完整包。
// bundledHotVersion:随包内置的内容版本;appVersion:当前原生版本。
function evaluate(manifest, appVersion, bundledHotVersion, hotBase) {
if (!manifest || !manifest.contentVersion) return { hasHot: false };
const confirmed = hotstate.effectiveVersion(hotBase); // 已确认的热更版本
const effective = cmp(confirmed || "0", bundledHotVersion || "0") > 0 ? confirmed : (bundledHotVersion || "0");
const newer = cmp(manifest.contentVersion, effective) > 0;
const minOk = cmp(appVersion, manifest.minAppVersion || "0") >= 0;
const needFull = !!manifest.requiresFull || !minOk;
return {
hasHot: newer,
needFull: newer && needFull, // 有新内容但当前原生版本不够 → 必须下完整包
canHot: newer && minOk && !manifest.requiresFull,
contentVersion: manifest.contentVersion,
minAppVersion: manifest.minAppVersion || null,
notes: manifest.notes || "",
mandatory: !!manifest.mandatory,
};
}
// 下载 + 校验 + 解压 + 置 target。onProgress(percent 0..100)
async function applyManifest(manifest, hotBase, onProgress) {
if (!manifest || !manifest.bundleUrl || !manifest.sha256) {
throw new Error("热更清单不完整");
}
const ver = manifest.contentVersion;
onProgress && onProgress(1);
const res = await fetch(manifest.bundleUrl, { headers: { "User-Agent": UA } });
if (!res.ok) throw new Error(`下载失败(${res.status})`);
// 带进度读取
const total = Number(res.headers.get("content-length")) || 0;
const reader = res.body.getReader();
const chunks = [];
let got = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
got += value.length;
if (total && onProgress) onProgress(Math.min(99, Math.round((got / total) * 100)));
}
const buf = Buffer.concat(chunks.map((c) => Buffer.from(c)));
// sha256(必做)
const sum = crypto.createHash("sha256").update(buf).digest("hex");
if (sum.toLowerCase() !== String(manifest.sha256).toLowerCase()) {
throw new Error("SHA256 校验失败,已丢弃");
}
// ed25519 签名(可选:仅当内嵌公钥非空)
if (HOT_PUBKEY_PEM) {
if (!manifest.signature) throw new Error("缺少签名");
const ok = crypto.verify(
null,
Buffer.from(`${manifest.sha256}:${ver}`),
HOT_PUBKEY_PEM,
Buffer.from(manifest.signature, "base64")
);
if (!ok) throw new Error("签名校验失败");
}
// 解压到中转目录 → 校验结构 → 原子 rename
fs.mkdirSync(hotBase, { recursive: true });
const tmp = path.join(hotBase, `.tmp-${ver}`);
fs.rmSync(tmp, { recursive: true, force: true });
fs.mkdirSync(tmp, { recursive: true });
extractZip(buf, tmp);
if (!fs.existsSync(path.join(tmp, "electron", "main.js")) ||
!fs.existsSync(path.join(tmp, "renderer", "index.html"))) {
fs.rmSync(tmp, { recursive: true, force: true });
throw new Error("热更包结构不完整");
}
const dst = path.join(hotBase, ver);
fs.rmSync(dst, { recursive: true, force: true });
fs.renameSync(tmp, dst);
hotstate.setTarget(hotBase, ver); // 下次启动生效
onProgress && onProgress(100);
return { ok: true, version: ver };
}
module.exports = { fetchManifest, evaluate, applyManifest, cmp };
+873
View File
@@ -0,0 +1,873 @@
// memory.js —— 会话记忆:把 Codex 会话自动浓缩成「交接摘要」,边用边存。
// 分层记忆(Phase 0):
// 监视 ~/.codex/sessions 的 rollout-*.jsonl → 会话静默 idleSeconds 秒后自动摘要 →
// ① 存进 SQLite(userData/memory.db,历史可检索、只增不减 = 单一真源)
// ② 从库里【确定性聚合】出「项目核心记忆块」直接写进项目 AGENTS.md 标记段
// —— 关键:Codex 每次新会话必注入 AGENTS.md 全文,所以事实必须钉在这里,而不是放指针
// ③ HANDOFF.md 降级为「给人看的近期快照」;每会话摘要另 append 到 ~/.codex/memory/<hash>.md 供 grep 检索
// 摘要优先走「路由中转」里可用分组的 LLM;没配分组或调用失败则退化为本地规则提取。
// 核心块的渲染是【确定性】的(每次从库重新投影,非在上一版上叠加)→ 天然无「传话游戏」漂移。
const { app } = require("electron");
const fs = require("fs");
const path = require("path");
const os = require("os");
const crypto = require("crypto");
const initSqlJs = require("sql.js");
const sessions = require("./sessions");
const router = require("./router");
const AGENTS_START = "<!-- codex-memory:start -->";
const AGENTS_END = "<!-- codex-memory:end -->";
// —— 运行时状态 ——
let db = null;
let SQLmod = null;
let log = () => {};
let notify = () => {};
let watcher = null;
let sweepTimer = null;
let persistTimer = null;
const dirty = new Map(); // rollout 文件 → 最后一次变化的时间戳
const seenRollouts = new Set(); // 已见过的 rollout 文件(判定「新会话开始」)
const queue = [];
let working = false;
let cfg = null;
// ---------------------------------------------------------------------------
// 配置(userData/memory.json)
// ---------------------------------------------------------------------------
function cfgFile() {
return path.join(app.getPath("userData"), "memory.json");
}
function loadCfg() {
if (cfg) return cfg;
let raw = {};
try {
raw = JSON.parse(fs.readFileSync(cfgFile(), "utf-8"));
} catch {}
cfg = {
enabled: true, // 总开关
disabledProjects: [], // 关闭写入的项目 cwd 列表(记忆库照常记录,只是不写文件)
idleSeconds: 20, // 会话静默多少秒后视为「一段落」并摘要(另有「开新会话即刻结算上一个」触发)
backfillDays: 7, // 启动回填:只补最近 N 天的旧会话,防止首次启动海量调用
handoffSessions: 4, // HANDOFF.md 里保留最近几次会话
// —— 项目核心记忆块(写进 AGENTS.md,每轮注入)的预算与容量 ——
coreMaxLines: 55, // 核心块最多行数
coreMaxChars: 1400, // 核心块最多字符
coreDecisions: 12, // 核心块保留最近多少条去重决策
coreTopics: 12, // 历史话题索引保留最近多少条
archiveEnabled: true, // 是否把每会话摘要 append 到 ~/.codex/memory/<hash>.md 供 grep 检索
semanticCompact: true, // 是否用 LLM 做语义去重/取代(仅在配了中转分组时生效)
compactMinItems: 6, // 活跃条目达到多少条才值得跑一次语义整合
...raw,
};
// 兼容旧配置:老版本存的是 idleMinutes
if (typeof raw.idleMinutes === "number" && typeof raw.idleSeconds !== "number") {
cfg.idleSeconds = Math.max(10, Math.round(raw.idleMinutes * 60));
}
return cfg;
}
function saveCfg() {
fs.writeFileSync(cfgFile(), JSON.stringify(cfg, null, 2));
}
// ---------------------------------------------------------------------------
// SQLite(sql.js:内存库 + 落盘持久化)
// ---------------------------------------------------------------------------
function dbFile() {
return path.join(app.getPath("userData"), "memory.db");
}
async function ensureDb() {
if (db) return db;
if (!SQLmod) {
const wasm = require.resolve("sql.js/dist/sql-wasm.wasm");
SQLmod = await initSqlJs({ wasmBinary: fs.readFileSync(wasm) });
}
try {
db = new SQLmod.Database(fs.readFileSync(dbFile()));
} catch {
db = new SQLmod.Database();
}
db.run(`CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
ts TEXT, project TEXT, cwd TEXT, topic TEXT,
summary TEXT, decisions TEXT, next_steps TEXT, files TEXT,
rollout_path TEXT, rollout_size INTEGER, engine TEXT, updated_at TEXT
)`);
// —— Phase 1:结构化核心记忆 ——
db.run(`CREATE TABLE IF NOT EXISTS meta (k TEXT PRIMARY KEY, v TEXT)`);
// 每个 scope(项目 cwd)一行滚动状态
db.run(`CREATE TABLE IF NOT EXISTS core (
scope TEXT PRIMARY KEY,
state TEXT, next_steps TEXT, files TEXT, updated_at TEXT
)`);
// 决策/长期事实:一条一行。pinned=1 的长期事实(身份/偏好)永不淘汰
db.run(`CREATE TABLE IF NOT EXISTS core_decisions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
scope TEXT, text TEXT, norm TEXT,
pinned INTEGER DEFAULT 0, status TEXT DEFAULT 'active',
session_id TEXT, ts TEXT
)`);
return db;
}
function persistDb() {
clearTimeout(persistTimer);
persistTimer = setTimeout(flushDb, 500);
}
function flushDb() {
clearTimeout(persistTimer);
if (!db) return;
try {
fs.writeFileSync(dbFile(), Buffer.from(db.export()));
} catch (e) {
log("会话记忆:记忆库写盘失败 " + (e.message || e));
}
}
function all(sql, params = []) {
const rows = [];
const st = db.prepare(sql);
st.bind(params);
while (st.step()) rows.push(st.getAsObject());
st.free();
return rows;
}
function parseArr(s) {
try {
const a = JSON.parse(s);
return Array.isArray(a) ? a.map(String).filter(Boolean) : [];
} catch {
return [];
}
}
// ---------------------------------------------------------------------------
// 摘要生成
// ---------------------------------------------------------------------------
function clip(s, n) {
s = String(s || "").replace(/\s+/g, " ").trim();
return s.length > n ? s.slice(0, n) + "…" : s;
}
// 把整段对话压缩成给 LLM 看的精简文本(控制 token:单条截断 + 总长掐头留尾)
function compressConv(conv) {
const parts = [];
for (const m of conv.messages) {
if (m.role === "user") parts.push("用户: " + clip(m.text, 600));
else if (m.role === "assistant") parts.push("助手: " + clip(m.text, 400));
}
let text = parts.join("\n");
if (text.length > 16000) {
text = text.slice(0, 6000) + "\n……(中间已省略)……\n" + text.slice(-10000);
}
return text;
}
const SUMMARY_PROMPT = (convText) =>
`你是"会话交接摘要器"。下面是用户与 AI 编程助手(Codex)在某个项目里的对话记录(已压缩,可能有省略)。
请只输出一个 JSON 对象(不要代码块、不要其它文字),字段:
{
"topic": "一句话主题,不超过30字",
"summary": "摘要,不超过200字。只写结论与当前状态,不写过程;要让没看过对话的人能接着干活",
"decisions": ["已确定的关键决策,每条不超过40字,没有就空数组"],
"next_steps": ["明确的下一步待办,每条不超过40字,没有就空数组"],
"files": ["对话中涉及的重要文件路径,最多8个"]
}
对话记录:
<<<
${convText}
>>>`;
// 调 LLM(复用路由中转的可用分组;chat / responses 两种协议都支持)
async function llmSummarize(convText) {
const g = router.llmGroup();
if (!g) throw new Error("未配置可用的中转分组");
const base = g.baseUrl.replace(/\/$/, "");
const prompt = SUMMARY_PROMPT(convText);
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 90000);
try {
let content = "";
const headers = {
"content-type": "application/json",
authorization: "Bearer " + g.key,
"User-Agent": "codex-market",
};
if (g.mode === "chat") {
let ep;
if (/\/chat\/completions$/.test(base)) ep = base;
else if (/\/v\d+$/.test(base)) ep = base + "/chat/completions";
else ep = base + "/v1/chat/completions";
const res = await fetch(ep, {
method: "POST",
signal: ctrl.signal,
headers,
body: JSON.stringify({ model: g.model, messages: [{ role: "user", content: prompt }], stream: false }),
});
if (!res.ok) throw new Error("HTTP " + res.status);
const j = await res.json();
content = (j.choices && j.choices[0] && j.choices[0].message && j.choices[0].message.content) || "";
} else {
const ep = /\/v\d+$/.test(base) ? base + "/responses" : base + "/v1/responses";
const res = await fetch(ep, {
method: "POST",
signal: ctrl.signal,
headers,
body: JSON.stringify({ model: g.model, input: prompt, stream: false }),
});
if (!res.ok) throw new Error("HTTP " + res.status);
const j = await res.json();
content = j.output_text || "";
if (!content && Array.isArray(j.output)) {
for (const it of j.output) {
if (it.type !== "message") continue;
for (const c of it.content || []) if (c.type === "output_text") content += c.text || "";
}
}
}
const m = String(content).match(/\{[\s\S]*\}/);
if (!m) throw new Error("LLM 未返回 JSON");
const d = JSON.parse(m[0]);
return {
topic: clip(d.topic, 60),
summary: String(d.summary || "").trim().slice(0, 600),
decisions: (Array.isArray(d.decisions) ? d.decisions : []).map((x) => clip(x, 80)).filter(Boolean).slice(0, 8),
next_steps: (Array.isArray(d.next_steps) ? d.next_steps : []).map((x) => clip(x, 80)).filter(Boolean).slice(0, 8),
files: (Array.isArray(d.files) ? d.files : []).map(String).filter(Boolean).slice(0, 8),
engine: "llm:" + (g.name || g.model || ""),
};
} finally {
clearTimeout(timer);
}
}
// 本地兜底:不调外部服务的规则提取(质量粗糙但可用)
function localSummarize(item, conv) {
const users = conv.messages.filter((m) => m.role === "user");
const asst = conv.messages.filter((m) => m.role === "assistant");
const blob = conv.messages.map((m) => m.text).join("\n");
const files = Array.from(
new Set(blob.match(/[\w./~-]+\.(?:js|ts|tsx|jsx|py|json|md|toml|yml|yaml|css|html|sh|go|rs|java|sql)\b/g) || [])
).slice(0, 8);
const summary = [
"首条需求:" + clip((users[0] && users[0].text) || item.title, 200),
asst.length ? "最后进展:" + clip(asst[asst.length - 1].text, 260) : "",
]
.filter(Boolean)
.join("\n");
return {
topic: clip(item.title || (users[0] && users[0].text) || "(无标题)", 60),
summary,
decisions: [],
next_steps: [],
files,
engine: "local",
};
}
// ---------------------------------------------------------------------------
// 摘要一条会话 → 落库 → 写 HANDOFF / AGENTS
// ---------------------------------------------------------------------------
async function summarizeFile(file) {
const items = sessions.listConversations({ includeSubagent: false });
const item = items.find((i) => i.path === file);
if (!item || !item.id) return; // 子 agent 线程或读不到元信息 → 跳过
const conv = sessions.readConversation(file);
if (!conv.messages.some((m) => m.role === "user")) return; // 空会话不摘要
let r;
try {
r = await llmSummarize(compressConv(conv));
} catch (e) {
log(`会话记忆:LLM 摘要失败(${e.message || e}),改用本地提取`);
r = localSummarize(item, conv);
}
await ensureDb();
db.run(
`INSERT INTO sessions (id, ts, project, cwd, topic, summary, decisions, next_steps, files,
rollout_path, rollout_size, engine, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(id) DO UPDATE SET
ts=excluded.ts, project=excluded.project, cwd=excluded.cwd, topic=excluded.topic,
summary=excluded.summary, decisions=excluded.decisions, next_steps=excluded.next_steps,
files=excluded.files, rollout_path=excluded.rollout_path, rollout_size=excluded.rollout_size,
engine=excluded.engine, updated_at=excluded.updated_at`,
[
item.id,
item.ts || "",
item.project || "",
item.cwd || "",
r.topic,
r.summary,
JSON.stringify(r.decisions),
JSON.stringify(r.next_steps),
JSON.stringify(r.files),
file,
item.size || 0,
r.engine,
new Date().toISOString(),
]
);
// Phase 1:把本次摘要并入结构化 core(决策去重/淘汰、长期事实 pin、滚动状态)
// Phase 2:有新决策进来时,跑一次 LLM 语义整合(近义合并 / 推翻作废,只输出操作,抗漂移)
if (item.cwd) {
try {
const added = consolidateCore(item.cwd, item.ts, item.id, r.decisions, r.summary, r.next_steps, r.files);
if (added > 0) await semanticCompact(item.cwd);
} catch (e) {
log("会话记忆:整合 core 失败 " + (e.message || e));
}
}
flushDb(); // 同步落盘:真源先持久,派生文件后写,避免 CLI/下次会话读到旧数据
if (projectWritable(item.cwd)) {
try {
writeProjectCore(item.cwd); // 核心块直接进 AGENTS.md(Codex 必读)
writeHandoff(item.cwd); // 详细快照(L2)
appendArchive(item.cwd, item, r); // grep 归档(L3,只增不删)
} catch (e) {
log("会话记忆:写核心/HANDOFF/归档失败 " + (e.message || e));
}
}
log(`会话记忆:已更新「${r.topic}」(${item.project || item.cwd || "未知项目"})`);
notify();
}
// 这个项目能不能写文件(总开关 + 项目开关 + 目录合法性)
function projectWritable(cwd) {
loadCfg();
if (!cfg.enabled) return false;
if (!cwd || cwd === os.homedir() || cwd === "/") return false; // 家目录/根目录不写,避免制造垃圾
if (cfg.disabledProjects.includes(cwd)) return false;
try {
return fs.statSync(cwd).isDirectory();
} catch {
return false;
}
}
function handoffPath(cwd) {
return path.join(cwd, "HANDOFF.md");
}
// 原子写:先写 .tmp 再 rename,避免 Codex 启动时读到写了一半的半截文件
function writeFileAtomic(file, text) {
const tmp = file + ".tmp";
fs.writeFileSync(tmp, text, "utf-8");
fs.renameSync(tmp, file);
}
// 项目 → grep 归档文件(放 ~/.codex/memory,不污染用户项目 git 树)
function hashCwd(cwd) {
return crypto.createHash("sha1").update(String(cwd)).digest("hex").slice(0, 12);
}
function archivePath(cwd) {
const d = path.join(os.homedir(), ".codex", "memory");
fs.mkdirSync(d, { recursive: true });
return path.join(d, hashCwd(cwd) + ".md");
}
// 每次会话摘要 append 到归档(只增不删,供模型 rg 检索长尾细节)
function appendArchive(cwd, item, r) {
if (!loadCfg().archiveEnabled) return;
const when = (item.ts || "").slice(0, 16).replace("T", " ");
const L = [`\n## ${when} · ${r.topic} [${item.id || ""}]`];
if (r.summary) L.push(r.summary);
if ((r.decisions || []).length) L.push("决策:" + r.decisions.join(";"));
if ((r.files || []).length) L.push("文件:" + r.files.join("、"));
try {
fs.appendFileSync(archivePath(cwd), L.join("\n") + "\n", "utf-8");
} catch {}
}
// 写 HANDOFF.md(L2):详细近期快照,给人看 / 被引导时读;核心已在 AGENTS.md,关键信息不依赖本文件
function writeHandoff(cwd) {
const rows = all(`SELECT * FROM sessions WHERE cwd = ? ORDER BY ts DESC LIMIT ?`, [cwd, loadCfg().handoffSessions]);
if (!rows.length) return;
const L = [];
L.push("<!-- 本文件由「Codex 插件市场 · 会话记忆」自动生成与覆盖,请勿手动编辑 -->");
L.push("# HANDOFF —— 会话交接快照(补充细节)");
L.push("");
L.push("> 核心记忆已写进本项目 `AGENTS.md`(Codex 每次会话自动读)。本文件是更详细的近期快照,供需要时参考;关键信息不依赖本文件。");
L.push("");
rows.forEach((r, i) => {
const dec = parseArr(r.decisions);
const next = parseArr(r.next_steps);
const files = parseArr(r.files);
const when = (r.ts || "").slice(0, 16).replace("T", " ");
L.push(`## ${i === 0 ? "最近会话" : "更早会话"} · ${when} · ${r.topic}`);
L.push("");
if (r.summary) L.push(r.summary, "");
if (dec.length) { L.push("**已确定的决策**:"); dec.forEach((d) => L.push(`- ${d}`)); L.push(""); }
if (next.length) { L.push("**下一步**:"); next.forEach((d) => L.push(`- ${d}`)); L.push(""); }
if (files.length) L.push(`**涉及文件**:${files.join("、")}`, "");
});
writeFileAtomic(handoffPath(cwd), L.join("\n"));
}
// 预算裁剪:首尾标记 + 检索指令行始终保留;超预算时优先砍末尾(历史话题),再砍靠后的内容
function enforceBudget(lines, maxLines, maxChars) {
const start = lines[0];
const end = lines[lines.length - 1];
const retrieval = lines.find((l) => l.startsWith("> 回答涉及")) || "";
let body = lines.slice(1, lines.length - 1).filter((l) => l !== retrieval);
const assemble = () => [start, ...body, retrieval, end].filter(Boolean).join("\n");
while (body.length && [start, ...body, retrieval, end].filter(Boolean).length > maxLines) body.pop();
while (body.length && assemble().length > maxChars) body.pop();
return assemble();
}
// —— Phase 1:结构化核心记忆的增量整合(同步、确定性 → 无漂移、无 RMW 竞态窗口)——
function normText(s) {
return String(s || "").toLowerCase().replace(/\s+/g, "").replace(/[。,、;:,.;:!??!「」『』()()]/g, "");
}
// 命中即视为「长期事实」(身份 / 称呼 / 偏好 / 背景)→ pinned,永不因预算淘汰
const PINNED_RE = /(称呼|叫我|叫你|用户(是|叫|名|有|为|的身份)|我(是|叫)|偏好|以后(都|请|叫|按)|习惯|喜欢|沟通(风格|方式)?|风格|背景|经验|职业|身份|资深|专职)/;
function classifyPinned(text) {
return PINNED_RE.test(String(text || "")) ? 1 : 0;
}
// 把一次会话摘要并入结构化 core:决策去重新增 + 超预算淘汰(归档不删) + 更新滚动状态
function consolidateCore(scope, ts, sessionId, decisions, summary, nextSteps, files) {
loadCfg();
const existing = all("SELECT norm FROM core_decisions WHERE scope = ? AND status='active'", [scope]).map((r) => r.norm);
const norms = new Set(existing);
let added = 0;
for (const d of decisions || []) {
const t = String(d || "").trim();
if (!t) continue;
const n = normText(t);
if (!n || norms.has(n)) continue;
norms.add(n);
added++;
db.run("INSERT INTO core_decisions (scope,text,norm,pinned,status,session_id,ts) VALUES (?,?,?,?,'active',?,?)", [
scope, t, n, classifyPinned(t), sessionId || "", ts || "",
]);
}
// 淘汰:非 pinned 的 active 决策超预算 → 归档(最旧先归档;pinned 永不淘汰、仍在库可检索)
const acts = all("SELECT id FROM core_decisions WHERE scope = ? AND status='active' AND pinned=0 ORDER BY ts DESC, id DESC", [scope]);
if (acts.length > cfg.coreDecisions) {
for (const d of acts.slice(cfg.coreDecisions)) db.run("UPDATE core_decisions SET status='archived' WHERE id = ?", [d.id]);
}
// 滚动状态(当前状态 / 下一步 / 关键文件)来自最近一次摘要
db.run(
`INSERT INTO core (scope,state,next_steps,files,updated_at) VALUES (?,?,?,?,?)
ON CONFLICT(scope) DO UPDATE SET state=excluded.state, next_steps=excluded.next_steps, files=excluded.files, updated_at=excluded.updated_at`,
[scope, clip(summary, 300), JSON.stringify((nextSteps || []).slice(0, 6)), JSON.stringify((files || []).slice(0, 8)), new Date().toISOString()]
);
return added;
}
// —— Phase 2:LLM 语义整合(只让 LLM 输出「操作」:哪些 id 是重复/被取代,不重写记忆文本 → 抗漂移)——
// 复用中转分组做一次 LLM 调用,返回原始文本
async function llmRaw(prompt) {
const g = router.llmGroup();
if (!g) throw new Error("未配置可用的中转分组");
const base = g.baseUrl.replace(/\/$/, "");
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 90000);
const headers = { "content-type": "application/json", authorization: "Bearer " + g.key, "User-Agent": "codex-market" };
try {
if (g.mode === "chat") {
let ep;
if (/\/chat\/completions$/.test(base)) ep = base;
else if (/\/v\d+$/.test(base)) ep = base + "/chat/completions";
else ep = base + "/v1/chat/completions";
const res = await fetch(ep, { method: "POST", signal: ctrl.signal, headers, body: JSON.stringify({ model: g.model, messages: [{ role: "user", content: prompt }], stream: false }) });
if (!res.ok) throw new Error("HTTP " + res.status);
const j = await res.json();
return (j.choices && j.choices[0] && j.choices[0].message && j.choices[0].message.content) || "";
}
const ep = /\/v\d+$/.test(base) ? base + "/responses" : base + "/v1/responses";
const res = await fetch(ep, { method: "POST", signal: ctrl.signal, headers, body: JSON.stringify({ model: g.model, input: prompt, stream: false }) });
if (!res.ok) throw new Error("HTTP " + res.status);
const j = await res.json();
let content = j.output_text || "";
if (!content && Array.isArray(j.output)) {
for (const it of j.output) {
if (it.type !== "message") continue;
for (const c of it.content || []) if (c.type === "output_text") content += c.text || "";
}
}
return content;
} finally {
clearTimeout(timer);
}
}
const COMPACT_PROMPT = (list) =>
`你是"记忆去重/整合器"。下面是某项目已记录的若干事实/决策条目(每条带数字 id)。请只输出一个 JSON 对象(不要代码块、不要其它文字):
{
"duplicates": [ { "keep": <保留的id>, "remove": [<被合并的同义id>...], "text": "<该组最简洁准确的规范表述>" } ],
"superseded": [ { "old": <被后来推翻/过时的id>, "by": <取代它的新id> } ]
}
规则:
- 只合并【真正同义】的(如"称呼用户为小楼"与"用户身份称呼为小楼");拿不准就不要合并。
- superseded 只在明确"后者推翻前者"时给(如先"用 Redis"后"改用内存缓存");不确定不给。
- keep 选表述最清晰的那条;text 用该组最准确简洁的说法。
- 没有可合并/取代的,返回 {"duplicates":[],"superseded":[]}。
条目:
<<<
${list}
>>>`;
// 对某项目的活跃条目做语义整合:LLM 只判断关系,代码按 id 执行合并/作废(归档不删)
async function semanticCompact(scope) {
loadCfg();
if (!cfg.semanticCompact || !router.llmGroup()) return;
const rows = all("SELECT id, text FROM core_decisions WHERE scope = ? AND status='active' ORDER BY id ASC", [scope]);
if (rows.length < cfg.compactMinItems) return;
const idSet = new Set(rows.map((r) => r.id));
const list = rows.map((r) => `[${r.id}] ${r.text}`).join("\n");
let ops;
try {
const raw = await llmRaw(COMPACT_PROMPT(list));
const m = String(raw).match(/\{[\s\S]*\}/);
if (!m) return;
ops = JSON.parse(m[0]);
} catch (e) {
log("会话记忆:语义整合跳过(" + (e.message || e) + ")");
return; // 任何失败 → 保持现状,不动数据
}
applyCompactOps(scope, ops, idSet);
}
// 执行 LLM 给的操作,严格校验 id 属于本 scope 的活跃条目,非法一律忽略
function applyCompactOps(scope, ops, idSet) {
let changed = 0;
const valid = (id) => Number.isInteger(id) && idSet.has(id);
for (const g of (ops && ops.duplicates) || []) {
if (!valid(g.keep)) continue;
const removes = (g.remove || []).filter((id) => valid(id) && id !== g.keep);
if (!removes.length) continue;
if (g.text && String(g.text).trim()) {
const t = String(g.text).trim();
db.run("UPDATE core_decisions SET text = ?, norm = ? WHERE id = ? AND scope = ?", [t, normText(t), g.keep, scope]);
}
for (const id of removes) {
db.run("UPDATE core_decisions SET status='merged' WHERE id = ? AND scope = ?", [id, scope]);
idSet.delete(id);
changed++;
}
}
for (const s of (ops && ops.superseded) || []) {
if (!valid(s.old) || !valid(s.by) || s.old === s.by) continue;
db.run("UPDATE core_decisions SET status='superseded', superseded_by = ? WHERE id = ? AND scope = ?", [s.by, s.old, scope]);
idSet.delete(s.old);
changed++;
}
if (changed) {
flushDb();
log(`会话记忆:语义整合合并/作废 ${changed} 条`);
}
}
// 一次性迁移:把已有 sessions 按时间正序 replay 进结构化 core(幂等,靠 meta 标记只跑一次)
function migrateCoreFromSessions() {
let done = false;
try {
done = all("SELECT v FROM meta WHERE k='core_migrated'").length > 0;
} catch {}
if (done) return;
const rows = all("SELECT * FROM sessions WHERE cwd != '' ORDER BY ts ASC");
for (const r of rows) {
consolidateCore(r.cwd, r.ts, r.id, parseArr(r.decisions), r.summary, parseArr(r.next_steps), parseArr(r.files));
}
db.run("INSERT OR REPLACE INTO meta (k,v) VALUES ('core_migrated','1')");
flushDb();
}
// 【核心】从结构化 core 表渲染「项目核心记忆块」。pinned 长期事实置顶且永不被预算裁掉。
function renderProjectCore(cwd) {
loadCfg();
const c = all("SELECT * FROM core WHERE scope = ?", [cwd])[0];
const pinned = all("SELECT text FROM core_decisions WHERE scope = ? AND pinned=1 AND status='active' ORDER BY id ASC", [cwd]).map((r) => r.text);
const decs = all("SELECT text FROM core_decisions WHERE scope = ? AND pinned=0 AND status='active' ORDER BY ts DESC, id DESC LIMIT ?", [cwd, cfg.coreDecisions]).map((r) => r.text);
const topicRows = all("SELECT ts, topic FROM sessions WHERE cwd = ? ORDER BY ts DESC LIMIT ?", [cwd, cfg.coreTopics]);
if (!c && !pinned.length && !decs.length && !topicRows.length) return null;
const nextSteps = c ? parseArr(c.next_steps).slice(0, 6) : [];
const files = c ? parseArr(c.files) : [];
const L = [];
L.push(AGENTS_START);
L.push("## 项目记忆(Codex 插件市场自动维护 · 回答前据此 · 勿手动编辑本段)");
if (pinned.length) { L.push("**关于用户 / 长期事实(务必遵守)**:"); pinned.forEach((d) => L.push(`- ${d}`)); }
if (c && c.state) L.push(`**当前状态**:${clip(c.state, 300)}`);
if (decs.length) { L.push("**已定决策**:"); decs.forEach((d) => L.push(`- ${d}`)); }
if (nextSteps.length) { L.push("**下一步**:"); nextSteps.forEach((d) => L.push(`- ${d}`)); }
if (files.length) L.push(`**关键文件**:${files.join("、")}`);
if (topicRows.length) { L.push("**历史话题(需其中细节时用下面命令检索)**:"); topicRows.forEach((r) => L.push(`- ${(r.ts || "").slice(0, 10)} ${r.topic}`)); }
L.push(`> 回答涉及本段未列出的历史细节前,先运行 \`rg -n "关键词" ${archivePath(cwd)}\` 查证再答。`);
L.push(AGENTS_END);
return enforceBudget(L, cfg.coreMaxLines, cfg.coreMaxChars);
}
// 把核心记忆块写进项目 AGENTS.md:写前重读,只替换标记段,块外内容原样保留;标记残缺则放弃不硬盖
function writeProjectCore(cwd) {
const core = renderProjectCore(cwd);
if (!core) return;
const f = path.join(cwd, "AGENTS.md");
let text = "";
try { text = fs.readFileSync(f, "utf-8"); } catch {}
const si = text.indexOf(AGENTS_START);
const ei = text.indexOf(AGENTS_END);
let nt;
if (si >= 0 && ei > si) {
nt = text.slice(0, si) + core + text.slice(ei + AGENTS_END.length);
} else if (si >= 0 || ei >= 0) {
log("会话记忆:AGENTS.md 记忆标记不完整,跳过本次更新以免破坏文件");
return;
} else {
nt = (text ? text.trimEnd() + "\n\n" : "") + core + "\n";
}
if (nt !== text) writeFileAtomic(f, nt);
}
// 新版首启:先把已有 sessions 迁移进结构化 core,再把所有已知项目 AGENTS.md 升级为核心块格式
function refreshAllCores() {
try {
migrateCoreFromSessions();
} catch (e) {
log("会话记忆:core 迁移失败 " + (e.message || e));
}
let cwds = [];
try {
cwds = all("SELECT DISTINCT cwd FROM sessions WHERE cwd != ''").map((r) => r.cwd);
} catch {
return;
}
for (const cwd of cwds) {
if (!projectWritable(cwd)) continue;
try {
writeProjectCore(cwd);
writeHandoff(cwd);
} catch {}
}
}
// ---------------------------------------------------------------------------
// 监视 + 队列(串行处理,避免并发打爆中转 API)
// ---------------------------------------------------------------------------
function enqueue(file) {
if (!queue.includes(file)) queue.push(file);
pump();
}
async function pump() {
if (working) return;
working = true;
try {
while (queue.length) {
if (!loadCfg().enabled) {
queue.length = 0;
break;
}
const file = queue.shift();
try {
await summarizeFile(file);
} catch (e) {
log("会话记忆:摘要失败 " + (e.message || e));
}
}
} finally {
working = false;
}
}
// 定时清点:静默超过 idleSeconds 的脏文件 → 入队摘要
function sweep() {
const idleMs = loadCfg().idleSeconds * 1000;
const now = Date.now();
for (const [file, t] of dirty) {
if (now - t >= idleMs) {
dirty.delete(file);
enqueue(file);
}
}
}
// 立刻结算「除 exceptFile 外」所有待处理会话(用于「开新会话即刻把上一个存好」)
function flushPending(exceptFile) {
for (const [file] of dirty) {
if (file === exceptFile) continue;
dirty.delete(file);
enqueue(file);
}
}
// 启动回填:把上次运行后新增/长大的会话补摘要(限最近 N 天、单次最多 20 个)
// 一次性:对所有已知项目跑语义整合,清理迁移进来的近义冗余(meta 标记,仅首次 Phase 2 启动跑)
async function compactAllOnce() {
loadCfg();
if (!cfg.semanticCompact || !router.llmGroup()) return;
let done = false;
try {
done = all("SELECT v FROM meta WHERE k='compact_v1'").length > 0;
} catch {}
if (done) return;
const cwds = all("SELECT DISTINCT cwd FROM sessions WHERE cwd != ''").map((r) => r.cwd);
for (const cwd of cwds) {
try {
await semanticCompact(cwd);
if (projectWritable(cwd)) writeProjectCore(cwd);
} catch {}
}
db.run("INSERT OR REPLACE INTO meta (k,v) VALUES ('compact_v1','1')");
flushDb();
}
async function backfill() {
await ensureDb();
loadCfg();
if (!cfg.enabled) return;
refreshAllCores(); // 首启即把已有项目 AGENTS.md 升级为核心块格式
compactAllOnce().catch(() => {}); // 后台一次性语义整合已有近义项
const cutoff = Date.now() - cfg.backfillDays * 86400000;
const known = new Map(all("SELECT rollout_path, rollout_size FROM sessions").map((r) => [r.rollout_path, r.rollout_size]));
const items = sessions.listConversations({ includeSubagent: false });
let queued = 0;
let skippedOld = 0;
for (const it of items) {
const t = Date.parse(it.ts || "");
if (!t || t < cutoff) {
if (!known.has(it.path)) skippedOld++;
continue;
}
if (known.get(it.path) === it.size) continue; // 没变化
enqueue(it.path);
if (++queued >= 20) break;
}
if (queued) log(`会话记忆:发现 ${queued} 个新增/有变化的会话,开始后台摘要…`);
if (skippedOld) log(`会话记忆:${skippedOld} 个 ${cfg.backfillDays} 天前的旧会话未自动回填(可在「会话记忆」页对单条手动摘要)`);
}
function start(opts = {}) {
if (opts.log) log = opts.log;
if (opts.notify) notify = opts.notify;
loadCfg();
// 先把已存在的会话标记为「已见」,这样只有启动后【新开】的会话才触发「即刻结算上一个」
try {
for (const it of sessions.listConversations({ includeSubagent: true })) seenRollouts.add(it.path);
} catch {}
// 监视器常开(总开关只控制是否处理),这样开关切换无需重启
try {
fs.mkdirSync(sessions.SESSIONS_DIR, { recursive: true });
watcher = fs.watch(sessions.SESSIONS_DIR, { recursive: true }, (_ev, name) => {
if (!name || !/rollout-.*\.jsonl$/.test(String(name))) return;
const full = path.join(sessions.SESSIONS_DIR, String(name));
// 「开新会话即刻结算上一个」:首次见到某 rollout 文件 = 新会话开始 → 立刻把其它待处理的(多半是刚关掉的那个)先存好
if (!seenRollouts.has(full)) {
seenRollouts.add(full);
flushPending(full);
}
dirty.set(full, Date.now());
});
} catch (e) {
log("会话记忆:无法监视 sessions 目录 " + (e.message || e));
}
sweepTimer = setInterval(sweep, 5000);
backfill().catch((e) => log("会话记忆:启动扫描失败 " + (e.message || e)));
}
function stop() {
try {
if (watcher) watcher.close();
} catch {}
watcher = null;
clearInterval(sweepTimer);
flushDb(); // 退出前把内存库落盘
}
// ---------------------------------------------------------------------------
// 给 UI 的接口
// ---------------------------------------------------------------------------
async function statusInfo() {
await ensureDb();
loadCfg();
const g = router.llmGroup();
const projects = all(
`SELECT cwd, project, COUNT(*) AS n, MAX(ts) AS last FROM sessions WHERE cwd != '' GROUP BY cwd ORDER BY last DESC`
).map((p) => ({
cwd: p.cwd,
project: p.project || path.basename(p.cwd || ""),
count: p.n,
last: p.last,
enabled: !cfg.disabledProjects.includes(p.cwd),
hasHandoff: fs.existsSync(handoffPath(p.cwd)),
}));
return {
enabled: cfg.enabled,
idleSeconds: cfg.idleSeconds,
engine: g ? `LLM 摘要(分组「${g.name}」· ${g.model})` : "本地提取(未配置可用中转分组,质量有限)",
total: (all("SELECT COUNT(*) AS n FROM sessions")[0] || { n: 0 }).n,
pending: queue.length + dirty.size,
projects,
};
}
async function list({ project = "", q = "", limit = 200 } = {}) {
await ensureDb();
let sql = "SELECT * FROM sessions";
const cond = [];
const params = [];
if (project) {
cond.push("cwd = ?");
params.push(project);
}
if (q) {
cond.push("(topic LIKE ? OR summary LIKE ? OR decisions LIKE ? OR project LIKE ?)");
const s = `%${q}%`;
params.push(s, s, s, s);
}
if (cond.length) sql += " WHERE " + cond.join(" AND ");
sql += " ORDER BY ts DESC LIMIT ?";
params.push(limit);
return all(sql, params).map((r) => ({
...r,
decisions: parseArr(r.decisions),
next_steps: parseArr(r.next_steps),
files: parseArr(r.files),
}));
}
function setEnabled(on) {
loadCfg();
cfg.enabled = !!on;
saveCfg();
if (cfg.enabled) backfill().catch(() => {});
}
function toggleProject(cwd, on) {
loadCfg();
const set = new Set(cfg.disabledProjects);
if (on) set.delete(cwd);
else set.add(cwd);
cfg.disabledProjects = Array.from(set);
saveCfg();
if (on && projectWritable(cwd)) {
try {
writeProjectCore(cwd);
writeHandoff(cwd);
} catch {}
}
}
async function resummarize(file) {
await summarizeFile(file);
}
async function deleteRow(id) {
await ensureDb();
db.run("DELETE FROM sessions WHERE id = ?", [id]);
persistDb();
notify();
}
module.exports = {
start,
stop,
statusInfo,
list,
setEnabled,
toggleProject,
resummarize,
deleteRow,
handoffPath,
renderProjectCore,
writeProjectCore,
semanticCompact,
applyCompactOps,
};
+194
View File
@@ -0,0 +1,194 @@
// provider_sync.js —— 一键把历史会话同步到「当前账号」的 provider,使其在 Codex 侧栏重新显示。
// 复刻 CodexPlusPlus 的 provider-sync:改 rollout 首行 provider + state DB(provider/has_user_event/cwd)。
// 必须在 Codex 已退出时运行。数据库用纯 JS 的 sql.js(免原生编译,跨平台)。
const fs = require("fs");
const path = require("path");
const os = require("os");
const { execFileSync } = require("child_process");
const initSqlJs = require("sql.js");
const CODEX_HOME = path.join(os.homedir(), ".codex");
// 检测 Codex 是否在运行(尽力而为,跨平台)
function codexRunning() {
try {
if (process.platform === "darwin") {
return execFileSync("pgrep", ["-f", "Codex.app/Contents/MacOS/Codex"], { encoding: "utf-8" }).trim().length > 0;
}
if (process.platform === "win32") {
return /Codex\.exe/i.test(execFileSync("tasklist", [], { encoding: "utf-8" }));
}
} catch {
/* pgrep 无匹配时退出码非 0,视为未运行 */
}
return false;
}
// 目标 provider = config.toml 根级 model_provider,缺省 openai
function targetProvider() {
try {
const head = fs.readFileSync(path.join(CODEX_HOME, "config.toml"), "utf-8").split("\n[")[0];
const m = head.match(/^\s*model_provider\s*=\s*["']([^"']+)/m);
if (m) return m[1];
} catch {}
return "openai";
}
function findRollouts() {
const out = [];
for (const root of [path.join(CODEX_HOME, "sessions"), path.join(CODEX_HOME, "archived_sessions")]) {
const walk = (dir) => {
let ents;
try {
ents = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return;
}
for (const e of ents) {
const full = path.join(dir, e.name);
if (e.isDirectory()) walk(full);
else if (e.isFile() && e.name.startsWith("rollout-") && e.name.endsWith(".jsonl")) out.push(full);
}
};
walk(root);
}
return out;
}
function ts() {
const d = new Date();
const p = (n) => String(n).padStart(2, "0");
return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
}
async function syncProviderToCurrent(log = () => {}, onProgress = () => {}) {
if (codexRunning()) throw new Error("检测到 Codex 正在运行,请先完全退出 Codex 再同步。");
const target = targetProvider();
log(`目标 provider:${target}`);
onProgress({ phase: "备份中", done: 0, total: 0 });
// 1) 备份
const backup = path.join(CODEX_HOME, "backups_state", "provider-sync", ts());
fs.mkdirSync(backup, { recursive: true });
for (const name of ["config.toml", ".codex-global-state.json", ".codex-global-state.json.bak"]) {
const src = path.join(CODEX_HOME, name);
if (fs.existsSync(src)) fs.copyFileSync(src, path.join(backup, name));
}
for (const db of fs.readdirSync(CODEX_HOME).filter((f) => /^state_.*\.sqlite/.test(f))) {
fs.copyFileSync(path.join(CODEX_HOME, db), path.join(backup, db));
}
log(`已备份到 ${backup}`);
// 2) 遍历 rollout:改首行 provider,收集 thread_id / cwd / has_user_event
const userEventIds = new Set();
const cwdById = {};
let changedFiles = 0;
const rolloutFiles = findRollouts();
const rtotal = rolloutFiles.length;
let ri = 0;
for (const file of rolloutFiles) {
ri++;
if (ri % 5 === 0 || ri === rtotal) onProgress({ phase: "改写会话文件", done: ri, total: rtotal });
let lines;
try {
lines = fs.readFileSync(file, "utf-8").split(/(?<=\n)/); // 保留换行
} catch {
continue;
}
if (!lines.length) continue;
const blob = lines.join("");
const hasUE = blob.includes('"user_message"') || blob.includes('"user_input"');
let tid = null;
let rewrote = false;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (!line.trim()) continue;
let rec;
try {
rec = JSON.parse(line);
} catch {
continue;
}
if (rec.type === "session_meta" && rec.payload && typeof rec.payload === "object") {
tid = rec.payload.id || rec.payload.session_id || null;
const cwd = rec.payload.cwd;
if (tid && cwd) cwdById[tid] = cwd;
if (rec.payload.model_provider !== target) {
rec.payload.model_provider = target;
lines[i] = JSON.stringify(rec) + "\n";
rewrote = true;
}
break;
}
}
if (rewrote) {
fs.writeFileSync(file, lines.join(""), "utf-8");
changedFiles++;
}
if (tid && hasUE) userEventIds.add(tid);
}
log(`改写 rollout 文件:${changedFiles} 个;含用户消息的会话:${userEventIds.size} 条`);
// 3) 更新所有 state_*.sqlite
onProgress({ phase: "更新数据库", done: 0, total: 0 });
const SQL = await initSqlJs({
// wasm 恒在【包内】node_modules(热更包不含 node_modules),故用 BUNDLE_ROOT 而非 __dirname
locateFile: (f) =>
path.join(global.__BUNDLE_ROOT__ || path.join(__dirname, "..", ".."),
"node_modules", "sql.js", "dist", f),
});
let provRows = 0;
let ueRows = 0;
let cwdRows = 0;
for (const name of fs.readdirSync(CODEX_HOME).filter((f) => /^state_.*\.sqlite$/.test(f))) {
const dbPath = path.join(CODEX_HOME, name);
let db;
try {
db = new SQL.Database(fs.readFileSync(dbPath));
const tbl = db.exec("SELECT name FROM sqlite_master WHERE type='table' AND name='threads'");
if (!tbl.length) {
db.close();
continue;
}
const cols = new Set(db.exec("PRAGMA table_info(threads)")[0].values.map((r) => r[1]));
if (!cols.has("model_provider")) {
db.close();
continue;
}
db.run("UPDATE threads SET model_provider=? WHERE COALESCE(model_provider,'')<>?", [target, target]);
provRows += db.getRowsModified();
if (cols.has("has_user_event")) {
for (const tid of userEventIds) {
db.run("UPDATE threads SET has_user_event=1 WHERE id=? AND COALESCE(has_user_event,0)<>1", [tid]);
ueRows += db.getRowsModified();
}
}
if (cols.has("cwd")) {
for (const tid of Object.keys(cwdById)) {
db.run("UPDATE threads SET cwd=? WHERE id=? AND COALESCE(cwd,'')<>?", [cwdById[tid], tid, cwdById[tid]]);
cwdRows += db.getRowsModified();
}
}
fs.writeFileSync(dbPath, Buffer.from(db.export()));
db.close();
} catch (e) {
if (db) try { db.close(); } catch {}
log(` 数据库跳过 ${name}:${e.message || e}`);
}
}
log(`数据库更新:provider ${provRows} 行、has_user_event ${ueRows} 行、cwd ${cwdRows} 行`);
onProgress({ phase: "完成", done: 0, total: 0 });
return {
target,
totalConversations: rtotal,
changedFiles,
userEvents: userEventIds.size,
provRows,
ueRows,
cwdRows,
backup,
};
}
module.exports = { syncProviderToCurrent, codexRunning, targetProvider };
+673
View File
@@ -0,0 +1,673 @@
// proxy.js —— 全局 / 系统代理:内置 sing-box,把「要密码的上游 SOCKS5」转成
// ① 系统代理:本地 mixed 端口(不要密码) + 设置 macOS/Windows 系统代理指过来;
// ② 全局 TUN:创建虚拟网卡接管整机流量(需管理员权限,启动时弹一次密码框)。
// macOS 系统代理不会发送用户名/密码,所以必须走本地中转——这正是本模块存在的原因。
const { app, safeStorage } = require("electron");
const { spawn, execFile } = require("child_process");
const { promisify } = require("util");
const fs = require("fs");
const net = require("net");
const path = require("path");
const http = require("http");
const pExecFile = promisify(execFile);
// —— 运行时状态(内存)——
let state = {
mode: "off", // off | system | global
child: null, // sing-box 子进程(系统模式) 或 osascript/powershell 提权进程(全局模式)
localPort: 0, // 系统模式本地 mixed 端口
services: [], // macOS:已设置系统代理的网络服务名,停止时逐一还原
onChange: null, // 状态变化回调(通知前端 + 托盘)
};
function setOnChange(cb) {
state.onChange = cb;
}
function emit() {
if (typeof state.onChange === "function") {
try {
state.onChange(status());
} catch {}
}
}
// ---------------------------------------------------------------------------
// 路径:sing-box 二进制 / 运行时目录 / 配置文件
// ---------------------------------------------------------------------------
function resDir() {
// 打包后二进制在 resources/singbox;开发时在项目 resources/singbox
return app.isPackaged
? path.join(process.resourcesPath, "singbox")
: path.join(__dirname, "..", "..", "resources", "singbox");
}
function binPath() {
const plat = process.platform;
let sub;
let name = "sing-box";
if (plat === "darwin") sub = process.arch === "arm64" ? "darwin-arm64" : "darwin-x64";
else if (plat === "win32") {
sub = "windows-x64";
name = "sing-box.exe";
} else {
throw new Error("全局代理暂只支持 macOS 与 Windows");
}
return path.join(resDir(), sub, name);
}
function binExists() {
try {
return fs.existsSync(binPath());
} catch {
return false;
}
}
function runtimeDir() {
const d = path.join(app.getPath("userData"), "proxy-runtime");
fs.mkdirSync(d, { recursive: true });
return d;
}
function cfgPath() {
return path.join(app.getPath("userData"), "proxy.json");
}
// ---------------------------------------------------------------------------
// 配置持久化(proxyStr 用 safeStorage 加密)
// ---------------------------------------------------------------------------
function enc(s) {
if (!s) return "";
try {
if (safeStorage.isEncryptionAvailable()) return "v1:" + safeStorage.encryptString(s).toString("base64");
} catch {}
return "b64:" + Buffer.from(s, "utf-8").toString("base64");
}
function dec(s) {
if (!s) return "";
try {
if (s.startsWith("v1:")) return safeStorage.decryptString(Buffer.from(s.slice(3), "base64"));
if (s.startsWith("b64:")) return Buffer.from(s.slice(4), "base64").toString("utf-8");
} catch {}
return "";
}
let conf = null;
function defaultConf() {
return { proxyStr: "", localPort: 0, appliedPort: 0 };
}
function loadConf() {
if (conf) return conf;
try {
const raw = JSON.parse(fs.readFileSync(cfgPath(), "utf-8"));
raw.proxyStr = raw.proxyStr ? dec(raw.proxyStr) : "";
conf = { ...defaultConf(), ...raw };
} catch {
conf = defaultConf();
}
return conf;
}
function saveConf() {
const out = { ...conf, proxyStr: enc(conf.proxyStr) };
const p = cfgPath();
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, JSON.stringify(out, null, 2));
try {
fs.chmodSync(p, 0o600);
} catch {}
}
function getConfig() {
loadConf();
return { proxyStr: conf.proxyStr, localPort: conf.localPort || 0 };
}
function setConfig({ proxyStr, localPort } = {}) {
loadConf();
if (proxyStr !== undefined) conf.proxyStr = String(proxyStr || "").trim();
if (localPort !== undefined) conf.localPort = parseInt(localPort, 10) || 0;
saveConf();
return getConfig();
}
// ---------------------------------------------------------------------------
// 解析上游代理字符串:支持
// host:port:user:pass / host:port / socks5://user:pass@host:port
// ---------------------------------------------------------------------------
function parseProxy(str) {
str = String(str || "").trim();
if (!str) return null;
const m = str.match(/^socks5?:\/\/(?:([^:@/]+):([^@/]+)@)?([^:/@]+):(\d+)/i);
if (m) return { host: m[3], port: parseInt(m[4], 10), user: m[1] || "", pass: m[2] || "" };
const parts = str.split(":");
if (parts.length === 4 && /^\d+$/.test(parts[1])) {
return { host: parts[0], port: parseInt(parts[1], 10), user: parts[2], pass: parts[3] };
}
if (parts.length === 2 && /^\d+$/.test(parts[1])) {
return { host: parts[0], port: parseInt(parts[1], 10), user: "", pass: "" };
}
return null;
}
// ---------------------------------------------------------------------------
// 找空闲端口(优先用用户指定端口,占用则随机取)
// ---------------------------------------------------------------------------
function findFreePort(preferred) {
return new Promise((resolve) => {
const tryListen = (port, fallback) => {
const srv = net.createServer();
srv.once("error", () => {
if (fallback) tryListen(0, false);
else resolve(0);
});
srv.listen(port, "127.0.0.1", () => {
const p = srv.address().port;
srv.close(() => resolve(p));
});
};
tryListen(preferred || 0, !!preferred);
});
}
// ---------------------------------------------------------------------------
// sing-box 配置生成
// ---------------------------------------------------------------------------
function outbounds(p) {
const proxy = { type: "socks", tag: "proxy", server: p.host, server_port: p.port, version: "5" };
if (p.user) {
proxy.username = p.user;
proxy.password = p.pass;
}
return [proxy, { type: "direct", tag: "direct" }];
}
function systemConfig(p, port) {
return {
log: { level: "warn" },
inbounds: [{ type: "mixed", tag: "mixed-in", listen: "127.0.0.1", listen_port: port }],
outbounds: outbounds(p),
route: { final: "proxy" },
};
}
function globalConfig(p, logPath) {
return {
// 让 sing-box 直接把日志写到文件(UTF-8),不依赖 shell 重定向 → 中文路径无损、判断启动可靠
log: { level: "info", output: logPath, timestamp: true },
dns: {
servers: [
// 用 TCP-53 远程 DNS(经代理解析)——比 DoT(853) 更通用,避免代理不放行加密 DNS 导致全站解析失败
{ type: "tcp", tag: "remote-dns", server: "8.8.8.8", detour: "proxy" },
{ type: "local", tag: "local-dns" },
],
final: "remote-dns",
strategy: "ipv4_only",
},
inbounds: [
// gvisor 用户态协议栈:Windows 上比 system 栈更稳、兼容性好
{ type: "tun", tag: "tun-in", address: ["172.19.0.1/30"], auto_route: true, strict_route: true, stack: "gvisor" },
],
outbounds: outbounds(p),
route: {
auto_detect_interface: true,
default_domain_resolver: "local-dns",
rules: [
{ action: "sniff" }, // 嗅探协议,供下面识别 DNS / QUIC
// 关键:把 DNS 查询劫持给 sing-box 自己用 TCP 解析。否则浏览器的 UDP DNS 被塞进 TUN → 交给只支持 TCP 的 SOCKS5 → 丢弃 → 全站解析失败 = 没网
{ protocol: "dns", action: "hijack-dns" },
{ port: 53, action: "hijack-dns" },
// QUIC(HTTP/3, UDP 443)经只支持 TCP 的 SOCKS5 会全丢 → 直接拒绝,逼浏览器回落到 TCP
{ network: "udp", port: 443, action: "reject" },
],
final: "proxy",
},
};
}
function writeCfg(name, obj) {
const p = path.join(runtimeDir(), name);
fs.writeFileSync(p, JSON.stringify(obj, null, 2));
return p;
}
// ---------------------------------------------------------------------------
// macOS:系统代理设置 / 还原
// ---------------------------------------------------------------------------
async function macServices() {
// 列出所有网络服务,去掉标题行与被禁用(带*)的
const { stdout } = await pExecFile("networksetup", ["-listallnetworkservices"]);
return stdout
.split("\n")
.slice(1)
.map((s) => s.trim())
.filter((s) => s && !s.startsWith("*"));
}
async function macSetSystemProxy(port) {
const services = await macServices();
const applied = [];
for (const svc of services) {
try {
await pExecFile("networksetup", ["-setwebproxy", svc, "127.0.0.1", String(port)]);
await pExecFile("networksetup", ["-setsecurewebproxy", svc, "127.0.0.1", String(port)]);
await pExecFile("networksetup", ["-setsocksfirewallproxy", svc, "127.0.0.1", String(port)]);
applied.push(svc);
} catch {
/* 某个服务设置失败(如未连接)则跳过 */
}
}
return applied;
}
async function macUnsetSystemProxy(services) {
for (const svc of services || []) {
for (const kind of ["-setwebproxystate", "-setsecurewebproxystate", "-setsocksfirewallproxystate"]) {
try {
await pExecFile("networksetup", [kind, svc, "off"]);
} catch {}
}
}
}
// ---------------------------------------------------------------------------
// Windows:系统代理设置 / 还原(HKCU,无需管理员)
// ---------------------------------------------------------------------------
const WIN_INET = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings";
// 通知 WinINET 代理设置已变更,让浏览器(Chrome/Edge 等)立即重新读取,而不是等重启
async function winRefreshInet() {
const ps =
`$s='[DllImport("wininet.dll",SetLastError=true)]public static extern bool InternetSetOption(IntPtr h,int o,IntPtr b,int l);';` +
`$t=Add-Type -MemberDefinition $s -Name W -Namespace I -PassThru;` +
`$t::InternetSetOption([IntPtr]::Zero,39,[IntPtr]::Zero,0)|Out-Null;` + // INTERNET_OPTION_SETTINGS_CHANGED
`$t::InternetSetOption([IntPtr]::Zero,37,[IntPtr]::Zero,0)|Out-Null`; // INTERNET_OPTION_REFRESH
await pExecFile("powershell", ["-NoProfile", "-Command", ps]).catch(() => {});
}
async function winSetSystemProxy(port) {
await pExecFile("reg", ["add", WIN_INET, "/v", "ProxyEnable", "/t", "REG_DWORD", "/d", "1", "/f"]);
await pExecFile("reg", ["add", WIN_INET, "/v", "ProxyServer", "/d", `127.0.0.1:${port}`, "/f"]);
// 排除本地地址直连
await pExecFile("reg", ["add", WIN_INET, "/v", "ProxyOverride", "/d", "localhost;127.*;<local>", "/f"]).catch(() => {});
await winRefreshInet();
}
async function winUnsetSystemProxy() {
await pExecFile("reg", ["add", WIN_INET, "/v", "ProxyEnable", "/t", "REG_DWORD", "/d", "0", "/f"]).catch(() => {});
await winRefreshInet();
}
// ---------------------------------------------------------------------------
// 系统代理模式:启动本地 sing-box mixed + 设系统代理
// ---------------------------------------------------------------------------
async function startSystem(p) {
const port = await findFreePort(conf.localPort || 0);
if (!port) throw new Error("找不到可用的本地端口");
const cfg = writeCfg("system.json", systemConfig(p, port));
const child = spawn(binPath(), ["run", "-c", cfg], { stdio: ["ignore", "pipe", "pipe"] });
let stderr = "";
child.stderr.on("data", (d) => (stderr += d.toString()));
await new Promise((resolve, reject) => {
let settled = false;
child.on("error", (e) => {
if (!settled) {
settled = true;
reject(e);
}
});
child.on("exit", (code) => {
if (!settled) {
settled = true;
reject(new Error(`sing-box 启动失败(${code}):${stderr.slice(0, 300)}`));
}
});
// 轮询本地端口,通了就算启动成功
const t0 = Date.now();
const poll = () => {
const s = net.connect(port, "127.0.0.1");
s.on("connect", () => {
s.destroy();
if (!settled) {
settled = true;
resolve();
}
});
s.on("error", () => {
s.destroy();
if (Date.now() - t0 > 8000) {
if (!settled) {
settled = true;
reject(new Error("sing-box 端口未就绪:" + stderr.slice(0, 200)));
}
} else setTimeout(poll, 300);
});
};
poll();
});
// 端口通了 → 设系统代理
let services = [];
if (process.platform === "darwin") services = await macSetSystemProxy(port);
else if (process.platform === "win32") await winSetSystemProxy(port);
state.mode = "system";
state.child = child;
state.localPort = port;
state.services = services;
conf.appliedPort = port;
saveConf();
child.on("exit", () => {
// 子进程意外退出 → 复位(并尝试还原系统代理)
if (state.child === child) {
cleanupSystemProxy().catch(() => {});
state.mode = "off";
state.child = null;
emit();
}
});
emit();
return status();
}
async function cleanupSystemProxy() {
if (process.platform === "darwin") await macUnsetSystemProxy(state.services);
else if (process.platform === "win32") await winUnsetSystemProxy();
state.services = [];
}
// ---------------------------------------------------------------------------
// 全局 TUN 模式:提权启动 sing-box(flag 文件做“可停止”,全程只弹一次密码)
// 原理:提权 shell 里后台跑 sing-box,前台 while 循环盯住 flag 文件;
// 我方(普通权限)删除 flag → 提权循环发现文件没了 → kill sing-box 退出。
// ---------------------------------------------------------------------------
function flagPath() {
return path.join(runtimeDir(), "global.flag");
}
function stripAnsi(s) {
return String(s).replace(/\x1b\[[0-9;]*m/g, "");
}
// 读日志:Windows 的系统错误常是 GBK 中文,UTF-8 读出来是乱码 → 检测到乱码就用 GBK 兜底
function readLogSmart(logf) {
let buf;
try {
buf = fs.readFileSync(logf);
} catch {
return "";
}
let s = buf.toString("utf-8");
if (s.includes("�")) {
for (const enc of ["gbk", "gb18030", "big5"]) {
try {
const d = new TextDecoder(enc).decode(buf);
if (!d.includes("�")) {
s = d;
break;
}
} catch {}
}
}
return stripAnsi(s);
}
// 盯 sing-box 日志判断启动成败(mac / Windows 通用):
// 出现 "sing-box started" → 成功;出现 FATAL → 失败;
// 提权进程(仅 mac)提前退出且无 started → 取消授权;超时同理。
function waitForStart(logf, timeoutMs, child, watchChildExit) {
return new Promise((resolve) => {
const t0 = Date.now();
let childExited = false;
if (watchChildExit && child) child.on("exit", () => (childExited = true));
const tick = () => {
const txt = readLogSmart(logf);
if (/sing-box started/i.test(txt)) return resolve({ ok: true });
const fatal = txt.match(/FATAL[^\n]*/i) || txt.match(/ERROR[^\n]*(bind|permission|denied|wintun|tun)[^\n]*/i);
if (fatal) return resolve({ ok: false, reason: "引擎启动失败:" + fatal[0].trim().slice(0, 260) });
if (childExited && Date.now() - t0 > 1500) {
return resolve({
ok: false,
reason: txt ? "启动失败:" + txt.trim().slice(-260) : "已取消管理员授权,全局代理未启动。",
});
}
if (Date.now() - t0 > timeoutMs) {
return resolve({
ok: false,
reason: txt
? "启动超时:" + txt.trim().slice(-260)
: "未获得管理员授权(密码框 / UAC 被取消),或引擎无法启动。",
});
}
setTimeout(tick, 400);
};
tick();
});
}
async function startGlobal(p) {
const flag = flagPath();
const logf = path.join(runtimeDir(), "global.log");
const cfg = writeCfg("global.json", globalConfig(p, logf));
// 先自检配置,避免弹了密码才发现配置错
await pExecFile(binPath(), ["check", "-c", cfg]);
try {
fs.unlinkSync(logf); // 清掉上次日志,保证这次判断读到的是本次输出
} catch {}
fs.writeFileSync(flag, "run");
let child;
if (process.platform === "darwin") {
// 提权 bash:后台跑 sing-box(日志由 sing-box 自己写文件),盯 flag;flag 删了或引擎挂了就退出
const sh = path.join(runtimeDir(), "run-global.sh");
fs.writeFileSync(
sh,
`#!/bin/bash
"${binPath()}" run -c "${cfg}" &
SB=$!
while [ -f "${flag}" ]; do sleep 1; kill -0 $SB 2>/dev/null || break; done
kill $SB 2>/dev/null
wait $SB 2>/dev/null
`
);
fs.chmodSync(sh, 0o755);
const osa = `do shell script "/bin/bash '${sh}'" with administrator privileges`;
child = spawn("osascript", ["-e", osa]);
} else if (process.platform === "win32") {
// PowerShell(Unicode 原生,兼容中文用户名路径):提权后台跑 sing-box,盯 flag,删了就杀。
// sing-box 自己把日志写进 log.output,不用 shell 重定向 → 避开 cmd 的 GBK 编码坑。
const ps = path.join(runtimeDir(), "run-global.ps1");
const psBody =
"" + // UTF-8 BOM,保证 PowerShell 按 UTF-8 读取(路径含中文也不乱)
`$ErrorActionPreference='SilentlyContinue'\r\n` +
// 先杀掉上次残留的引擎(此脚本已提权,能杀掉之前遗留的管理员进程),释放被占用的 wintun 网卡
`Stop-Process -Name sing-box -Force\r\n` +
`Start-Sleep -Milliseconds 800\r\n` +
`$proc = Start-Process -FilePath '${binPath()}' -ArgumentList @('run','-c','${cfg}') -WindowStyle Hidden -PassThru\r\n` +
`while (Test-Path '${flag}') { if ($proc.HasExited) { break }; Start-Sleep -Seconds 1 }\r\n` +
`if (-not $proc.HasExited) { Stop-Process -Id $proc.Id -Force }\r\n` +
`Stop-Process -Name sing-box -Force\r\n`;
fs.writeFileSync(ps, psBody, "utf-8");
// 非提权的外层 powershell 负责触发一次 UAC,把上面的脚本以管理员+隐藏窗口运行
const outer = `Start-Process powershell -Verb RunAs -WindowStyle Hidden -ArgumentList '-NoProfile','-ExecutionPolicy','Bypass','-File','${ps}'`;
child = spawn("powershell", ["-NoProfile", "-Command", outer], { windowsHide: true });
} else {
throw new Error("全局代理暂只支持 macOS 与 Windows");
}
// mac 的提权进程会随 sing-box 存活;win 的外层进程点完 UAC 立刻退出,故只在 mac 盯它早退
// 开 wintun/utun 网卡可能较慢,给 40 秒;那句 "open interface take too much time" 只是 WARN,会继续尝试
const res = await waitForStart(logf, 40000, child, process.platform === "darwin");
if (!res.ok) {
try {
fs.unlinkSync(flag);
} catch {}
try {
if (child) child.kill();
} catch {}
throw new Error(res.reason);
}
state.mode = "global";
state.localPort = 0;
if (process.platform === "darwin") {
// mac:提权进程存活即代理存活,它退出就复位状态
state.child = child;
child.on("exit", () => {
if (state.child === child) {
try {
fs.unlinkSync(flag);
} catch {}
state.mode = "off";
state.child = null;
emit();
}
});
} else {
// win:外层进程已退出,靠 flag 文件控制生命周期
state.child = null;
}
emit();
return status();
}
// ---------------------------------------------------------------------------
// 统一启动 / 停止
// ---------------------------------------------------------------------------
async function start(mode) {
loadConf();
if (!binExists()) throw new Error("缺少 sing-box 引擎文件,请重新安装本软件");
const p = parseProxy(conf.proxyStr);
if (!p) throw new Error("请先填写正确的代理地址,格式:host:port:user:pass");
if (state.mode !== "off") await stop();
if (mode === "system") return startSystem(p);
if (mode === "global") return startGlobal(p);
throw new Error("未知模式:" + mode);
}
async function stop() {
const prev = state.mode;
if (prev === "system") {
await cleanupSystemProxy();
if (state.child) {
try {
state.child.kill();
} catch {}
}
} else if (prev === "global") {
// 删 flag → 提权循环自行 kill sing-box 并拆除 TUN
try {
fs.unlinkSync(flagPath());
} catch {}
// 兜底:等提权进程收尾
await new Promise((r) => setTimeout(r, 1500));
}
state.mode = "off";
state.child = null;
state.localPort = 0;
state.services = [];
emit();
return status();
}
// 退出前清理:确保不把系统代理 / TUN 残留给用户
async function shutdown() {
if (state.mode === "off") return;
try {
await stop();
} catch {}
}
// 启动时的残留清理:上次若崩溃遗留了「指向我们端口的系统代理」,且引擎已不在 → 关掉它,
// 但不动用户自己(如 Clash)设的其它端口,避免误伤。
async function cleanupStale() {
loadConf();
if (process.platform !== "darwin" || !conf.appliedPort) return;
try {
const services = await macServices();
for (const svc of services) {
const { stdout } = await pExecFile("networksetup", ["-getsocksfirewallproxy", svc]).catch(() => ({ stdout: "" }));
if (/Enabled:\s*Yes/i.test(stdout) && stdout.includes("127.0.0.1") && stdout.includes(String(conf.appliedPort))) {
await macUnsetSystemProxy([svc]);
}
}
} catch {}
}
// ---------------------------------------------------------------------------
// 测试连通性:临时起一个本地 mixed 口,经它请求取出口 IP
// ---------------------------------------------------------------------------
async function testProxy() {
loadConf();
if (!binExists()) throw new Error("缺少 sing-box 引擎文件");
const p = parseProxy(conf.proxyStr);
if (!p) throw new Error("代理地址格式不对,应为 host:port:user:pass");
const port = await findFreePort(0);
const cfg = writeCfg("test.json", systemConfig(p, port));
const child = spawn(binPath(), ["run", "-c", cfg], { stdio: ["ignore", "ignore", "pipe"] });
let stderr = "";
child.stderr.on("data", (d) => (stderr += d.toString()));
try {
// 等端口就绪
await new Promise((resolve, reject) => {
const t0 = Date.now();
const poll = () => {
const s = net.connect(port, "127.0.0.1");
s.on("connect", () => {
s.destroy();
resolve();
});
s.on("error", () => {
s.destroy();
if (Date.now() - t0 > 6000) reject(new Error("引擎未就绪:" + stderr.slice(0, 200)));
else setTimeout(poll, 250);
});
};
poll();
});
const ip = await httpGetViaProxy(port, "http://api.ipify.org/");
return { ok: true, ip, proxy: `${p.host}:${p.port}` };
} finally {
try {
child.kill();
} catch {}
}
}
// 经本地 HTTP 代理端口发一个 GET,返回响应体(用于取出口 IP)
function httpGetViaProxy(port, targetUrl) {
return new Promise((resolve, reject) => {
const u = new URL(targetUrl);
const req = http.request(
{
host: "127.0.0.1",
port,
method: "GET",
path: targetUrl, // 绝对 URL → 走代理
headers: { Host: u.host, "User-Agent": "codex-market", Connection: "close" },
timeout: 15000,
},
(res) => {
const chunks = [];
res.on("data", (c) => chunks.push(c));
res.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8").trim()));
}
);
req.on("timeout", () => req.destroy(new Error("请求超时(15s)")));
req.on("error", reject);
req.end();
});
}
// ---------------------------------------------------------------------------
// 状态
// ---------------------------------------------------------------------------
function status() {
loadConf();
const p = parseProxy(conf.proxyStr);
return {
mode: state.mode, // off | system | global
running: state.mode !== "off",
localPort: state.localPort || 0,
binOk: binExists(),
platform: process.platform,
proxy: p ? { host: p.host, port: p.port, hasAuth: !!p.user } : null,
hasConfig: !!p,
};
}
module.exports = {
getConfig,
setConfig,
start,
stop,
status,
testProxy,
shutdown,
cleanupStale,
setOnChange,
};
+176
View File
@@ -0,0 +1,176 @@
// purge.js —— 检测本机 Codex 的所有安装位置,并「强制清除」。
// 跨平台(macOS / Windows / Linux)自动识别:数据目录、PATH 里的可执行文件、
// npm 全局包、Homebrew 安装等。用于「更新后中转失效 → 彻底清干净再重装」。
const fs = require("fs");
const path = require("path");
const os = require("os");
const { exec } = require("child_process");
const codex = require("./codex");
const IS_WIN = process.platform === "win32";
// 跑一条 shell 命令,带超时;失败返回 null(用于探测,绝不抛错)。
function run(command) {
return new Promise((resolve) => {
try {
exec(command, { timeout: 8000, windowsHide: true }, (err, stdout) => {
if (err) return resolve(null);
resolve(String(stdout || "").trim() || null);
});
} catch (e) {
resolve(null);
}
});
}
// 某个路径存在吗?(含符号链接本身)
function lstat(p) {
try {
return fs.lstatSync(p);
} catch (e) {
return null;
}
}
// 求路径体积:目录递归、文件取 size、符号链接算 0。
function sizeOf(p, st) {
if (!st) return 0;
if (st.isSymbolicLink()) return 0;
if (st.isDirectory()) return codex.dirSize(p);
try {
return fs.statSync(p).size;
} catch (e) {
return 0;
}
}
// 从可执行文件的真实路径回溯出 @openai/codex 包根目录。
// 例:.../@openai/codex/bin/codex.js -> .../@openai/codex
function pkgRootFromBin(realBin) {
let dir = path.dirname(realBin);
for (let i = 0; i < 6; i++) {
if (
path.basename(dir) === "codex" &&
path.basename(path.dirname(dir)) === "@openai"
) {
return dir;
}
const parent = path.dirname(dir);
if (parent === dir) break;
dir = parent;
}
return null;
}
// 探测所有可能的 Codex 安装位置。返回去重后的候选清单(含 exists / size)。
async function detectInstalls() {
const home = os.homedir();
// key(绝对路径)-> 候选
const found = new Map();
const add = (label, kind, p) => {
if (!p) return;
let abs;
try {
abs = path.resolve(p);
} catch (e) {
return;
}
if (found.has(abs)) return;
found.set(abs, { label, kind, path: abs });
};
// 1) 数据目录(配置 / 登录凭证 / 会话 / 插件 / 技能)——始终列出
add("Codex 数据目录(配置 · 登录 · 会话 · 插件)", "data", path.join(home, ".codex"));
// 2) PATH 里的可执行文件(which / where 可能返回多行)
const whichOut = IS_WIN ? await run("where codex") : await run("which -a codex");
if (whichOut) {
for (const line of whichOut.split(/\r?\n/)) {
const bin = line.trim();
if (!bin) continue;
add("Codex 可执行文件(PATH)", "bin", bin);
// 顺着符号链接找到真实文件,再回溯 npm 包根
try {
const real = fs.realpathSync(bin);
if (real !== bin) add("Codex 可执行文件(真实位置)", "bin", real);
const root = pkgRootFromBin(real);
if (root) add("@openai/codex 安装包", "npm-package", root);
} catch (e) {
/* 断链/权限:忽略 */
}
}
}
// 3) npm 全局包与 npm 全局 bin
const npmRoot = await run(IS_WIN ? "npm.cmd root -g" : "npm root -g");
if (npmRoot) add("@openai/codex 安装包(npm 全局)", "npm-package", path.join(npmRoot, "@openai", "codex"));
const npmPrefix = await run(IS_WIN ? "npm.cmd prefix -g" : "npm prefix -g");
if (npmPrefix) {
const binDir = IS_WIN ? npmPrefix : path.join(npmPrefix, "bin");
for (const n of ["codex", "codex.cmd", "codex.ps1", "codex.exe"]) {
add("Codex 启动脚本(npm bin)", "bin", path.join(binDir, n));
}
add("@openai/codex 安装包(npm 全局)", "npm-package", path.join(npmPrefix, IS_WIN ? "node_modules" : "lib/node_modules", "@openai", "codex"));
}
// 4) 各平台常见固定位置兜底
if (IS_WIN) {
const appdata = process.env.APPDATA || path.join(home, "AppData", "Roaming");
for (const n of ["codex", "codex.cmd", "codex.ps1"]) {
add("Codex 启动脚本(%APPDATA%\\npm)", "bin", path.join(appdata, "npm", n));
}
add("@openai/codex 安装包(%APPDATA%\\npm)", "npm-package", path.join(appdata, "npm", "node_modules", "@openai", "codex"));
} else {
// macOS / Linux:Homebrew 与常见全局前缀
for (const prefix of ["/opt/homebrew", "/usr/local", path.join(home, ".npm-global")]) {
add("Codex 可执行文件(" + prefix + ")", "bin", path.join(prefix, "bin", "codex"));
add("@openai/codex 安装包(" + prefix + ")", "npm-package", path.join(prefix, "lib", "node_modules", "@openai", "codex"));
}
}
// 收尾:补 exists / size,只保留真实存在的项
const items = [];
for (const c of found.values()) {
const st = lstat(c.path);
if (!st) continue; // 不存在的不展示
items.push({
...c,
exists: true,
isSymlink: st.isSymbolicLink(),
isDir: st.isDirectory() && !st.isSymbolicLink(),
size: sizeOf(c.path, st),
sizeText: codex.humanSize(sizeOf(c.path, st)),
});
}
// 数据目录排最前,其余按体积降序
items.sort((a, b) => {
if (a.kind === "data") return -1;
if (b.kind === "data") return 1;
return b.size - a.size;
});
return items;
}
// 强制删除给定路径。为安全起见,只删除「当前探测得到」的路径,
// 防止渲染进程传入任意路径。返回每一项的删除结果。
async function purge(requestedPaths) {
const wanted = new Set((requestedPaths || []).map((p) => path.resolve(p)));
const detected = await detectInstalls();
const allowed = detected.filter((d) => wanted.has(d.path));
const results = [];
for (const item of allowed) {
try {
fs.rmSync(item.path, { recursive: true, force: true });
results.push({ path: item.path, label: item.label, ok: true, stillExists: !!lstat(item.path) });
} catch (e) {
results.push({ path: item.path, label: item.label, ok: false, error: String(e && e.message || e) });
}
}
return results;
}
module.exports = { detectInstalls, purge };
+884
View File
@@ -0,0 +1,884 @@
// router.js —— 本地路由/中转代理:Codex 直连本地,指令经此转发到"当前分组"的中转 API。
// 切分组只改内存里的 activeId,Codex 那头地址不变 → 秒切、无需重启 Codex。
const http = require("http");
const https = require("https");
const fs = require("fs");
const path = require("path");
const os = require("os");
const crypto = require("crypto");
const { URL } = require("url");
const { app, safeStorage } = require("electron");
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 名
function cfgPath() {
return path.join(app.getPath("userData"), "router.json");
}
// —— 加解密(复用 safeStorage)——
function enc(s) {
if (!s) return "";
try {
if (safeStorage.isEncryptionAvailable()) return "v1:" + safeStorage.encryptString(s).toString("base64");
} catch {}
return "b64:" + Buffer.from(s, "utf-8").toString("base64");
}
function dec(s) {
if (!s) return "";
try {
if (s.startsWith("v1:")) return safeStorage.decryptString(Buffer.from(s.slice(3), "base64"));
if (s.startsWith("b64:")) return Buffer.from(s.slice(4), "base64").toString("utf-8");
} catch {}
return "";
}
// —— 配置持久化 —— config: { port, routerKey, activeId, groups:[{id,name,baseUrl,key,model}] }
let config = null;
function defaultConfig() {
return { port: 8787, routerKey: "rk-" + crypto.randomBytes(16).toString("hex"), activeId: null, groups: [] };
}
function load() {
if (config) return config;
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) }));
config = { ...defaultConfig(), ...raw };
} catch {
config = defaultConfig();
}
ensureDefault();
return config;
}
function save() {
const out = {
...config,
routerKey: enc(config.routerKey),
groups: config.groups.map((g) => ({ ...g, key: enc(g.key) })),
};
const p = cfgPath();
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, JSON.stringify(out, null, 2));
try {
fs.chmodSync(p, 0o600);
} catch {}
}
function activeGroup() {
load();
return config.groups.find((g) => g.id === config.activeId) || null;
}
// —— 默认分组:客户原本的 Codex 配置(自己账号,直连)——
function readConfigToml() {
try {
return fs.readFileSync(path.join(CODEX_HOME, "config.toml"), "utf-8");
} catch {
return "";
}
}
function currentModelProvider() {
const head = readConfigToml().split("\n[")[0];
const m = head.match(/^\s*model_provider\s*=\s*["']([^"']+)/m);
return m ? m[1] : "";
}
// 读根级 model(新版桌面 Codex 登录态通常没有;用于复原时精确还原/清除中转写入的 model)
function currentRootModel() {
const head = readConfigToml().split("\n[")[0];
const m = head.match(/^\s*model\s*=\s*["']([^"']+)/m);
return m ? m[1] : "";
}
function extractProviderBlock(text, prov) {
const esc = prov.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const re = new RegExp(`\\[model_providers\\.${esc}\\][\\s\\S]*?(?=\\n\\[|$)`, "m");
const m = text.match(re);
return m ? m[0].trimEnd() + "\n" : "";
}
function readAuthKey() {
try {
return JSON.parse(fs.readFileSync(path.join(CODEX_HOME, "auth.json"), "utf-8")).OPENAI_API_KEY || "";
} catch {
return "";
}
}
// 是否已用自己账号登录 Codex(ChatGPT OAuth)——凭证是 auth.json 的 tokens,而非 API key。
// 这是「我的账号 · 直连」的真实判据:只要 tokens 在,切中转只是覆盖 OPENAI_API_KEY,登录不会丢。
function isLoggedIn() {
try {
const a = JSON.parse(fs.readFileSync(path.join(CODEX_HOME, "auth.json"), "utf-8"));
return !!(a && a.tokens && (a.tokens.access_token || a.tokens.id_token || a.tokens.refresh_token));
} catch {
return false;
}
}
// 从 TOML 文本里删除某个 section(从 [header] 行删到下一个 [ 行之前),按行处理最稳
function removeTomlSection(text, header) {
const lines = text.split("\n");
const out = [];
let skip = false;
for (const line of lines) {
const t = line.trim();
if (t === `[${header}]`) {
skip = true;
continue;
}
if (skip && t.startsWith("[") && t.endsWith("]")) skip = false; // 遇到下一个 section,停止跳过
if (!skip) out.push(line);
}
return out.join("\n").replace(/\n{3,}/g, "\n\n");
}
function backupConfig(text) {
try {
const dir = path.join(CODEX_HOME, "backups_state", "router");
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, "config.toml." + Date.now() + ".bak"), text || "");
} catch {}
}
// 首次使用时,把当前(原始)Codex 配置快照为不可删除的「默认」分组。
// 铁律:默认分组必须代表「我的账号 · 直连」的登录态,绝不能是接入中转的状态,
// 否则「切回默认」会把 Codex 重新指向中转,用户就再也复原不回自己账号了。
function ensureDefault() {
if (!config) return;
const loggedIn = isLoggedIn();
const existing = config.groups.find((g) => g.isDefault);
if (existing) {
// 自愈:已用自己账号登录,但历史版本把默认分组错误地快照成了中转 provider →
// 纠正为「纯登录态」,保证「切回默认」永远能干净复原自己账号。
if (loggedIn && (existing.modelProvider || existing.providerBlock || existing.authKey)) {
existing.modelProvider = "";
existing.providerBlock = "";
existing.authKey = "";
save();
}
return;
}
if (loggedIn) {
// 已登录自己账号:默认分组 = Codex 原生登录直连(不覆盖 model_provider、无 API key、
// 保留 auth.json 的 tokens)。无论此刻 config.toml 指向哪个中转,都能干净复原。
config.groups.unshift({
id: "default",
isDefault: true,
name: "默认(我的账号 · 直连)",
modelProvider: "",
providerBlock: "",
authKey: "",
origModel: currentRootModel(), // 记住原始根级 model(新版桌面登录态通常无 → ""),复原时精确还原
});
if (!config.activeId) config.activeId = "default";
save();
return;
}
// 未用 OAuth 登录(纯 API key 用户,无 tokens 可保护):沿用原逻辑,快照当前直连 provider。
const prov = currentModelProvider();
if (!prov || prov === PROVIDER_ID) return; // 已是本地路由 / 无配置 → 不快照
const text = readConfigToml();
config.groups.unshift({
id: "default",
isDefault: true,
name: "默认(我的账号 · 直连)",
modelProvider: prov,
providerBlock: extractProviderBlock(text, prov),
authKey: readAuthKey(),
origModel: currentRootModel(),
});
if (!config.activeId) config.activeId = "default";
save();
}
// 模型目录路径(config.toml 的 model_catalog_json,默认 cc-switch-model-catalog.json)
function catalogPath() {
const m = readConfigToml().match(/model_catalog_json\s*=\s*["']([^"']+)/);
const name = m ? m[1] : "cc-switch-model-catalog.json";
return path.isAbsolute(name) ? name : path.join(CODEX_HOME, name);
}
// 把分组的模型 + 上下文窗口写进 Codex 模型目录,让 Codex 按真实窗口自动压缩
function applyModelCatalog(group) {
if (!group || group.isDefault || !group.model || !group.contextWindow) return;
const p = catalogPath();
let cat;
try {
cat = JSON.parse(fs.readFileSync(p, "utf-8"));
} catch {
return; // 无目录文件就不动
}
if (!cat.models || !cat.models.length) return;
const win = parseInt(group.contextWindow, 10);
if (!win) return;
const existing = cat.models.find((m) => m.slug === group.model);
if (existing) {
existing.context_window = win;
existing.max_context_window = win;
existing.visibility = "list";
} else {
const tpl = JSON.parse(JSON.stringify(cat.models[0])); // 克隆模板(含 base_instructions 等必需字段)
tpl.slug = group.model;
tpl.display_name = group.model;
tpl.description = group.model;
tpl.context_window = win;
tpl.max_context_window = win;
tpl.priority = 900;
tpl.visibility = "list";
cat.models.push(tpl);
}
try {
fs.writeFileSync(p + ".bak-router", fs.readFileSync(p));
} catch {}
fs.writeFileSync(p, JSON.stringify(cat, null, 2), "utf-8");
}
// 把 config.toml 根级 model 设成指定值
function setConfigModel(model) {
if (!model) return;
const cfgFile = path.join(CODEX_HOME, "config.toml");
let text = readConfigToml();
if (/^\s*model\s*=/m.test(text)) text = text.replace(/^\s*model\s*=.*$/m, `model = "${model}"`);
else text = text.replace(/^(\s*model_provider\s*=.*)$/m, `$1\nmodel = "${model}"`);
fs.writeFileSync(cfgFile, text, "utf-8");
}
// 恢复到默认(把 config.toml/auth.json 还原成客户原始配置,Codex 直连自己账号)
function restoreDirect() {
const def = config.groups.find((g) => g.isDefault);
if (!def) return;
const cfgFile = path.join(CODEX_HOME, "config.toml");
let text = readConfigToml();
backupConfig(text);
// 先移除本地路由段(无论哪种复原都要清掉)
text = removeTomlSection(text, `model_providers.${PROVIDER_ID}`);
if (def.modelProvider) {
// 有原始直连 provider(纯 API key 用户,如 Mac 的 custom):设根键 + 追加原始段,避免重复键。
// 此分支完全保持旧行为(不动根级 model 行),确保已在用的 Mac 环境零回归。
if (/^\s*model_provider\s*=/m.test(text)) text = text.replace(/^\s*model_provider\s*=.*$/m, `model_provider = "${def.modelProvider}"`);
else text = `model_provider = "${def.modelProvider}"\n` + text;
if (def.providerBlock) {
text = removeTomlSection(text, `model_providers.${def.modelProvider}`);
text = text.trimEnd() + "\n\n" + def.providerBlock;
}
} else {
// 纯登录态(Windows 新格式 OAuth):删根级 model_provider 覆盖,回到 Codex 原生登录直连。
text = text.replace(/^\s*model_provider\s*=.*$\n?/m, "");
// 清掉中转写入的根级 model;原始登录态通常无 model,若快照记了 origModel 则精确还原。
text = text.replace(/^\s*model\s*=.*$\n?/m, "");
if (def.origModel) text = `model = "${def.origModel}"\n` + text;
}
text = text.replace(/\n{3,}/g, "\n\n");
fs.writeFileSync(cfgFile, text, "utf-8");
const af = path.join(CODEX_HOME, "auth.json");
try {
const a = JSON.parse(fs.readFileSync(af, "utf-8"));
// 登录态:清空 OPENAI_API_KEY、保留 tokens → Codex 用 OAuth 直连;纯 key 用户则还原原始 key
a.OPENAI_API_KEY = def.authKey || null;
fs.writeFileSync(af, JSON.stringify(a, null, 2), "utf-8");
fs.chmodSync(af, 0o600);
} catch {}
}
function getGroupKey(id) {
load();
const g = config.groups.find((x) => x.id === id);
return g ? g.key : "";
}
// 按 id 取分组(含解密后的 key,供内部功能按指定分组直调中转)。只读,不改激活态/切回逻辑。
function groupById(id) {
load();
return config.groups.find((x) => x.id === id) || null;
}
// 可供画布等内部功能"按需选用"的中转分组列表:排除默认直连分组,需配齐 baseUrl+key。只读。
function usableGroups() {
load();
return config.groups.filter((g) => !g.isDefault && g.baseUrl && g.key);
}
// 给内部功能(如「会话记忆」摘要)用的可调用分组:优先当前激活的中转分组,
// 不可用则取第一个配置齐全的中转分组;默认分组(直连账号)不参与。
function llmGroup() {
load();
const usable = (g) => g && !g.isDefault && g.baseUrl && g.key && g.model;
const act = config.groups.find((g) => g.id === config.activeId);
if (usable(act)) return act;
return config.groups.find(usable) || null;
}
// 拉取某个中转 API 的模型列表(试 /v1/models 再 /models)
async function fetchModels(baseUrl, key) {
if (!baseUrl) throw new Error("请先填 API 地址");
const base = baseUrl.replace(/\/$/, "");
let lastErr = null;
for (const p of ["/v1/models", "/models"]) {
try {
const res = await fetch(base + p, {
headers: { Authorization: "Bearer " + (key || ""), "User-Agent": "codex-market" },
});
if (!res.ok) {
lastErr = new Error("HTTP " + res.status);
continue;
}
const j = await res.json();
const arr = Array.isArray(j) ? j : j.data || j.models || [];
const list = arr.map((m) => (typeof m === "string" ? m : m.id || m.name)).filter(Boolean);
if (list.length) return Array.from(new Set(list));
} catch (e) {
lastErr = e;
}
}
throw lastErr || new Error("未获取到模型列表(接口可能不支持 /v1/models)");
}
// —— 代理服务器 ——
let server = null;
// 抓取一次真实请求/响应到 userData/router-capture/(用于开发协议转换)
function captureDir() {
return path.join(app.getPath("userData"), "router-capture");
}
function writeCapture(name, data) {
try {
fs.mkdirSync(captureDir(), { recursive: true });
fs.writeFileSync(path.join(captureDir(), name), data);
} catch {}
}
function handle(req, res) {
const g = activeGroup();
if (!g) {
res.writeHead(503, { "content-type": "text/plain; charset=utf-8" });
res.end("本地路由:未选择中转分组");
return;
}
// 校验路由 key(Codex 用 auth.json 里的 routerKey 作 Bearer 发来)
const auth = req.headers["authorization"] || "";
if (config.routerKey && auth !== "Bearer " + config.routerKey) {
// 放行 localhost 但不匹配时也继续(避免 key 变动导致不可用),仅记录
}
const chunks = [];
req.on("data", (c) => chunks.push(c));
req.on("end", () => {
let body = Buffer.concat(chunks);
const doCapture = /responses/.test(req.url || "");
// 分组指定了模型 → 覆盖请求体里的 model
if (g.model && body.length) {
try {
const j = JSON.parse(body.toString("utf-8"));
if (j && typeof j === "object" && "model" in j) {
j.model = g.model;
body = Buffer.from(JSON.stringify(j), "utf-8");
}
} catch {}
}
// —— chat 模式:Responses ⇄ Chat Completions 协议转换(DeepSeek/GLM/Kimi 等)——
if (g.mode === "chat" && /responses/.test(req.url || "")) {
let responsesReq = {};
try {
responsesReq = JSON.parse(body.toString("utf-8"));
} catch {}
const chatReq = translate.responsesToChat(responsesReq, g.model || responsesReq.model);
const chatBody = Buffer.from(JSON.stringify(chatReq), "utf-8");
// 拼 chat 端点:多数网关/厂商(DeepSeek/GLM/Kimi/one-api)是 /v1/chat/completions
const cbase = g.baseUrl.replace(/\/$/, "");
let chatEndpoint;
if (/\/chat\/completions$/.test(cbase)) chatEndpoint = cbase; // 用户已填全
else if (/\/v\d+$/.test(cbase)) chatEndpoint = cbase + "/chat/completions"; // 已含 /v1
else chatEndpoint = cbase + "/v1/chat/completions";
let chatUrl;
try {
chatUrl = new URL(chatEndpoint);
} catch {
res.writeHead(502);
res.end("分组 API 地址无效");
return;
}
const https2 = chatUrl.protocol === "https:";
const conv = new translate.ChatToResponses(chatReq.model);
let ended = false;
const endOnce = (tail) => {
if (ended) return;
ended = true;
if (tail) res.write(tail);
res.end();
};
res.writeHead(200, { "content-type": "text/event-stream; charset=utf-8", "cache-control": "no-cache", connection: "keep-alive" });
if (doCapture) writeCapture("last-chat-request.json", `UPSTREAM ${chatUrl.href}\n\n` + JSON.stringify(chatReq, null, 2));
const up2 = (https2 ? https : http).request(
{
method: "POST",
hostname: chatUrl.hostname,
port: chatUrl.port || (https2 ? 443 : 80),
path: chatUrl.pathname + chatUrl.search,
headers: {
"content-type": "application/json",
authorization: "Bearer " + g.key,
accept: "text/event-stream",
host: chatUrl.host,
"content-length": Buffer.byteLength(chatBody),
},
},
(upRes) => {
if (upRes.statusCode !== 200) {
const eb = [];
upRes.on("data", (c) => eb.push(c));
upRes.on("end", () => {
const raw = Buffer.concat(eb).toString("utf-8").slice(0, 2000);
if (doCapture) writeCapture("last-chat-response.txt", `STATUS ${upRes.statusCode}\n\n` + raw);
// 提取上游错误消息,作为一条助手回复显示给 Codex(而不是"断连")
let human = raw.slice(0, 800);
try {
const j = JSON.parse(raw);
human = (j.error && (j.error.message || j.error)) || j.message || human;
} catch {}
let s = conv.onChunk({ choices: [{ delta: { content: `⚠️ 中转上游返回错误 (${upRes.statusCode}):\n${human}` } }] });
s += conv.finish();
endOnce(s);
});
return;
}
const rawBuf = [];
let sseBuf = "";
upRes.on("data", (chunk) => {
if (doCapture) rawBuf.push(chunk);
sseBuf += chunk.toString("utf-8");
let i;
while ((i = sseBuf.indexOf("\n")) >= 0) {
const line = sseBuf.slice(0, i).trim();
sseBuf = sseBuf.slice(i + 1);
if (!line.startsWith("data:")) continue;
const data = line.slice(5).trim();
if (data === "[DONE]") {
endOnce(conv.finish());
return;
}
try {
res.write(conv.onChunk(JSON.parse(data)));
} catch {}
}
});
upRes.on("end", () => {
if (doCapture) writeCapture("last-chat-response.txt", `STATUS 200\n\n` + Buffer.concat(rawBuf).toString("utf-8").slice(0, 8000));
endOnce(conv.finish());
});
}
);
up2.on("error", (e) => {
if (doCapture) writeCapture("last-chat-response.txt", "PROXY ERROR: " + String(e.message || e));
endOnce(conv.ev("error", { code: "proxy_error", message: String(e.message || e) }));
});
up2.end(chatBody);
return;
}
let target;
try {
target = new URL(g.baseUrl.replace(/\/$/, "") + req.url);
} catch {
res.writeHead(502);
res.end("分组 API 地址无效");
return;
}
const isHttps = target.protocol === "https:";
const mod = isHttps ? https : http;
const headers = { ...req.headers };
headers["host"] = target.host;
// Anthropic 风格请求(Claude Code 走 /v1/messages):保持客户端原本的认证方式,只把密钥换成分组的真实密钥。
// 关键:不要同时塞 Bearer + x-api-key —— 有的中转会据此判定为"直连 API 模式"而落到空账号池,返回 "No available accounts"。
const isAnthropic = /\/v1\/messages/.test(req.url || "") || "anthropic-version" in headers || "x-api-key" in headers;
if (isAnthropic) {
if ("x-api-key" in headers) {
headers["x-api-key"] = g.key; // 客户端用的是 x-api-key 模式
delete headers["authorization"];
} else {
headers["authorization"] = "Bearer " + g.key; // Claude Code 默认:只发 Bearer
delete headers["x-api-key"];
}
if (!headers["anthropic-version"]) headers["anthropic-version"] = "2023-06-01";
} else {
headers["authorization"] = "Bearer " + g.key; // 非 Anthropic(Codex 等):换成分组密钥
}
delete headers["content-length"];
if (body.length) headers["content-length"] = Buffer.byteLength(body);
if (doCapture) {
writeCapture("last-request.json", `URL ${req.method} ${req.url}\nUPSTREAM ${target.href}\n\n` + body.toString("utf-8"));
}
const up = mod.request(
{
method: req.method,
hostname: target.hostname,
port: target.port || (isHttps ? 443 : 80),
path: target.pathname + target.search,
headers,
},
(upRes) => {
res.writeHead(upRes.statusCode || 502, upRes.headers);
if (doCapture) {
const buf = [];
upRes.on("data", (c) => {
buf.push(c);
res.write(c);
});
upRes.on("end", () => {
res.end();
writeCapture("last-response.txt", `STATUS ${upRes.statusCode}\n\n` + Buffer.concat(buf).toString("utf-8"));
});
} else {
upRes.pipe(res); // 流式透传(SSE 也 OK)
}
}
);
up.on("error", (e) => {
if (!res.headersSent) res.writeHead(502, { "content-type": "text/plain; charset=utf-8" });
res.end("上游错误:" + e.message);
});
up.end(body);
});
}
function start() {
load();
if (server) return { running: true, port: config.port };
return new Promise((resolve, reject) => {
server = http.createServer(handle);
server.on("error", (e) => {
server = null;
reject(e);
});
server.listen(config.port, "127.0.0.1", () => resolve({ running: true, port: config.port }));
});
}
function stop() {
if (server) {
server.close();
server = null;
}
return { running: false };
}
function isRunning() {
return !!server;
}
function status() {
load();
return {
running: isRunning(),
port: config.port,
routerKey: config.routerKey,
address: `http://127.0.0.1:${config.port}`,
activeId: config.activeId,
claudeApplied: claudeApplied(),
groups: config.groups.map((g) => ({
id: g.id,
name: g.name,
baseUrl: g.isDefault ? "(Codex 原始配置 · 直连你的账号)" : g.baseUrl,
model: g.model || "",
mode: g.mode || "responses",
contextWindow: g.contextWindow || "",
hasKey: g.isDefault ? true : !!g.key,
isDefault: !!g.isDefault,
codexImage: !!g.codexImage,
})),
};
}
// ——————————————————————————————————————————————————————————————
// gen-image 生图技能:把「用于 Codex 生图」的中转分组凭证写进技能的 .env
// ——————————————————————————————————————————————————————————————
// 技能目录名可能因显示名不同而变(市场安装按 skill.name 派生中文目录),
// 故扫描各 skills/*/SKILL.md 的 name 字段来定位,而非写死路径。
function genImageSkillDir() {
const base = path.join(CODEX_HOME, "skills");
let names = [];
try {
names = fs.readdirSync(base, { withFileTypes: true })
.filter((d) => d.isDirectory() || d.isSymbolicLink())
.map((d) => d.name);
} catch {
return null;
}
for (const name of names) {
try {
const txt = fs.readFileSync(path.join(base, name, "SKILL.md"), "utf-8");
const fm = /^---\s*\n([\s\S]*?)\n---/.exec(txt);
const scope = fm ? fm[1] : txt.slice(0, 400);
const m = /^\s*name\s*:\s*(.+)$/im.exec(scope);
if (m && m[1].trim().replace(/^["']|["']$/g, "") === "gen-image") return path.join(base, name);
} catch {}
}
for (const guess of ["gen-image", "gen-image-生图"]) {
if (fs.existsSync(path.join(base, guess, "SKILL.md"))) return path.join(base, guess);
}
return null;
}
function writeGenImageEnv(dir, baseUrl, key, model) {
const body =
"# 由 TD工具箱「中转」自动写入:勾选“在 Codex 中用于生图”的分组凭证。\n" +
"# 取消勾选 / 删除该分组会被清除。请勿手动提交到 git。\n" +
`IMAGE_API_BASE_URL=${baseUrl || ""}\n` +
`IMAGE_API_KEY=${key || ""}\n` +
`IMAGE_MODEL=${model || "gpt-image-2"}\n`;
const p = path.join(dir, ".env");
fs.writeFileSync(p, body, "utf-8");
try { fs.chmodSync(p, 0o600); } catch {}
}
function clearGenImageEnv(dir) {
const p = path.join(dir, ".env");
try {
if (!fs.existsSync(p)) return;
// 只清除工具箱自动写入的那份,保留用户手工建的 .env(避免误删)
if (fs.readFileSync(p, "utf-8").includes("由 TD工具箱")) fs.unlinkSync(p);
} catch {}
}
// 依据当前分组的 codexImage 标记,同步写入/清除 gen-image 的 .env。
// 生图默认用 gpt-image-2(分组的 model 是对话模型,不用于生图)。
function syncGenImage() {
load();
const dir = genImageSkillDir();
const src = config.groups.find((g) => g.codexImage && !g.isDefault && g.baseUrl && g.key);
if (!dir) return { ok: false, reason: src ? "skill-not-installed" : "no-skill" };
if (!src) { clearGenImageEnv(dir); return { ok: true, cleared: true, dir }; }
writeGenImageEnv(dir, src.baseUrl, src.key, src.imageModel || "gpt-image-2");
return { ok: true, wrote: true, dir, group: src.name };
}
// —— 分组增删改 / 切换 ——
function upsertGroup(g) {
load();
let id;
if (g.id) {
const i = config.groups.findIndex((x) => x.id === g.id);
if (i >= 0) config.groups[i] = { ...config.groups[i], ...g };
else config.groups.push({ ...g });
id = g.id;
} else {
g.id = "g-" + crypto.randomBytes(6).toString("hex");
config.groups.push(g);
id = g.id;
}
if (!config.activeId) config.activeId = g.id;
// 生图来源唯一:勾选的分组独占,清掉其它分组的标记
const cur = config.groups.find((x) => x.id === id);
if (cur && cur.codexImage) {
for (const grp of config.groups) if (grp.id !== id) grp.codexImage = false;
}
save();
try { syncGenImage(); } catch {}
return status();
}
function deleteGroup(id) {
load();
const g = config.groups.find((x) => x.id === id);
if (g && g.isDefault) return { ...status(), error: "默认分组不可删除" };
config.groups = config.groups.filter((x) => x.id !== id);
if (config.activeId === id) config.activeId = config.groups[0] ? config.groups[0].id : null;
save();
try { syncGenImage(); } catch {} // 删掉的可能正是生图来源,同步清除 .env
return status();
}
function setActive(id) {
load();
const target = config.groups.find((g) => g.id === id);
if (!target) return status();
const before = currentModelProvider();
let needsRestart = false;
let mode = "proxy";
if (target.isDefault) {
// 切回默认 = 还原原始配置,Codex 直连自己账号(需重启 Codex)
restoreDirect();
mode = "direct";
needsRestart = before === PROVIDER_ID; // 之前在走路由 → 需重启
} else {
// 切到中转分组:确保 config.toml 指向本地路由
if (before !== PROVIDER_ID) {
applyToCodex();
needsRestart = true; // 从直连切到路由 → 需重启一次
}
// 若分组指定了模型:写模型目录窗口 + 设 config 模型,让 Codex 按真实窗口压缩
if (target.model) {
applyModelCatalog(target);
setConfigModel(target.model);
}
}
config.activeId = id;
save();
return { ...status(), _needsRestart: needsRestart, _mode: mode };
}
function setPort(port) {
load();
config.port = parseInt(port, 10) || 8787;
save();
return status();
}
// —— 自动配置 Codex —— 写 config.toml 的 local-router provider + auth.json 的 routerKey
function codexSnippet() {
load();
return `model_provider = "${PROVIDER_ID}"\n\n[model_providers.${PROVIDER_ID}]\nname = "本地路由"\nbase_url = "http://127.0.0.1:${config.port}"\nwire_api = "responses"\nrequires_openai_auth = true`;
}
function applyToCodex() {
load();
// 本地路由需要一个中转分组做上游;若当前是默认/空,自动选第一个中转分组
const cur = config.groups.find((g) => g.id === config.activeId);
if (!cur || cur.isDefault) {
const proxy = config.groups.find((g) => !g.isDefault);
if (proxy) config.activeId = proxy.id;
}
const cfgFile = path.join(CODEX_HOME, "config.toml");
let text = "";
try {
text = fs.readFileSync(cfgFile, "utf-8");
} catch {}
// 备份
try {
fs.mkdirSync(path.join(CODEX_HOME, "backups_state", "router"), { recursive: true });
fs.writeFileSync(path.join(CODEX_HOME, "backups_state", "router", "config.toml." + Date.now() + ".bak"), text);
} catch {}
// 替换/插入 model_provider 根键
if (/^\s*model_provider\s*=/m.test(text)) {
text = text.replace(/^\s*model_provider\s*=.*$/m, `model_provider = "${PROVIDER_ID}"`);
} else {
text = `model_provider = "${PROVIDER_ID}"\n` + text;
}
// 清掉「默认分组」原始 provider 的旧段(已存进 router.json,避免残留误导)
const def = config.groups.find((g) => g.isDefault);
if (def && def.modelProvider && def.modelProvider !== PROVIDER_ID) {
text = removeTomlSection(text, `model_providers.${def.modelProvider}`);
}
// 替换/插入 [model_providers.local-router] 段:先删旧的(含被 bug 弄重复的),再追加干净的
const block = `[model_providers.${PROVIDER_ID}]\nname = "本地路由"\nbase_url = "http://127.0.0.1:${config.port}"\nwire_api = "responses"\nrequires_openai_auth = true\n`;
text = removeTomlSection(text, `model_providers.${PROVIDER_ID}`);
text = text.trimEnd() + "\n\n" + block;
fs.writeFileSync(cfgFile, text, "utf-8");
// auth.json 写 routerKey(保留其它字段),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 = config.routerKey;
try {
fs.writeFileSync(authFile, JSON.stringify(auth, null, 2), "utf-8");
fs.chmodSync(authFile, 0o600);
} catch {}
// 当前分组若指定了模型 → 写模型目录窗口 + 设 config 模型
const cur2 = config.groups.find((g) => g.id === config.activeId);
if (cur2 && !cur2.isDefault && cur2.model) {
applyModelCatalog(cur2);
setConfigModel(cur2.model);
}
save();
return { ok: true };
}
// —— 自动接入 Claude Code —— 写 ~/.claude/settings.json 的 env:ANTHROPIC_BASE_URL + ANTHROPIC_AUTH_TOKEN
// Claude Code 会把 settings.json 里 env 的键当环境变量注入每个会话,从而走本地路由。
function claudeSettingsPath() {
return path.join(CLAUDE_HOME, "settings.json");
}
function applyToClaudeCode() {
load();
// 本地路由需要一个中转分组做上游;若当前是默认/空,自动选第一个中转分组
const cur = config.groups.find((g) => g.id === config.activeId);
if (!cur || cur.isDefault) {
const proxy = config.groups.find((g) => !g.isDefault);
if (proxy) {
config.activeId = proxy.id;
save();
}
}
fs.mkdirSync(CLAUDE_HOME, { recursive: true });
const p = claudeSettingsPath();
let settings = {};
let existed = false;
try {
settings = JSON.parse(fs.readFileSync(p, "utf-8"));
existed = true;
} catch {}
// 备份原文件
if (existed) {
try {
const dir = path.join(CLAUDE_HOME, "backups_router");
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, "settings.json." + Date.now() + ".bak"), fs.readFileSync(p));
} catch {}
}
settings.env = settings.env || {};
// 首次接入时记住用户原有的两项,便于「取消接入」还原
if (!config.claudePrev) {
config.claudePrev = {
base: Object.prototype.hasOwnProperty.call(settings.env, "ANTHROPIC_BASE_URL") ? settings.env.ANTHROPIC_BASE_URL : null,
token: Object.prototype.hasOwnProperty.call(settings.env, "ANTHROPIC_AUTH_TOKEN") ? settings.env.ANTHROPIC_AUTH_TOKEN : null,
};
}
settings.env.ANTHROPIC_BASE_URL = `http://127.0.0.1:${config.port}`;
settings.env.ANTHROPIC_AUTH_TOKEN = config.routerKey;
fs.writeFileSync(p, JSON.stringify(settings, null, 2), "utf-8");
try {
fs.chmodSync(p, 0o600);
} catch {}
save();
return { ok: true, path: p };
}
// 取消接入:把 settings.json 里的两项还原成用户原值(或删除)
function restoreClaudeCode() {
load();
const p = claudeSettingsPath();
let settings = {};
try {
settings = JSON.parse(fs.readFileSync(p, "utf-8"));
} catch {
return { ok: true }; // 没有文件就无需还原
}
settings.env = settings.env || {};
const prev = config.claudePrev || { base: null, token: null };
for (const [k, v] of [["ANTHROPIC_BASE_URL", prev.base], ["ANTHROPIC_AUTH_TOKEN", prev.token]]) {
if (v == null) delete settings.env[k];
else settings.env[k] = v;
}
if (settings.env && Object.keys(settings.env).length === 0) delete settings.env;
fs.writeFileSync(p, JSON.stringify(settings, null, 2), "utf-8");
delete config.claudePrev;
save();
return { ok: true };
}
// Claude Code 是否已指向本地路由
function claudeApplied() {
try {
const s = JSON.parse(fs.readFileSync(claudeSettingsPath(), "utf-8"));
return !!(s.env && s.env.ANTHROPIC_BASE_URL === `http://127.0.0.1:${(config || {}).port || 8787}`);
} catch {
return false;
}
}
module.exports = {
load,
start,
stop,
isRunning,
status,
upsertGroup,
deleteGroup,
setActive,
setPort,
applyToCodex,
codexSnippet,
applyToClaudeCode,
restoreClaudeCode,
claudeApplied,
getGroupKey,
syncGenImage,
groupById,
usableGroups,
llmGroup,
fetchModels,
};
+66
View File
@@ -0,0 +1,66 @@
// 密钥安全存储:用 Electron safeStorage 加密后写入 userData/secrets.json
// name 取 "github" / "registry"。读时解密;无则返回空串。
const fs = require("fs");
const path = require("path");
const { app, safeStorage } = require("electron");
function secretsPath() {
return path.join(app.getPath("userData"), "secrets.json");
}
function readStore() {
try {
const txt = fs.readFileSync(secretsPath(), "utf-8");
return JSON.parse(txt || "{}");
} catch (e) {
return {};
}
}
function writeStore(store) {
const p = secretsPath();
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, JSON.stringify(store, null, 2), { encoding: "utf-8" });
try {
fs.chmodSync(p, 0o600);
} catch (e) {
/* 忽略权限设置失败(非 POSIX 平台) */
}
}
// 取密钥;无则空串
function getSecret(name) {
const store = readStore();
const enc = store[name];
if (!enc) return "";
try {
if (safeStorage.isEncryptionAvailable()) {
return safeStorage.decryptString(Buffer.from(enc, "base64"));
}
// 加密不可用时的降级:明文(base64)存储
return Buffer.from(enc, "base64").toString("utf-8");
} catch (e) {
return "";
}
}
// 存密钥;空值视为删除
function setSecret(name, value) {
const store = readStore();
if (!value) {
delete store[name];
writeStore(store);
return { ok: true };
}
let encB64;
if (safeStorage.isEncryptionAvailable()) {
encB64 = safeStorage.encryptString(value).toString("base64");
} else {
encB64 = Buffer.from(value, "utf-8").toString("base64");
}
store[name] = encB64;
writeStore(store);
return { ok: true };
}
module.exports = { getSecret, setSecret };
+234
View File
@@ -0,0 +1,234 @@
// sessions.js —— 读取 / 解析 / 导出 / 备份 / 导入 Codex 会话记录。
// 直接读 ~/.codex/sessions / archived_sessions 下的 rollout-*.jsonl,不依赖数据库,跨平台。
const fs = require("fs");
const path = require("path");
const os = require("os");
const CODEX_HOME = path.join(os.homedir(), ".codex");
const SESSIONS_DIR = path.join(CODEX_HOME, "sessions");
const ARCHIVED_SESSIONS_DIR = path.join(CODEX_HOME, "archived_sessions");
const SESSION_INDEX = path.join(CODEX_HOME, "session_index.jsonl");
// 递归找出所有 rollout-*.jsonl
function findRolloutFiles(base) {
const out = [];
function walk(dir) {
let ents;
try {
ents = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return;
}
for (const ent of ents) {
const full = path.join(dir, ent.name);
if (ent.isDirectory()) walk(full);
else if (ent.isFile() && ent.name.startsWith("rollout-") && ent.name.endsWith(".jsonl")) out.push(full);
}
}
walk(base);
return out;
}
// 读取 session_index.jsonl,得到 {会话id: Codex 生成的标题}
function loadIndexTitles() {
const map = {};
try {
const txt = fs.readFileSync(SESSION_INDEX, "utf-8");
for (const line of txt.split("\n")) {
if (!line.trim()) continue;
try {
const d = JSON.parse(line);
if (d.id && d.thread_name) map[d.id] = d.thread_name;
} catch {}
}
} catch {}
return map;
}
// 只读文件开头一段,快速取元信息与首条用户消息(避免整读大文件)
function peekMeta(file) {
let head = "";
try {
const fd = fs.openSync(file, "r");
const buf = Buffer.alloc(262144); // 256KB:足够越过超长系统提示找到首条用户消息
const n = fs.readSync(fd, buf, 0, buf.length, 0);
fs.closeSync(fd);
head = buf.slice(0, n).toString("utf-8");
} catch {
return null;
}
const meta = { id: "", cwd: "", ts: "", source: "", threadSource: "", provider: "", title: "" };
const lines = head.split("\n");
for (const line of lines) {
if (!line.trim()) continue;
let d;
try {
d = JSON.parse(line);
} catch {
continue; // 可能是被截断的最后一行
}
const p = d.payload || {};
if (d.type === "session_meta") {
meta.id = p.session_id || p.id || "";
meta.cwd = p.cwd || "";
meta.ts = p.timestamp || d.timestamp || "";
meta.source = p.source || "";
meta.threadSource = p.thread_source || "";
meta.provider = p.model_provider || "";
} else if (d.type === "event_msg" && p.type === "user_message" && !meta.title) {
meta.title = String(p.message || "").replace(/\s+/g, " ").trim().slice(0, 60);
}
if (meta.id && meta.title) break;
}
return meta;
}
function normalizeListOptions(opts) {
if (typeof opts === "boolean") return { includeSubagent: opts, includeArchived: false };
return {
includeSubagent: !!(opts && opts.includeSubagent),
includeArchived: !!(opts && opts.includeArchived),
};
}
// 列出会话(默认只列当前 sessions,并排除子 agent 派生线程;全量模式会带 archived_sessions)
function listConversations(opts = {}) {
const { includeSubagent, includeArchived } = normalizeListOptions(opts);
const roots = [{ dir: SESSIONS_DIR, archived: false }];
if (includeArchived) roots.push({ dir: ARCHIVED_SESSIONS_DIR, archived: true });
const titles = loadIndexTitles();
const items = [];
for (const root of roots) {
const files = findRolloutFiles(root.dir);
for (const file of files) {
const m = peekMeta(file);
if (!m) continue;
if (!includeSubagent && m.threadSource === "subagent") continue;
let size = 0;
try {
size = fs.statSync(file).size;
} catch {}
items.push({
id: m.id,
title: titles[m.id] || m.title || "(无标题对话)",
project: m.cwd ? path.basename(m.cwd) : "",
cwd: m.cwd,
ts: m.ts,
source: m.source,
threadSource: m.threadSource,
provider: m.provider,
path: file,
size,
archived: root.archived,
});
}
}
items.sort((a, b) => (b.ts || "").localeCompare(a.ts || ""));
return items;
}
// 解析单个会话文件为可读消息列表
function readConversation(file) {
const messages = [];
let meta = {};
let content;
try {
content = fs.readFileSync(file, "utf-8");
} catch (e) {
return { meta, messages, error: String(e) };
}
for (const line of content.split("\n")) {
if (!line.trim()) continue;
let d;
try {
d = JSON.parse(line);
} catch {
continue;
}
const p = d.payload || {};
const ts = d.timestamp || "";
if (d.type === "session_meta") {
meta = { id: p.session_id || p.id || "", cwd: p.cwd || "", ts: p.timestamp || ts, provider: p.model_provider || "" };
} else if (d.type === "event_msg" && p.type === "user_message") {
messages.push({ role: "user", text: String(p.message || ""), ts });
} else if (d.type === "event_msg" && p.type === "agent_message") {
messages.push({ role: "assistant", text: String(p.message || ""), ts });
} else if (d.type === "response_item" && p.type === "function_call") {
const name = p.name || "tool";
messages.push({ role: "tool", text: `调用工具:${name}`, ts, collapsed: true });
}
}
return { meta, messages };
}
// 导出为 Markdown 文本
function toMarkdown(item, conv) {
const lines = [];
lines.push(`# ${item.title || "Codex 会话记录"}`);
lines.push("");
if (item.project) lines.push(`- 项目:${item.project}`);
if (item.cwd) lines.push(`- 目录:${item.cwd}`);
if (item.ts) lines.push(`- 时间:${item.ts}`);
if (item.provider) lines.push(`- Provider:${item.provider}`);
lines.push("");
lines.push("---");
lines.push("");
for (const m of conv.messages) {
if (m.role === "user") lines.push(`## 👤 用户\n\n${m.text}\n`);
else if (m.role === "assistant") lines.push(`## 🤖 Codex\n\n${m.text}\n`);
else if (m.role === "tool") lines.push(`> 🔧 ${m.text}\n`);
}
return lines.join("\n");
}
// 备份:把整个 sessions/ 与索引复制到目标目录下的 codex-sessions-backup/
function backupAll(destDir, log = () => {}) {
const out = path.join(destDir, "codex-sessions-backup");
fs.mkdirSync(out, { recursive: true });
const files = findRolloutFiles(SESSIONS_DIR);
let n = 0;
for (const f of files) {
const rel = path.relative(SESSIONS_DIR, f);
const dst = path.join(out, "sessions", rel);
fs.mkdirSync(path.dirname(dst), { recursive: true });
fs.copyFileSync(f, dst);
n++;
}
if (fs.existsSync(SESSION_INDEX)) fs.copyFileSync(SESSION_INDEX, path.join(out, "session_index.jsonl"));
log(`已备份 ${n} 个会话文件到 ${out}`);
return { path: out, count: n };
}
// 导入:把外部备份目录里的 rollout-*.jsonl 合并进本机 sessions/(按原年月日结构,已存在则跳过)
function importSessions(srcDir, log = () => {}) {
// 支持传入 backup 根目录或其中的 sessions/ 子目录
let base = srcDir;
if (fs.existsSync(path.join(srcDir, "sessions"))) base = path.join(srcDir, "sessions");
const files = findRolloutFiles(base);
let added = 0;
let skipped = 0;
for (const f of files) {
const rel = path.relative(base, f);
const dst = path.join(SESSIONS_DIR, rel);
if (fs.existsSync(dst)) {
skipped++;
continue;
}
fs.mkdirSync(path.dirname(dst), { recursive: true });
fs.copyFileSync(f, dst);
added++;
}
log(`导入完成:新增 ${added} 个,跳过 ${skipped} 个(已存在)`);
return { added, skipped };
}
module.exports = {
CODEX_HOME,
SESSIONS_DIR,
ARCHIVED_SESSIONS_DIR,
listConversations,
readConversation,
toMarkdown,
backupAll,
importSessions,
};
+354
View File
@@ -0,0 +1,354 @@
// translate.js —— Responses API ⇄ Chat Completions 协议转换。
// 让只支持 chat/completions 的中转(DeepSeek/GLM/Kimi/通义等)也能给 Codex(用 responses)用。
// 参考实现:sybil-solutions/codex-shim、BerriAI/litellm。
const crypto = require("crypto");
function genId(prefix) {
return prefix + Date.now().toString(36) + crypto.randomBytes(4).toString("hex");
}
// content parts / string → 纯文本
function partsToText(content) {
if (typeof content === "string") return content;
if (Array.isArray(content)) {
return content
.map((p) => {
if (typeof p === "string") return p;
if (p && (p.type === "input_text" || p.type === "output_text" || p.type === "text")) return p.text || "";
return "";
})
.join("");
}
return "";
}
// Responses 的 tool(扁平)→ Chat Completions 的 tool(嵌套在 function 下)
function toChatTool(t) {
if (!t) return null;
if (t.type === "function" && t.function) return t; // 已是嵌套
if (t.type === "function" || t.name) {
return {
type: "function",
function: {
name: t.name,
description: t.description || "",
parameters: t.parameters || { type: "object", properties: {} },
},
};
}
return null; // 托管型工具(web_search 等)无 chat 等价,先跳过
}
function toChatToolChoice(tc) {
if (typeof tc === "string") return tc;
if (tc && tc.type === "function" && tc.name) return { type: "function", function: { name: tc.name } };
return tc;
}
// —— 请求:Responses → Chat Completions ——
function responsesToChat(req, overrideModel) {
const messages = [];
if (req.instructions) messages.push({ role: "system", content: req.instructions });
const pending = [];
const flush = () => {
if (pending.length) {
messages.push({ role: "assistant", content: null, tool_calls: pending.splice(0) });
}
};
const input = req.input;
if (typeof input === "string") {
messages.push({ role: "user", content: input });
} else if (Array.isArray(input)) {
for (const item of input) {
const t = item.type;
if (t === "message" || (item.role && !t)) {
flush();
const role = item.role === "developer" ? "system" : item.role;
messages.push({ role, content: partsToText(item.content) });
} else if (t === "function_call") {
const callId = item.call_id || item.id || "call_0";
pending.push({ id: callId, type: "function", function: { name: item.name || "", arguments: item.arguments || "" } });
} else if (t === "function_call_output") {
flush();
const out = typeof item.output === "string" ? item.output : JSON.stringify(item.output);
messages.push({ role: "tool", tool_call_id: item.call_id, content: out });
}
// reasoning item → 丢弃(chat 上游用不到)
}
}
flush();
const out = { model: overrideModel || req.model, messages, stream: req.stream !== false };
if (Array.isArray(req.tools)) {
const tools = req.tools.map(toChatTool).filter(Boolean);
if (tools.length) out.tools = tools;
}
if (req.tool_choice) out.tool_choice = toChatToolChoice(req.tool_choice);
if (req.max_output_tokens) out.max_tokens = req.max_output_tokens;
if (req.temperature != null) out.temperature = req.temperature;
if (req.top_p != null) out.top_p = req.top_p;
if (req.parallel_tool_calls != null) out.parallel_tool_calls = req.parallel_tool_calls;
const eff = req.reasoning && req.reasoning.effort;
if (eff) out.reasoning_effort = eff;
if (out.stream) out.stream_options = { include_usage: true };
return out;
}
function mapUsage(u) {
if (!u) return null;
return {
input_tokens: u.prompt_tokens || 0,
input_tokens_details: { cached_tokens: (u.prompt_tokens_details && u.prompt_tokens_details.cached_tokens) || 0 },
output_tokens: u.completion_tokens || 0,
output_tokens_details: { reasoning_tokens: 0 },
total_tokens: u.total_tokens || 0,
};
}
// —— 响应:Chat Completions 流 → Responses 流事件(有状态转换器)——
class ChatToResponses {
constructor(model) {
this.model = model;
this.responseId = genId("resp_");
this.msgId = genId("msg_");
this.seq = 0;
this.outIndex = 0;
this.msgOpen = false;
this.msgClosed = false;
this.msgText = "";
this.msgOutputIndex = null;
this.tools = {}; // index → {id,name,arguments,outputIndex,closed}
this.usage = null;
this.started = false;
// 推理模型(DeepSeek-R1/v4、GLM、Kimi)的思考内容
this.reasonOpen = false;
this.reasonClosed = false;
this.reasonText = "";
this.reasonId = null;
this.reasonOutputIndex = null;
}
openReason() {
if (this.reasonOpen) return "";
this.reasonOpen = true;
this.reasonId = genId("rs_");
this.reasonOutputIndex = this.outIndex++;
let s = this.ev("response.output_item.added", {
output_index: this.reasonOutputIndex,
item: { id: this.reasonId, type: "reasoning", summary: [] },
});
s += this.ev("response.reasoning_summary_part.added", {
item_id: this.reasonId,
output_index: this.reasonOutputIndex,
summary_index: 0,
part: { type: "summary_text", text: "" },
});
return s;
}
closeReason() {
if (!this.reasonOpen || this.reasonClosed) return "";
this.reasonClosed = true;
let s = this.ev("response.reasoning_summary_text.done", {
item_id: this.reasonId,
output_index: this.reasonOutputIndex,
summary_index: 0,
text: this.reasonText,
});
s += this.ev("response.reasoning_summary_part.done", {
item_id: this.reasonId,
output_index: this.reasonOutputIndex,
summary_index: 0,
part: { type: "summary_text", text: this.reasonText },
});
s += this.ev("response.output_item.done", {
output_index: this.reasonOutputIndex,
item: { id: this.reasonId, type: "reasoning", summary: [{ type: "summary_text", text: this.reasonText }] },
});
return s;
}
ev(type, obj) {
const payload = Object.assign({ type, sequence_number: this.seq++ }, obj);
return `event: ${type}\ndata: ${JSON.stringify(payload)}\n\n`;
}
baseResponse(status, output) {
return {
id: this.responseId,
object: "response",
created_at: Math.floor(Date.now() / 1000),
status,
error: null,
incomplete_details: null,
instructions: null,
model: this.model,
output,
parallel_tool_calls: true,
previous_response_id: null,
reasoning: { effort: null, summary: null },
store: false,
temperature: 1.0,
text: { format: { type: "text" } },
tool_choice: "auto",
tools: [],
top_p: 1.0,
truncation: "disabled",
usage: this.usage,
metadata: {},
};
}
start() {
const r = this.baseResponse("in_progress", []);
return this.ev("response.created", { response: r }) + this.ev("response.in_progress", { response: r });
}
openMsg() {
if (this.msgOpen) return "";
this.msgOpen = true;
this.msgOutputIndex = this.outIndex++;
let s = this.ev("response.output_item.added", {
output_index: this.msgOutputIndex,
item: { id: this.msgId, type: "message", status: "in_progress", role: "assistant", content: [] },
});
s += this.ev("response.content_part.added", {
item_id: this.msgId,
output_index: this.msgOutputIndex,
content_index: 0,
part: { type: "output_text", text: "", annotations: [] },
});
return s;
}
closeMsg() {
if (!this.msgOpen || this.msgClosed) return "";
this.msgClosed = true;
let s = this.ev("response.output_text.done", {
item_id: this.msgId,
output_index: this.msgOutputIndex,
content_index: 0,
text: this.msgText,
logprobs: [],
});
s += this.ev("response.content_part.done", {
item_id: this.msgId,
output_index: this.msgOutputIndex,
content_index: 0,
part: { type: "output_text", text: this.msgText, annotations: [] },
});
s += this.ev("response.output_item.done", {
output_index: this.msgOutputIndex,
item: {
id: this.msgId,
type: "message",
status: "completed",
role: "assistant",
content: [{ type: "output_text", text: this.msgText, annotations: [] }],
},
});
return s;
}
onToolDelta(tc) {
let out = "";
const index = tc.index != null ? tc.index : 0;
const fn = tc.function || {};
let st = this.tools[index];
if (!st) {
out += this.closeReason(); // 先关推理
out += this.closeMsg(); // 再关文本消息,才开工具项
const callId = tc.id || "call_" + index;
st = { id: callId, name: fn.name || "", arguments: "", outputIndex: this.outIndex++, closed: false };
this.tools[index] = st;
out += this.ev("response.output_item.added", {
output_index: st.outputIndex,
item: { id: st.id, type: "function_call", status: "in_progress", call_id: st.id, name: st.name, arguments: "" },
});
} else if (fn.name) {
st.name += fn.name;
}
if (fn.arguments) {
st.arguments += fn.arguments;
out += this.ev("response.function_call_arguments.delta", {
item_id: st.id,
output_index: st.outputIndex,
delta: fn.arguments,
});
}
return out;
}
closeTool(st) {
if (st.closed) return "";
st.closed = true;
let s = this.ev("response.function_call_arguments.done", {
item_id: st.id,
output_index: st.outputIndex,
arguments: st.arguments,
});
s += this.ev("response.output_item.done", {
output_index: st.outputIndex,
item: { id: st.id, type: "function_call", status: "completed", call_id: st.id, name: st.name, arguments: st.arguments },
});
return s;
}
// 喂一个 chat 流 chunk(已解析的 JSON),返回要写给 Codex 的 Responses 事件串
onChunk(obj) {
let out = "";
if (!this.started) {
out += this.start();
this.started = true;
}
if (obj.usage) this.usage = mapUsage(obj.usage);
const choice = obj.choices && obj.choices[0];
if (!choice) return out;
const delta = choice.delta || {};
// 推理内容(reasoning_content)→ 思考事件
const rc = delta.reasoning_content || delta.reasoning;
if (rc) {
out += this.openReason();
this.reasonText += rc;
out += this.ev("response.reasoning_summary_text.delta", {
item_id: this.reasonId,
output_index: this.reasonOutputIndex,
summary_index: 0,
delta: rc,
});
}
if (delta.content) {
out += this.closeReason(); // 有正文了,先收尾推理
out += this.openMsg();
this.msgText += delta.content;
out += this.ev("response.output_text.delta", {
item_id: this.msgId,
output_index: this.msgOutputIndex,
content_index: 0,
delta: delta.content,
logprobs: [],
});
}
if (Array.isArray(delta.tool_calls)) {
for (const tc of delta.tool_calls) out += this.onToolDelta(tc);
}
return out;
}
finish() {
let out = "";
if (!this.started) {
out += this.start();
this.started = true;
}
out += this.closeReason();
out += this.closeMsg();
const sts = Object.values(this.tools).sort((a, b) => a.outputIndex - b.outputIndex);
for (const st of sts) out += this.closeTool(st);
const output = [];
if (this.reasonOpen)
output.push({ id: this.reasonId, type: "reasoning", summary: [{ type: "summary_text", text: this.reasonText }] });
if (this.msgOpen)
output.push({
id: this.msgId,
type: "message",
status: "completed",
role: "assistant",
content: [{ type: "output_text", text: this.msgText, annotations: [] }],
});
for (const st of sts)
output.push({ id: st.id, type: "function_call", status: "completed", call_id: st.id, name: st.name, arguments: st.arguments });
out += this.ev("response.completed", { response: this.baseResponse("completed", output) });
return out;
}
}
module.exports = { responsesToChat, ChatToResponses };
+50
View File
@@ -0,0 +1,50 @@
// zip.js —— 零依赖 ZIP 解压(stored/deflate),供技能安装与热更新复用。
"use strict";
const fs = require("fs");
const path = require("path");
const zlib = require("zlib");
// 解析中央目录,把 zip 内容写入 destDir;返回写出的文件数。
function extractZip(buf, destDir) {
// 找 EOCD(0x06054b50),从尾部回扫
let eocd = -1;
for (let i = buf.length - 22; i >= 0 && i >= buf.length - 22 - 65536; i--) {
if (buf.readUInt32LE(i) === 0x06054b50) { eocd = i; break; }
}
if (eocd < 0) throw new Error("不是有效的 zip(找不到 EOCD)");
const count = buf.readUInt16LE(eocd + 10);
let ptr = buf.readUInt32LE(eocd + 16); // 中央目录起始偏移
let written = 0;
for (let n = 0; n < count; n++) {
if (buf.readUInt32LE(ptr) !== 0x02014b50) break;
const method = buf.readUInt16LE(ptr + 10);
const compSize = buf.readUInt32LE(ptr + 20);
const nameLen = buf.readUInt16LE(ptr + 28);
const extraLen = buf.readUInt16LE(ptr + 30);
const commentLen = buf.readUInt16LE(ptr + 32);
const localOff = buf.readUInt32LE(ptr + 42);
const name = buf.toString("utf8", ptr + 46, ptr + 46 + nameLen);
ptr += 46 + nameLen + extraLen + commentLen;
// 读本地头,定位数据
if (buf.readUInt32LE(localOff) !== 0x04034b50) continue;
const lNameLen = buf.readUInt16LE(localOff + 26);
const lExtraLen = buf.readUInt16LE(localOff + 28);
const dataStart = localOff + 30 + lNameLen + lExtraLen;
const comp = buf.subarray(dataStart, dataStart + compSize);
// 防目录穿越
const safe = name.replace(/\\/g, "/").replace(/^\/+/, "");
if (safe.includes("../")) continue;
const outPath = path.join(destDir, safe);
if (safe.endsWith("/")) { fs.mkdirSync(outPath, { recursive: true }); continue; }
fs.mkdirSync(path.dirname(outPath), { recursive: true });
let data;
if (method === 0) data = comp;
else if (method === 8) data = zlib.inflateRawSync(comp);
else throw new Error(`不支持的压缩方式 ${method}`);
fs.writeFileSync(outPath, data);
written++;
}
return written;
}
module.exports = { extractZip };
+1446
View File
File diff suppressed because it is too large Load Diff
+122
View File
@@ -0,0 +1,122 @@
// 契约边界(已冻结):把主进程能力安全暴露给前端 window.api
const { contextBridge, ipcRenderer } = require("electron");
contextBridge.exposeInMainWorld("api", {
scanLocal: () => ipcRenderer.invoke("scan-local"),
pickDir: () => ipcRenderer.invoke("pick-dir"),
packLocal: (rels, destDir) => ipcRenderer.invoke("pack-local", { rels, destDir }),
upload: (rels, repo, token, isPrivate) =>
ipcRenderer.invoke("upload", { rels, repo, token, isPrivate }),
marketList: () => ipcRenderer.invoke("market-list"),
marketInstall: (entry) => ipcRenderer.invoke("market-install", { entry }),
// —— 技能商店(cocoloop 镜像)——
skillCatalog: () => ipcRenderer.invoke("skill-catalog"),
skillInstall: (skill) => ipcRenderer.invoke("skill-install", { skill }),
skillInstalled: () => ipcRenderer.invoke("skill-installed"),
skillUninstall: (dir) => ipcRenderer.invoke("skill-uninstall", { dir }),
skillMigrate: () => ipcRenderer.invoke("skill-migrate"),
// —— 插件市场(tokendun 官方插件,自建 gitea)——
pluginCatalog: () => ipcRenderer.invoke("plugin-catalog"),
pluginInstall: (plugin) => ipcRenderer.invoke("plugin-install", { plugin }),
pluginInstalled: () => ipcRenderer.invoke("plugin-installed"),
pluginUninstall: (rel) => ipcRenderer.invoke("plugin-uninstall", { rel }),
pluginMigrate: () => ipcRenderer.invoke("plugin-migrate"),
// —— 热更新 ——
hotCheck: () => ipcRenderer.invoke("hot-check"),
hotApply: () => ipcRenderer.invoke("hot-apply"),
hotConfirm: () => ipcRenderer.invoke("hot-confirm"),
hotRestart: () => ipcRenderer.invoke("hot-restart"),
onHotProgress: (cb) => {
const handler = (_e, p) => cb(p);
ipcRenderer.on("hot-progress", handler);
return () => ipcRenderer.removeListener("hot-progress", handler);
},
ghPull: (repoInput, token) => ipcRenderer.invoke("gh-pull", { repoInput, token }),
ghRestore: (rels, enable) => ipcRenderer.invoke("gh-restore", { rels, enable }),
getSecret: (name) => ipcRenderer.invoke("get-secret", { name }),
setSecret: (name, value) => ipcRenderer.invoke("set-secret", { name, value }),
openExternal: (url) => ipcRenderer.invoke("open-external", { url }),
quitApp: () => ipcRenderer.invoke("app-quit"),
sessionsList: (opts) =>
ipcRenderer.invoke("sessions-list", typeof opts === "object" && opts !== null ? opts : { includeSubagent: !!opts }),
sessionRead: (path) => ipcRenderer.invoke("session-read", { path }),
sessionExport: (item) => ipcRenderer.invoke("session-export", { item }),
sessionsBackup: () => ipcRenderer.invoke("sessions-backup"),
sessionsImport: () => ipcRenderer.invoke("sessions-import"),
// —— 会话记忆 ——
memoryStatus: () => ipcRenderer.invoke("memory-status"),
memoryList: (opts) => ipcRenderer.invoke("memory-list", opts),
memorySetEnabled: (enabled) => ipcRenderer.invoke("memory-set-enabled", { enabled }),
memoryToggleProject: (cwd, enabled) => ipcRenderer.invoke("memory-toggle-project", { cwd, enabled }),
memoryResummarize: (path) => ipcRenderer.invoke("memory-resummarize", { path }),
memoryDelete: (id) => ipcRenderer.invoke("memory-delete", { id }),
memoryOpenHandoff: (cwd) => ipcRenderer.invoke("memory-open-handoff", { cwd }),
onMemoryChanged: (cb) => {
const handler = () => cb();
ipcRenderer.on("memory-changed", handler);
return () => ipcRenderer.removeListener("memory-changed", handler);
},
providerSyncStatus: () => ipcRenderer.invoke("provider-sync-status"),
providerSync: () => ipcRenderer.invoke("provider-sync"),
onProviderSyncProgress: (cb) => {
const handler = (_e, p) => cb(p);
ipcRenderer.on("provider-sync-progress", handler);
return () => ipcRenderer.removeListener("provider-sync-progress", handler);
},
routerStatus: () => ipcRenderer.invoke("router-status"),
routerUpsertGroup: (group) => ipcRenderer.invoke("router-upsert-group", { group }),
routerDeleteGroup: (id) => ipcRenderer.invoke("router-delete-group", { id }),
routerSetActive: (id) => ipcRenderer.invoke("router-set-active", { id }),
routerSetPort: (port) => ipcRenderer.invoke("router-set-port", { port }),
routerStart: () => ipcRenderer.invoke("router-start"),
routerStop: () => ipcRenderer.invoke("router-stop"),
routerApplyCodex: () => ipcRenderer.invoke("router-apply-codex"),
routerApplyClaude: () => ipcRenderer.invoke("router-apply-claude"),
routerRestoreClaude: () => ipcRenderer.invoke("router-restore-claude"),
routerSnippet: () => ipcRenderer.invoke("router-snippet"),
routerFetchModels: (baseUrl, key, groupId) => ipcRenderer.invoke("router-fetch-models", { baseUrl, key, groupId }),
onRouterChanged: (cb) => {
const handler = (_e, s) => cb(s);
ipcRenderer.on("router-changed", handler);
return () => ipcRenderer.removeListener("router-changed", handler);
},
// —— 创意画布(中转执行层)——
canvasStatus: () => ipcRenderer.invoke("canvas-status"),
canvasRun: (kind, params) => ipcRenderer.invoke("canvas-run", { kind, params }),
canvasGroups: () => ipcRenderer.invoke("canvas-groups"),
canvasModels: (groupId) => ipcRenderer.invoke("canvas-models", { groupId }),
onCanvasProgress: (cb) => {
const h = (_e, p) => cb(p);
ipcRenderer.on("canvas-progress", h);
return () => ipcRenderer.removeListener("canvas-progress", h);
},
// —— 全局 / 系统代理 ——
proxyStatus: () => ipcRenderer.invoke("proxy-status"),
proxyGetConfig: () => ipcRenderer.invoke("proxy-get-config"),
proxySetConfig: (cfg) => ipcRenderer.invoke("proxy-set-config", cfg),
proxyStart: (mode) => ipcRenderer.invoke("proxy-start", { mode }),
proxyStop: () => ipcRenderer.invoke("proxy-stop"),
proxyTest: () => ipcRenderer.invoke("proxy-test"),
onProxyChanged: (cb) => {
const handler = (_e, s) => cb(s);
ipcRenderer.on("proxy-changed", handler);
return () => ipcRenderer.removeListener("proxy-changed", handler);
},
// —— 检测 / 强制清除本机 Codex ——
codexDetect: () => ipcRenderer.invoke("codex-detect"),
codexPurge: (paths) => ipcRenderer.invoke("codex-purge", { paths }),
appVersion: () => ipcRenderer.invoke("app-version"),
checkUpdate: () => ipcRenderer.invoke("check-update"),
onUpdateStatus: (cb) => {
const handler = (_e, s) => cb(s);
ipcRenderer.on("update-status", handler);
return () => ipcRenderer.removeListener("update-status", handler);
},
onLog: (cb) => {
const handler = (_e, msg) => cb(msg);
ipcRenderer.on("log", handler);
return () => ipcRenderer.removeListener("log", handler);
},
});
+4579
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
{
"name": "codex-market",
"version": "1.5.7",
"description": "TD工具箱 / 技能商店 / 备份工具 —— 跨平台桌面客户端",
"author": "Codex Market",
"license": "MIT",
"main": "electron/bootstrap.js",
"hotContentVersion": "1.5.7",
"scripts": {
"start": "electron .",
"dist": "electron-builder",
"dist:mac": "electron-builder --mac",
"dist:win": "electron-builder --win",
"publish:hot": "node scripts/publish-hot.js"
},
"devDependencies": {
"electron": "^34.0.0",
"electron-builder": "^24.13.3"
},
"dependencies": {
"electron-updater": "^6.8.9",
"isomorphic-git": "^1.29.0",
"sql.js": "^1.14.1"
}
}
+2798
View File
File diff suppressed because it is too large Load Diff
+275655
View File
File diff suppressed because it is too large Load Diff
+486
View File
@@ -0,0 +1,486 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Content-Security-Policy"
content="default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; img-src 'self' data:; media-src 'self' data: blob:;" />
<title>TD工具箱</title>
<link rel="stylesheet" href="vendor/drawflow.min.css" />
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<!-- 顶部品牌区 -->
<header class="topbar">
<div class="brand">
<div class="logo" aria-hidden="true">
<img src="logo.png" alt="tokendun" width="40" height="40" />
</div>
<div class="brand-text">
<div class="brand-name">TD工具箱</div>
<div class="brand-sub">tokendun 工具箱 · 技能 / 插件 · 路由 · 代理</div>
</div>
</div>
<a class="promo" href="#" data-open="https://api.tokendun.cc" title="官方推荐中转 · api.tokendun.cc">
<span class="promo-badge">官方推荐</span>
<span class="promo-text">推荐中转 · <b>安全 · 稳定 · 可靠</b></span>
<span class="promo-arrow">↗</span>
</a>
<nav class="tabs" role="tablist">
<button class="tab active" data-tab="market" role="tab">技能市场</button>
<button class="tab" data-tab="plugins" role="tab">插件市场</button>
<button class="tab" data-tab="canvas" role="tab">创意画布</button>
<button class="tab" data-tab="router" role="tab">路由中转</button>
<button class="tab" data-tab="proxy" role="tab">全局代理</button>
<button class="tab" data-tab="settings" role="tab">设置</button>
</nav>
</header>
<main class="content">
<!-- ① 技能商店 -->
<section class="panel active" id="tab-market">
<div class="store">
<!-- 左侧分类导航 -->
<aside class="store-nav">
<div class="store-nav-head">
<span class="store-nav-title">浏览分类</span>
<span class="store-nav-total" id="store-total">—</span>
</div>
<div class="store-nav-list" id="store-nav"></div>
</aside>
<!-- 右侧内容 -->
<div class="store-main">
<div class="store-head">
<div class="store-crumb">
<span class="muted">技能商店</span>
<span class="store-crumb-sep">/</span>
<span id="store-cat-title">全部技能</span>
</div>
<div class="store-tools">
<input id="market-search" class="search" type="search" placeholder="搜索技能 / 作者 / 功能…" />
<div class="segmented" id="store-sort">
<button class="seg active" data-sort="hot">热门</button>
<button class="seg" data-sort="downloads">下载量</button>
<button class="seg" data-sort="stars">星标</button>
</div>
</div>
</div>
<p class="muted store-sub" id="store-cat-sub">全部技能 · 通用 SKILL.md 标准,适配 Claude · Codex · OpenClaw</p>
<div id="market-grid" class="grid store-grid"></div>
<div id="store-more" class="store-more hidden"></div>
<div id="market-empty" class="empty hidden"></div>
</div>
</div>
</section>
<!-- ② 插件市场(tokendun 自研插件 + 内置工具) -->
<section class="panel" id="tab-plugins">
<div class="panel-head">
<div>
<h2>插件市场</h2>
<p class="muted">tokendun 官方自研插件与内置工具</p>
</div>
<div class="head-actions">
<input id="plugin-search" class="search" type="search" placeholder="搜索插件 / 作者…" />
<button id="plugin-refresh" class="btn ghost">刷新</button>
</div>
</div>
<!-- 内置工具 -->
<div class="tools-title">内置工具</div>
<div class="tools-grid">
<div class="tool-card">
<div class="tool-top"><span class="tool-ico">🗂️</span><div class="tool-name">会话记录</div></div>
<div class="tool-desc">浏览、搜索、导出本机 Codex 的全部历史对话。支持一键备份 / 导入;切换账号后还能把历史对话「同步认领」回当前账号,让侧栏重新显示。</div>
<div class="tool-foot"><button class="btn primary small" data-open-panel="sessions">打开</button></div>
</div>
<div class="tool-card">
<div class="tool-top"><span class="tool-ico">🧠</span><div class="tool-name">会话记忆</div></div>
<div class="tool-desc">自动把历史会话摘要成「长期记忆」并写入项目 HANDOFF,让 AI 跨会话记住上下文。可按项目开关,检索每次的决策 / 下一步 / 涉及文件。</div>
<div class="tool-foot"><button class="btn primary small" data-open-panel="memory">打开</button></div>
</div>
</div>
<!-- 已安装插件 -->
<div class="tools-title">已安装插件 <span id="plugin-installed-count" class="muted small"></span></div>
<div id="plugin-installed-grid" class="grid"></div>
<div id="plugin-installed-empty" class="empty hidden"></div>
<!-- 自研插件 -->
<div class="tools-title">官方插件</div>
<div id="plugin-grid" class="grid"></div>
<div id="plugin-empty" class="empty hidden"></div>
</section>
<!-- ②.5 创意画布(Drawflow 节点画布) -->
<section class="panel" id="tab-canvas">
<div class="cv-toolbar">
<div class="cv-toolbar-left">
<h2 class="cv-title">🎨 创意画布</h2>
<span class="pc-badge cv-status" id="cv-status">检测中…</span>
</div>
<div class="cv-toolbar-right">
<span class="cv-add-label">添加节点</span>
<button class="btn small" data-cv-add="text">📝 文本</button>
<button class="btn small" data-cv-add="image">🖼️ 文生图</button>
<button class="btn small" data-cv-add="video">🎬 图生视频</button>
<button class="btn small" data-cv-add="grokVideo">🎞️ Grok视频</button>
<button class="btn small" data-cv-add="tts">🔊 TTS配音</button>
<button class="btn small ghost" id="cv-models-btn">🔄 拉取模型</button>
<button class="btn small ghost" id="cv-seed-btn">✨ 载入示例</button>
<button class="btn small primary" id="cv-full-btn">⛶ 全屏</button>
</div>
</div>
<div class="cv-stage">
<div id="drawflow"></div>
<div class="cv-zoom">
<button class="cv-zoom-btn" id="cv-zoom-in" title="放大 (Ctrl+滚轮)">+</button>
<button class="cv-zoom-btn cv-zoom-level" id="cv-zoom-reset" title="点击重置为 100%">100%</button>
<button class="cv-zoom-btn" id="cv-zoom-out" title="缩小 (Ctrl+滚轮)">-</button>
</div>
<div class="cv-mask hidden" id="cv-mask">
<div class="cv-mask-box">
<div class="cv-mask-ico">🔌</div>
<div class="cv-mask-title">中转未就绪</div>
<p class="cv-mask-text" id="cv-mask-text">请先在『路由中转』配置并激活一个中转分组</p>
<button class="btn primary small" id="cv-goto-router">前往路由中转</button>
</div>
</div>
</div>
</section>
<!-- ③ 会话记录 -->
<section class="panel" id="tab-sessions">
<div class="panel-head">
<div>
<h2><button class="btn ghost small back-btn" data-back-plugins>← 插件市场</button> 会话记录</h2>
<p class="muted">直接读取本机 <code>~/.codex/sessions</code>,全量模式会同时读取 <code>archived_sessions</code> 和子任务会话</p>
</div>
<div class="head-actions">
<input id="sess-search" class="search" type="search" placeholder="搜索标题 / 项目…" />
<span id="sess-scope" class="muted small">当前会话</span>
<button id="sess-refresh" class="btn ghost">刷新</button>
<button id="sess-fetch-all" class="btn ghost">获取全部记录</button>
<button id="sess-import" class="btn ghost">导入备份…</button>
<button id="sess-backup" class="btn">备份全部…</button>
</div>
</div>
<div class="sync-banner">
<div class="sync-text">
<strong>🔄 同步会话到当前账号</strong>
<span class="muted small">切换账号后历史对话在 Codex 里不显示?点这里把 sessions 与 archived_sessions 的全部对话"认领"到当前账号。<b>需先完全退出 Codex。</b></span>
<div class="sync-progress hidden" id="sync-progress">
<div class="sync-progress-bar"><div class="sync-progress-fill" id="sync-progress-fill"></div></div>
<span class="sync-progress-label small muted" id="sync-progress-label"></span>
</div>
</div>
<button id="sync-btn" class="btn primary">一键同步</button>
</div>
<div class="sess-layout">
<div class="sess-list" id="sess-list"></div>
<div class="sess-reader" id="sess-reader">
<div class="empty" id="sess-empty">← 左侧选一条对话查看</div>
<div class="sess-conv hidden" id="sess-conv">
<div class="sess-conv-head">
<div class="sess-conv-title" id="sess-conv-title"></div>
<button id="sess-export" class="btn small">导出为 Markdown</button>
</div>
<div class="sess-messages" id="sess-messages"></div>
</div>
</div>
</div>
</section>
<!-- ③.5 会话记忆 -->
<section class="panel" id="tab-memory">
<div class="panel-head">
<div>
<h2><button class="btn ghost small back-btn" data-back-plugins>← 插件市场</button> 会话记忆</h2>
<p class="muted">自动把每次 Codex 会话浓缩成「交接摘要」:存入本地记忆库,并写到项目根目录的 <code>HANDOFF.md</code>。新开会话说「继续」,Codex 会先读它再接着干。</p>
</div>
<div class="head-actions">
<input id="mem-search" class="search" type="search" placeholder="搜索主题 / 摘要 / 决策…" />
<button id="mem-refresh" class="btn ghost">刷新</button>
<button id="mem-toggle" class="btn primary">—</button>
</div>
</div>
<div class="card action-card">
<div class="row between">
<div class="field">
<span>状态</span>
<div><span class="pc-badge" id="mem-state">—</span> <span class="muted small" id="mem-engine"></span></div>
</div>
<span class="muted small" id="mem-count"></span>
</div>
<p class="muted small" style="margin:6px 0 0">
工作方式:会话静默几分钟后自动摘要(优先走「路由中转」当前分组的 LLM,失败退化为本地提取)。
项目根目录会生成 <code>HANDOFF.md</code>(状态快照),并在 <code>AGENTS.md</code> 里加一段「先读 HANDOFF」引导(带标记,可随时删)。
</p>
</div>
<div class="panel-head" style="margin-top:14px">
<div><h3>项目</h3><p class="muted">按项目开关写入(关闭后记忆库照常记录,但不再更新该项目的 HANDOFF.md / AGENTS.md)</p></div>
</div>
<div id="mem-projects" class="checklist"></div>
<div class="panel-head" style="margin-top:14px">
<div><h3>历史摘要</h3><p class="muted">全部存在本机 SQLite(memory.db),可随时检索</p></div>
<select id="mem-project-filter" class="grp-select" style="margin-top:0;max-width:280px"><option value="">全部项目</option></select>
</div>
<div id="mem-list"></div>
<div id="mem-empty" class="empty hidden"></div>
</section>
<!-- ④ 路由中转 -->
<section class="panel" id="tab-router">
<div class="panel-head">
<div>
<h2>路由中转</h2>
<p class="muted">Codex 直连本软件本地路由 → 转发到"当前分组"的中转 API。在此秒切分组,<b>无需重启 Codex</b>。</p>
</div>
</div>
<div class="card action-card">
<div class="row between">
<div class="field">
<span>本地路由地址(Codex 连这里)</span>
<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-claude" class="btn">接入 Claude Code</button>
<button id="router-toggle" class="btn">启动</button>
</div>
</div>
<div class="row">
<label class="field grow">
<span>路由 Key(已自动写入 Codex)</span>
<input id="router-key" type="text" readonly />
</label>
<label class="field">
<span>端口</span>
<input id="router-port" type="number" style="width:100px" />
</label>
<button id="router-save-port" class="btn ghost" style="align-self:flex-end">改端口</button>
</div>
<p class="muted small">点「一键配置到 Codex」后,Codex 的 config.toml 会自动指向本地路由(只需一次)。之后切分组即时生效。</p>
<p class="muted small">「接入 Claude Code」会把 <code>~/.claude/settings.json</code> 的 <code>ANTHROPIC_BASE_URL</code> 指向本地路由(只需一次),须搭配 <b>Anthropic 格式</b>的中转分组。之后同样切分组即时生效。</p>
</div>
<div class="panel-head" style="margin-top:18px">
<div><h3>中转分组</h3><p class="muted">每个分组:名称 + API 地址 + 密钥 +(可选)模型。选中谁就转发到谁。</p></div>
<button id="grp-add" class="btn">+ 新增分组</button>
</div>
<div id="router-groups" class="checklist"></div>
<!-- 分组编辑弹窗 -->
<div id="grp-modal" class="modal-overlay hidden">
<div class="modal">
<div class="modal-head"><h3 id="grp-modal-title">新增分组</h3><button class="modal-close" id="grp-modal-close">✕</button></div>
<div class="modal-body">
<label class="field grow"><span>分组名称</span><input id="grp-name" placeholder="如:TokenDun 高速组" /></label>
<label class="field grow" style="margin-top:10px">
<span>接口类型</span>
<select id="grp-mode" class="grp-select" style="margin-top:0">
<option value="responses">Responses 直连(OpenAI / TokenDun 等 Codex 原生)</option>
<option value="chat">Chat 补全 · 自动转换(国产:DeepSeek / GLM / Kimi / 通义)</option>
</select>
</label>
<label class="field grow" style="margin-top:10px"><span>API 地址(base_url)</span><input id="grp-url" placeholder="https://api.xxx.com" /></label>
<label class="field grow" style="margin-top:10px"><span>API 密钥</span><input id="grp-key" type="password" placeholder="sk-…" /></label>
<label class="field grow" style="margin-top:10px;flex-direction:row;align-items:center;gap:8px;cursor:pointer">
<input id="grp-codex-image" type="checkbox" style="width:auto;margin:0" />
<span style="margin:0">在 Codex 中用于生图(写入 gen-image 技能,需支持 OpenAI 图片接口)</span>
</label>
<div class="field grow" style="margin-top:10px">
<span>默认模型(可选,填了则强制用它)</span>
<div class="row" style="gap:8px; align-items:center">
<input id="grp-model" placeholder="如 gpt-5.5,可留空" style="flex:1" />
<button id="grp-fetch-models" class="btn ghost" type="button">获取模型</button>
</div>
<select id="grp-model-list" class="grp-select hidden"><option value="">— 从下拉选择模型 —</option></select>
<span class="muted small" id="grp-model-hint"></span>
</div>
<div class="field grow" style="margin-top:10px">
<span>上下文窗口 tokens(国产模型建议填,Codex 据此自动压缩)</span>
<div class="row" style="gap:6px; flex-wrap:wrap; align-items:center">
<input id="grp-ctx" type="number" placeholder="如 131072" style="flex:1; min-width:120px" />
<button type="button" class="btn small ghost" data-ctx="65536">64K</button>
<button type="button" class="btn small ghost" data-ctx="131072">128K</button>
<button type="button" class="btn small ghost" data-ctx="204800">200K</button>
</div>
<span class="muted small">DeepSeek≈64K/128K · GLM≈128K · Kimi≈128K/200K · 通义≈128K。留空则用 Codex 默认(可能过大导致超限)</span>
</div>
<div class="row between" style="margin-top:16px">
<span></span>
<button id="grp-save" class="btn primary">保存</button>
</div>
</div>
</div>
</div>
</section>
<!-- ⑤ 全局代理 -->
<section class="panel" id="tab-proxy">
<div class="panel-head">
<div>
<h2>全局代理</h2>
<p class="muted">填入 SOCKS5 代理,一键把<b>整台电脑</b>(全局 TUN)或<b>系统</b>的网络切到代理上。支持要密码的 SOCKS5。</p>
</div>
</div>
<div class="card action-card">
<label class="field grow">
<span>SOCKS5 代理地址</span>
<input id="proxy-str" type="text" placeholder="host:port:user:pass 例如 1.2.3.4:1080:用户名:密码" />
</label>
<div class="row between">
<span class="muted small" id="proxy-parsed">支持格式:<code>host:port:user:pass</code> / <code>host:port</code> / <code>socks5://user:pass@host:port</code></span>
<div class="btn-group">
<button id="proxy-save" class="btn ghost">保存</button>
<button id="proxy-test" class="btn">测试连接</button>
</div>
</div>
<div id="proxy-test-out" class="muted small" style="margin-top:6px"></div>
</div>
<div class="card action-card">
<div class="row between">
<div class="field">
<span>当前状态</span>
<div><span class="pc-badge" id="proxy-state">未开启</span> <span class="muted small" id="proxy-state-detail"></span></div>
</div>
<div class="btn-group">
<button id="proxy-start-global" class="btn primary">启动全局代理(整机)</button>
<button id="proxy-start-system" class="btn">启动系统代理</button>
<button id="proxy-stop" class="btn ghost hidden">关闭代理</button>
</div>
</div>
<div class="proxy-modes">
<div class="pm-item">
<b>🌐 全局代理(整机 TUN)</b>
<span class="muted small">创建虚拟网卡接管<b>所有</b>程序的流量(含命令行、各类 App)。macOS/Windows 启动时会弹一次<b>系统管理员密码</b>——创建网卡必需,和 Clash 一样。</span>
</div>
<div class="pm-item">
<b>⚙️ 系统代理</b>
<span class="muted small">只设操作系统代理,<b>不需要管理员密码</b>。浏览器和多数 App 生效;不认系统代理的程序(部分命令行)不走。</span>
</div>
</div>
</div>
<div class="card action-card">
<p class="muted small" style="margin:0">
<b>说明</b>:本功能内置 sing-box 引擎在本地做中转,因此即使 SOCKS5 需要账号密码也能正常用于系统代理(操作系统本身不发密码,靠本地中转补上)。关闭软件会自动还原网络设置。
</p>
</div>
</section>
<!-- 设置 -->
<section class="panel" id="tab-settings">
<div class="panel-head"><div><h2>设置</h2><p class="muted">密钥用系统安全存储加密后保存在本机</p></div></div>
<div class="card action-card">
<label class="field grow">
<span>默认 GitHub Token</span>
<input id="set-github" type="password" placeholder="ghp_…(安装私有仓库 / 上传时复用)" />
</label>
<div class="row between">
<span class="muted small">保存后,上传 / 拉取页会自动带出</span>
<button id="btn-save-secret" class="btn primary">保存</button>
</div>
</div>
<div class="card action-card">
<label class="field grow">
<span>🔑 管理员密钥(上架插件到市场需要,普通用户留空)</span>
<input id="set-admin" type="password" placeholder="仅管理员填写 · 用于上架市场" />
</label>
<div class="row between">
<span class="muted small">没有此密钥的用户只能备份到自己仓库、安装市场插件,<b>不能上架到市场</b>,防止恶意插件。</span>
<button id="btn-save-admin" class="btn primary">保存</button>
</div>
</div>
<div class="card action-card">
<div class="row between">
<div class="field">
<span>软件版本</span>
<div><b id="app-version">—</b> <span class="muted small" id="update-info"></span></div>
</div>
<button id="btn-check-update" class="btn">检查更新</button>
</div>
</div>
<div class="card action-card">
<div class="row between">
<div class="field">
<span>🧹 强制清除本机 Codex</span>
<p class="muted small" style="margin:0;max-width:520px">
自动识别 Windows / macOS 上 Codex 的安装位置(数据目录、可执行文件、npm 全局包等)。
更新后中转失效时,可在此彻底清干净,再重新安装。<b style="color:var(--danger)">删除不可撤销</b>,
会清掉配置、登录凭证、会话与插件。
</p>
</div>
<button id="btn-purge-scan" class="btn">检测安装位置</button>
</div>
<div id="purge-list" class="checklist" style="margin:0"></div>
<div class="row between" id="purge-actions" style="display:none">
<span class="muted small">将删除上方全部已勾选的位置</span>
<button id="btn-purge-do" class="btn danger">彻底清除 Codex</button>
</div>
</div>
</section>
</main>
<!-- 运行日志抽屉 -->
<div class="logbar">
<button id="log-toggle" class="log-toggle">运行日志 <span id="log-caret">▲</span></button>
<div id="log-panel" class="log-panel hidden"><pre id="log-out"></pre></div>
</div>
<!-- 版权 / 使用说明 常驻页脚 -->
<footer class="footer-notice">
<span class="fn-text">本市场收录的插件版权归各自作者(含 OpenAI)所有,仅供已获授权用户备份 · 还原使用。</span>
<span class="fn-links">
<button class="fn-link" id="open-usage">📖 使用说明</button>
<button class="fn-link" id="open-notice">📄 版权声明</button>
</span>
</footer>
<!-- Toast -->
<div id="toast" class="toast hidden"></div>
<!-- 说明弹窗 -->
<div id="modal-overlay" class="modal-overlay hidden">
<div class="modal" role="dialog" aria-modal="true">
<div class="modal-head">
<h3 id="modal-title"></h3>
<button class="modal-close" id="modal-close" aria-label="关闭">✕</button>
</div>
<div class="modal-body" id="modal-body"></div>
</div>
</div>
<!-- 更新弹窗 -->
<div id="update-modal" class="modal-overlay hidden">
<div class="modal update-modal" role="dialog" aria-modal="true">
<div class="update-head">
<div class="update-ico">🚀</div>
<div>
<h3 id="update-title">发现新版本</h3>
<p class="muted small" id="update-ver"></p>
</div>
<span class="update-badge" id="update-badge"></span>
</div>
<div class="update-notes" id="update-notes"></div>
<div class="update-foot" id="update-foot"></div>
</div>
</div>
<script src="vendor/drawflow.min.js"></script>
<script src="app.js"></script>
</body>
</html>
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+1
View File
@@ -0,0 +1 @@
{"source": "tokendun 官方插件", "repo": "https://up.tokendun.cc/mhgmh/codex-market-app", "plugins": [{"name": "presentations", "displayName": "演示文稿", "source": "openai-primary-runtime", "rel": "plugins/cache/openai-primary-runtime/presentations", "description": "用一句话创建或编辑 PowerPoint / Google Slides 演示文稿,支持新建、改版、渲染导出。", "platform": "", "size": 7833601, "zipName": "presentations.zip", "url": "https://up.tokendun.cc/mhgmh/codex-market-app/releases/download/plugins/presentations.zip"}, {"name": "product-design", "displayName": "产品设计", "source": "openai-curated-remote", "rel": "plugins/cache/openai-curated-remote/product-design", "description": "完整的产品与 UX 设计工作流:用户研究、产品审计、视觉创意,从想法/网址/图片/Figma 生成可运行原型,再到设计 QA 与分享。", "platform": "", "size": 238496, "zipName": "product-design.zip", "url": "https://up.tokendun.cc/mhgmh/codex-market-app/releases/download/plugins/product-design.zip"}, {"name": "hyperframes", "displayName": "HTML 视频", "source": "openai-api-curated", "rel": "plugins/cache/openai-api-curated/hyperframes", "description": "用写 HTML 的方式做视频:合成动画、字幕、配音、音频可视化、转场,一键渲染成 MP4,还能把网站直接变成宣传视频。", "platform": "", "size": 371425, "zipName": "hyperframes.zip", "url": "https://up.tokendun.cc/mhgmh/codex-market-app/releases/download/plugins/hyperframes.zip"}, {"name": "computer-use", "displayName": "电脑操控", "source": "openai-bundled", "rel": "plugins/cache/openai-bundled/computer-use", "description": "让 Codex 直接操控本机 Mac 应用:自动点击、输入、滚动、拖拽、按键,完成需要动手操作 App 界面的任务(附带 HyperFrames 视频插件)。", "platform": "macos", "size": 60690310, "zipName": "computer-use.zip", "url": "https://up.tokendun.cc/mhgmh/codex-market-app/releases/download/plugins/computer-use.zip"}, {"name": "creative-production", "displayName": "创意创作", "source": "openai-curated-remote", "rel": "plugins/cache/openai-curated-remote/creative-production", "description": "从想法到成图的商业视觉创作插件:一站式探索定位、情绪板、场景、广告、Logo、分镜,并把选中的图精修成可发布成品。", "platform": "", "size": 2640342, "zipName": "creative-production.zip", "url": "https://up.tokendun.cc/mhgmh/codex-market-app/releases/download/plugins/creative-production.zip"}]}
+653
View File
@@ -0,0 +1,653 @@
/* Codex 插件市场 —— 深色为主,支持浅色。无外部 CDN,系统字体栈。 */
:root {
--grad: linear-gradient(135deg, #5B6CFF 0%, #A855F7 100%);
--accent: #7c85ff;
--bg: #0f1117;
--bg-2: #161923;
--card: #1b1f2b;
--card-2: #212636;
--border: #2a3042;
--text: #e8eaf2;
--muted: #9aa2b8;
--danger: #ff6b6b;
--ok: #4ade80;
--radius: 14px;
--shadow: 0 8px 30px rgba(0, 0, 0, 0.35);
}
@media (prefers-color-scheme: light) {
:root {
--bg: #f5f6fb;
--bg-2: #eceef6;
--card: #ffffff;
--card-2: #f3f4fa;
--border: #e2e5ef;
--text: #1b1e29;
--muted: #6b7186;
--shadow: 0 8px 24px rgba(60, 60, 120, 0.12);
}
}
* { box-sizing: border-box; }
html, body { height: 100%; margin: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
"Microsoft YaHei", Roboto, Helvetica, Arial, sans-serif;
background: var(--bg);
color: var(--text);
display: flex;
flex-direction: column;
overflow: hidden;
font-size: 14px;
}
code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px;
background: var(--card-2); padding: 1px 6px; border-radius: 6px; }
/* 顶栏 */
.topbar {
display: flex; align-items: center; justify-content: space-between;
padding: 12px 20px; background: var(--bg-2);
border-bottom: 1px solid var(--border);
/* 窗口用系统原生标题栏(main.js 未设 frame:false),不需要自绘拖拽区。
-webkit-app-region: drag 会让浏览器进程在 OS 层拦截该区域的鼠标事件用于「拖窗」,
真实鼠标点击到导航(含标签间隙/品牌区,以及 no-drag 命中不稳的边角)会被吞掉,
而 CDP Input.dispatchMouseEvent 直接注入渲染进程、绕过该拦截,所以静态与 CDP 都看不出。
原生边框已提供拖窗,这里移除以杜绝该类「点不动」。 */
}
.brand { display: flex; align-items: center; gap: 12px; }
.logo { width: 40px; height: 40px; display: grid; place-items: center;
border-radius: 11px; box-shadow: var(--shadow); }
.brand-name { font-weight: 700; font-size: 15px; letter-spacing: .2px; }
.brand-sub { color: var(--muted); font-size: 12px; margin-top: 1px; }
/* —— 顶部广告:官方推荐中转 —— */
.promo {
-webkit-app-region: no-drag;
display: flex; align-items: center; gap: 8px;
padding: 6px 12px; border-radius: 10px; text-decoration: none;
color: var(--text); font-size: 12px; white-space: nowrap;
background: linear-gradient(90deg, rgba(91,108,255,.14), rgba(168,85,247,.14));
border: 1px solid rgba(124,108,255,.35);
transition: .15s;
}
.promo:hover { border-color: rgba(124,108,255,.7);
background: linear-gradient(90deg, rgba(91,108,255,.22), rgba(168,85,247,.22)); }
.promo-badge { font-size: 11px; font-weight: 700; color: #fff;
background: var(--grad); padding: 2px 7px; border-radius: 6px; letter-spacing: .2px; }
.promo-text { color: var(--muted); }
.promo-text b { color: var(--text); font-weight: 600; }
.promo-arrow { color: var(--muted); font-weight: 700; }
.tabs { display: flex; gap: 4px; background: var(--card); padding: 4px;
border-radius: 12px; border: 1px solid var(--border); }
.tab { border: 0; background: transparent; color: var(--muted);
padding: 7px 16px; border-radius: 9px; cursor: pointer; font-size: 13px;
font-weight: 600; transition: .15s; }
.tab:hover { color: var(--text); }
.tab.active { color: #fff; background: var(--grad); box-shadow: var(--shadow); }
/* 内容区 */
.content { flex: 1; overflow-y: auto; padding: 22px; }
.panel { display: none; animation: fade .2s ease; }
.panel.active { display: block; }
@keyframes fade { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; } }
.panel-head { display: flex; align-items: flex-end; justify-content: space-between;
gap: 16px; margin-bottom: 16px; flex-wrap: wrap; }
h2 { margin: 0; font-size: 20px; }
h3 { margin: 0; font-size: 16px; }
.muted { color: var(--muted); margin: 4px 0 0; }
.small { font-size: 12px; }
.head-actions { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.search { background: var(--card); border: 1px solid var(--border); color: var(--text);
border-radius: 10px; padding: 9px 12px; width: 220px; outline: none; }
.search:focus { border-color: var(--accent); }
.segmented { display: flex; background: var(--card); border: 1px solid var(--border);
border-radius: 10px; padding: 3px; }
.seg { border: 0; background: transparent; color: var(--muted); padding: 6px 12px;
border-radius: 7px; cursor: pointer; font-size: 12px; font-weight: 600; }
.seg.active { background: var(--card-2); color: var(--text); }
/* 按钮 */
.btn { border: 1px solid var(--border); background: var(--card); color: var(--text);
padding: 9px 16px; border-radius: 10px; cursor: pointer; font-size: 13px;
font-weight: 600; transition: .15s; }
.btn:hover { border-color: var(--accent); }
.btn.primary { background: var(--grad); border: 0; color: #fff; box-shadow: var(--shadow); }
.btn.primary:hover { filter: brightness(1.07); }
.btn.ghost { background: transparent; }
.btn:disabled { opacity: .5; cursor: not-allowed; }
.btn.small { padding: 6px 12px; font-size: 12px; }
.btn.danger { border-color: var(--danger); color: var(--danger); }
.btn.danger:hover { background: var(--danger); color: #fff; border-color: var(--danger); }
.btn-group { display: flex; gap: 10px; }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; opacity: .7; word-break: break-all; }
/* 市场网格 */
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 14px; }
.plugin-card { background: var(--card); border: 1px solid var(--border);
border-radius: var(--radius); padding: 16px; display: flex; flex-direction: column;
gap: 10px; transition: .15s; }
.plugin-card:hover { transform: translateY(-2px); box-shadow: var(--shadow);
border-color: var(--accent); }
.pc-top { display: flex; align-items: center; gap: 10px; }
.pc-badge { font-size: 11px; font-weight: 700; padding: 3px 9px; border-radius: 999px; }
.pc-badge.plugin { background: rgba(124,133,255,.16); color: var(--accent); }
.pc-badge.skill { background: rgba(74,222,128,.15); color: var(--ok); }
.pc-badge.plat { background: rgba(245,158,11,.16); color: #f59e0b; }
.pc-badge.on { background: rgba(74,222,128,.18); color: var(--ok); }
/* 全局代理:两种模式说明块 */
.proxy-modes { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-top: 14px; }
.pm-item { display: flex; flex-direction: column; gap: 5px; padding: 12px 14px;
border: 1px solid var(--border); border-radius: 10px; background: rgba(255,255,255,.02); }
.pm-item b { font-size: 14px; }
@media (max-width: 760px) { .proxy-modes { grid-template-columns: 1fr; } }
.pc-name { font-weight: 700; font-size: 15px; word-break: break-all; }
.pc-meta { color: var(--muted); font-size: 12px; display: flex; gap: 10px;
flex-wrap: wrap; }
.pc-desc { color: var(--muted); font-size: 13px; line-height: 1.5;
min-height: 20px; flex: 1; }
.pc-foot { display: flex; align-items: center; justify-content: space-between;
gap: 10px; margin-top: 2px; }
.link { color: var(--accent); cursor: pointer; font-size: 12px; text-decoration: none; }
.link:hover { text-decoration: underline; }
/* 勾选列表(备份/还原) */
.checklist { display: flex; flex-direction: column; gap: 8px; margin-bottom: 16px; }
.cl-group { font-size: 12px; font-weight: 700; color: var(--muted);
text-transform: uppercase; letter-spacing: .5px; margin: 10px 2px 2px; }
.cl-item { display: flex; align-items: center; gap: 12px; background: var(--card);
border: 1px solid var(--border); border-radius: 11px; padding: 11px 14px;
cursor: pointer; transition: .12s; }
.cl-item:hover { border-color: var(--accent); }
.cl-item input { width: 17px; height: 17px; accent-color: var(--accent); }
.cl-item .cl-name { font-weight: 600; }
.cl-item .cl-src { color: var(--muted); font-size: 12px; }
.cl-item .cl-size { margin-left: auto; color: var(--muted); font-size: 12px; }
/* 子标签 */
.subtabs { display: flex; gap: 6px; margin-bottom: 18px; }
.subtab { border: 1px solid var(--border); background: var(--card); color: var(--muted);
padding: 8px 16px; border-radius: 10px; cursor: pointer; font-weight: 600; font-size: 13px; }
.subtab.active { color: var(--text); border-color: var(--accent); background: var(--card-2); }
.subpanel { display: none; }
.subpanel.active { display: block; }
/* 表单卡片 */
.card { background: var(--card); border: 1px solid var(--border);
border-radius: var(--radius); padding: 16px; }
.action-card { margin-top: 8px; display: flex; flex-direction: column; gap: 14px; }
.row { display: flex; gap: 14px; align-items: flex-end; flex-wrap: wrap; }
.row.between { justify-content: space-between; align-items: center; }
.field { display: flex; flex-direction: column; gap: 6px; }
.field.grow { flex: 1; min-width: 200px; }
.field span { font-size: 12px; color: var(--muted); font-weight: 600; }
.field input { background: var(--bg-2); border: 1px solid var(--border); color: var(--text);
border-radius: 10px; padding: 10px 12px; outline: none; }
.field input:focus { border-color: var(--accent); }
.check-inline { display: flex; align-items: center; gap: 8px; color: var(--muted);
font-size: 13px; cursor: pointer; }
.check-inline input { accent-color: var(--accent); width: 16px; height: 16px; }
/* 空 / 加载态 */
.empty { text-align: center; color: var(--muted); padding: 50px 20px;
border: 1px dashed var(--border); border-radius: var(--radius); }
.spinner { width: 26px; height: 26px; border: 3px solid var(--border);
border-top-color: var(--accent); border-radius: 50%; animation: spin .8s linear infinite;
margin: 0 auto 12px; }
@keyframes spin { to { transform: rotate(360deg); } }
.hidden { display: none !important; }
/* 日志抽屉 */
.logbar { border-top: 1px solid var(--border); background: var(--bg-2); }
.log-toggle { width: 100%; text-align: left; border: 0; background: transparent;
color: var(--muted); padding: 8px 20px; cursor: pointer; font-size: 12px;
font-weight: 600; display: flex; justify-content: space-between; }
.log-panel { max-height: 190px; overflow-y: auto; padding: 0 20px 12px; }
.log-panel pre { margin: 0; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px; line-height: 1.6; color: var(--muted); white-space: pre-wrap;
word-break: break-word; }
/* Toast */
.toast { position: fixed; bottom: 60px; left: 50%; transform: translateX(-50%);
background: var(--card); border: 1px solid var(--border); color: var(--text);
padding: 12px 20px; border-radius: 12px; box-shadow: var(--shadow); z-index: 50;
max-width: 80%; font-size: 13px; }
.toast.ok { border-color: var(--ok); }
.toast.err { border-color: var(--danger); }
/* 卡片内按钮忙碌态 */
.installing { pointer-events: none; opacity: .7; }
/* 会话记录:同步横幅 */
.sync-banner { display: flex; align-items: center; justify-content: space-between;
gap: 14px; background: linear-gradient(135deg, rgba(124,133,255,.12), rgba(168,85,247,.10));
border: 1px solid var(--border); border-radius: var(--radius); padding: 12px 16px;
margin-bottom: 12px; }
.sync-banner .sync-text { display: flex; flex-direction: column; gap: 3px; }
.sync-banner .sync-text strong { font-size: 14px; }
.sync-banner .sync-text b { color: var(--danger); }
.sync-progress { display: flex; align-items: center; gap: 10px; margin-top: 6px; }
.sync-progress-bar { flex: 1; max-width: 320px; height: 7px; background: var(--card-2);
border-radius: 999px; overflow: hidden; border: 1px solid var(--border); }
.sync-progress-fill { height: 100%; width: 0%; background: var(--grad);
border-radius: 999px; transition: width .2s ease; }
.sync-progress-label { white-space: nowrap; }
/* 会话记录:左右两栏 */
.sess-layout { display: grid; grid-template-columns: 300px 1fr; gap: 14px;
height: calc(100vh - 240px); min-height: 320px; }
.sess-list { overflow-y: auto; display: flex; flex-direction: column; gap: 6px;
padding-right: 4px; }
.sess-item { background: var(--card); border: 1px solid var(--border);
border-radius: 11px; padding: 10px 12px; cursor: pointer; transition: .12s; }
.sess-item:hover { border-color: var(--accent); }
.sess-item.active { border-color: var(--accent); background: var(--card-2); }
.sess-item .si-title { font-weight: 600; font-size: 13px; margin-bottom: 3px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.sess-item .si-meta { color: var(--muted); font-size: 11px; display: flex;
gap: 8px; flex-wrap: wrap; }
.si-badge { border: 1px solid var(--border); border-radius: 999px; padding: 1px 6px;
color: var(--text); background: var(--card-2); font-weight: 700; }
.sess-reader { background: var(--card); border: 1px solid var(--border);
border-radius: var(--radius); overflow: hidden; display: flex; flex-direction: column; }
.sess-conv { display: flex; flex-direction: column; height: 100%; }
.sess-conv-head { display: flex; align-items: center; justify-content: space-between;
gap: 10px; padding: 12px 16px; border-bottom: 1px solid var(--border); }
.sess-conv-title { font-weight: 700; font-size: 14px; white-space: nowrap;
overflow: hidden; text-overflow: ellipsis; }
.sess-messages { overflow-y: auto; padding: 16px; display: flex;
flex-direction: column; gap: 14px; }
.msg { display: flex; flex-direction: column; gap: 4px; }
.msg .who { font-size: 11px; font-weight: 700; color: var(--muted); }
.msg .bubble { border-radius: 12px; padding: 10px 13px; font-size: 13px;
line-height: 1.6; white-space: pre-wrap; word-break: break-word; }
.msg.user { align-items: flex-end; }
.msg.user .bubble { background: var(--grad); color: #fff; max-width: 85%; }
.msg.assistant .bubble { background: var(--card-2); color: var(--text);
border: 1px solid var(--border); max-width: 92%; }
.msg.tool .bubble { background: transparent; color: var(--muted); font-size: 12px;
border: 1px dashed var(--border); padding: 5px 10px; }
/* 常驻页脚:版权 / 使用说明 */
.footer-notice { display: flex; align-items: center; justify-content: space-between;
gap: 12px; padding: 7px 20px; background: var(--bg-2);
border-top: 1px solid var(--border); font-size: 11px; color: var(--muted); }
.footer-notice .fn-text { flex: 1; min-width: 0; overflow: hidden;
text-overflow: ellipsis; white-space: nowrap; }
.footer-notice .fn-links { display: flex; gap: 14px; flex-shrink: 0; }
.fn-link { border: 0; background: transparent; color: var(--accent); cursor: pointer;
font-size: 11px; font-weight: 600; padding: 0; }
.fn-link:hover { text-decoration: underline; }
/* 说明弹窗 */
.modal-overlay { position: fixed; inset: 0; background: rgba(0, 0, 0, .5);
display: flex; align-items: center; justify-content: center; z-index: 100;
padding: 24px; animation: fade .15s ease; }
.modal { background: var(--card); border: 1px solid var(--border);
border-radius: var(--radius); box-shadow: var(--shadow); max-width: 640px;
width: 100%; max-height: 82vh; display: flex; flex-direction: column; }
.modal-head { display: flex; align-items: center; justify-content: space-between;
padding: 15px 20px; border-bottom: 1px solid var(--border); }
.modal-head h3 { font-size: 16px; }
.modal-close { border: 0; background: transparent; color: var(--muted); font-size: 17px;
cursor: pointer; line-height: 1; padding: 3px 7px; border-radius: 8px; }
.modal-close:hover { color: var(--text); background: var(--card-2); }
.modal-body { padding: 18px 22px; overflow-y: auto; line-height: 1.7;
font-size: 13px; color: var(--text); }
.modal-body h4 { margin: 18px 0 6px; font-size: 14px; }
.modal-body h4:first-child { margin-top: 0; }
.modal-body p { margin: 6px 0; color: var(--muted); }
.modal-body ul { margin: 6px 0; padding-left: 20px; color: var(--muted); }
.modal-body li { margin: 4px 0; }
.modal-body strong { color: var(--text); }
.modal-body .step { display: flex; gap: 10px; margin: 8px 0; align-items: baseline; }
.modal-body .step .n { flex-shrink: 0; width: 20px; height: 20px; border-radius: 999px;
background: var(--grad); color: #fff; font-size: 11px; font-weight: 700;
display: grid; place-items: center; }
.cv-media-modal { max-width: min(1120px, 94vw); }
.cv-media-modal .modal-body { padding: 10px; overflow: hidden; }
.cv-modal-media { display: block; width: 100%; max-height: 76vh; object-fit: contain;
background: #000; border-radius: 8px; }
.grp-select { margin-top: 8px; width: 100%; background: var(--bg-2);
border: 1px solid var(--border); color: var(--text); border-radius: 10px;
padding: 9px 12px; outline: none; font-size: 13px; }
.grp-select:focus { border-color: var(--accent); }
/* —— 会话记忆 —— */
.mem-item { background: var(--card); border: 1px solid var(--border);
border-radius: var(--radius); padding: 13px 16px; margin-bottom: 10px; }
.mem-item:hover { border-color: var(--accent); }
.mem-top { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.mem-topic { font-weight: 600; }
.mem-actions { margin-left: auto; display: flex; gap: 6px; }
.mem-sum { margin-top: 8px; white-space: pre-wrap; font-size: 13px; color: var(--text); opacity: .88; }
.mem-line { margin-top: 6px; font-size: 12px; color: var(--muted); }
.mem-line b { color: var(--text); margin-right: 8px; font-weight: 600; }
/* ===========================================================================
技能商店(cocoloop 镜像)—— 侧栏分类 + 精致卡片
=========================================================================== */
.store { display: grid; grid-template-columns: 236px 1fr; gap: 18px;
height: calc(100vh - 150px); min-height: 420px; }
/* 左侧分类导航 */
.store-nav { display: flex; flex-direction: column; background: var(--bg-2);
border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
.store-nav-head { display: flex; align-items: baseline; justify-content: space-between;
padding: 13px 15px 9px; border-bottom: 1px solid var(--border); }
.store-nav-title { font-size: 12px; font-weight: 700; color: var(--muted);
letter-spacing: .5px; }
.store-nav-total { font-size: 11px; color: var(--accent); font-weight: 700; }
.store-nav-list { flex: 1; overflow-y: auto; padding: 8px; display: flex;
flex-direction: column; gap: 2px; }
.store-nav-list::-webkit-scrollbar { width: 8px; }
.store-nav-list::-webkit-scrollbar-thumb { background: var(--border);
border-radius: 8px; }
.store-nav-group { font-size: 10px; font-weight: 700; color: var(--muted);
text-transform: uppercase; letter-spacing: .6px; padding: 10px 10px 4px; opacity: .7; }
.nav-item { display: flex; align-items: center; gap: 9px; padding: 8px 10px;
border-radius: 9px; cursor: pointer; border: 1px solid transparent;
transition: .12s; color: var(--text); }
.nav-item:hover { background: var(--card); }
.nav-item.active { background: var(--grad); color: #fff; box-shadow: var(--shadow); }
.nav-item.active .nav-count { background: rgba(255,255,255,.22); color: #fff; }
.nav-ico { width: 18px; text-align: center; font-size: 14px; flex-shrink: 0; }
.nav-name { flex: 1; font-size: 13px; font-weight: 600; white-space: nowrap;
overflow: hidden; text-overflow: ellipsis; }
.nav-count { font-size: 11px; font-weight: 700; color: var(--muted);
background: var(--card-2); padding: 1px 8px; border-radius: 999px; flex-shrink: 0; }
/* 右侧主区 */
.store-main { display: flex; flex-direction: column; min-width: 0; }
.store-head { display: flex; align-items: center; justify-content: space-between;
gap: 16px; flex-wrap: wrap; }
.store-crumb { display: flex; align-items: baseline; gap: 8px; font-size: 20px;
font-weight: 700; }
.store-crumb .muted { font-size: 13px; font-weight: 600; }
.store-crumb-sep { color: var(--muted); font-weight: 400; }
.store-tools { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.store-sub { margin: 6px 0 14px; font-size: 12.5px; }
.store-grid { overflow-y: auto; align-content: start; padding: 2px 4px 4px 2px;
flex: 1; grid-template-columns: repeat(auto-fill, minmax(310px, 1fr)); }
.store-grid::-webkit-scrollbar { width: 9px; }
.store-grid::-webkit-scrollbar-thumb { background: var(--border); border-radius: 9px; }
/* 技能卡片 */
.skill-card { background: var(--card); border: 1px solid var(--border);
border-radius: var(--radius); padding: 15px; display: flex; flex-direction: column;
gap: 11px; transition: .15s; position: relative; overflow: hidden; }
.skill-card::before { content: ""; position: absolute; inset: 0 0 auto 0; height: 3px;
background: var(--grad); opacity: 0; transition: .15s; }
.skill-card:hover { transform: translateY(-3px); box-shadow: var(--shadow);
border-color: var(--accent); }
.skill-card:hover::before { opacity: 1; }
.sk-top { display: flex; align-items: flex-start; gap: 11px; }
.sk-ico { width: 42px; height: 42px; flex-shrink: 0; display: grid; place-items: center;
font-size: 22px; background: var(--card-2); border: 1px solid var(--border);
border-radius: 11px; }
.sk-headings { min-width: 0; flex: 1; }
.sk-name { font-weight: 700; font-size: 15px; line-height: 1.25; word-break: break-word; }
.sk-author { color: var(--muted); font-size: 12px; margin-top: 2px; }
.sk-sec { flex-shrink: 0; width: 24px; height: 24px; display: grid; place-items: center;
border-radius: 7px; font-size: 11px; font-weight: 800; }
.sk-sec.S { background: linear-gradient(135deg,#f59e0b,#f97316); color: #fff; }
.sk-sec.A { background: rgba(74,222,128,.16); color: var(--ok); }
.sk-sec.B { background: var(--card-2); color: var(--muted); }
.sk-subtitle { font-weight: 600; font-size: 13.5px; line-height: 1.4; color: var(--text); }
.sk-brief { color: var(--muted); font-size: 12.5px; line-height: 1.55; flex: 1;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
overflow: hidden; }
.sk-agents { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.sk-agents .lab { font-size: 11px; color: var(--muted); margin-right: 2px; }
.agent-badge { font-size: 11px; font-weight: 700; padding: 2px 9px; border-radius: 999px;
display: inline-flex; align-items: center; gap: 5px; background: var(--card-2);
border: 1px solid var(--border); }
.agent-badge::before { content: ""; width: 6px; height: 6px; border-radius: 50%; }
.agent-badge.claude::before { background: #d97757; }
.agent-badge.codex::before { background: #5b6cff; }
.agent-badge.openclaw::before { background: #a855f7; }
.agent-badge.mm { background: rgba(245,158,11,.14); border-color: transparent; color: #f59e0b; }
.agent-badge.mm::before { display: none; }
.sk-meta { display: flex; align-items: center; gap: 13px; padding-top: 9px;
border-top: 1px solid var(--border); color: var(--muted); font-size: 12px;
flex-wrap: wrap; }
.sk-meta .cred { margin-left: auto; font-size: 10px; font-weight: 700; padding: 1px 7px;
border: 1px solid var(--border); border-radius: 6px; letter-spacing: .3px; }
.sk-foot { display: flex; align-items: center; gap: 9px; }
.sk-foot .btn.primary { flex: 1; }
/* 加载更多 */
.store-more { display: flex; align-items: center; justify-content: center; gap: 12px;
padding: 14px; color: var(--muted); font-size: 13px; }
/* 技能详情弹窗内的排版 */
.sd-hero { display: flex; align-items: flex-start; gap: 14px; margin-bottom: 4px; }
.sd-hero .sk-ico { width: 52px; height: 52px; font-size: 27px; }
.sd-hero h3 { font-size: 18px; }
.sd-stats { display: flex; gap: 16px; flex-wrap: wrap; margin: 12px 0; color: var(--muted);
font-size: 13px; }
.sd-tags { display: flex; gap: 6px; flex-wrap: wrap; margin: 8px 0; }
.sd-tag { font-size: 11px; font-weight: 600; padding: 3px 10px; border-radius: 999px;
background: var(--card-2); color: var(--muted); border: 1px solid var(--border); }
.sd-install-row { display: flex; gap: 10px; margin-top: 8px; }
@media (max-width: 820px) {
.store { grid-template-columns: 1fr; height: auto; }
.store-nav { max-height: 200px; }
}
/* 品牌 logo 图片 */
.logo img { width: 100%; height: 100%; border-radius: 10px; display: block; object-fit: cover; }
/* ===========================================================================
更新弹窗
=========================================================================== */
.update-modal { max-width: 480px; padding: 0; }
.update-head { display: flex; align-items: center; gap: 13px; padding: 20px 22px 14px; }
.update-ico { width: 44px; height: 44px; flex-shrink: 0; display: grid; place-items: center;
font-size: 24px; border-radius: 12px; background: var(--grad); }
.update-head h3 { font-size: 17px; margin: 0; }
.update-badge { margin-left: auto; font-size: 11px; font-weight: 700; padding: 3px 10px;
border-radius: 999px; }
.update-badge.force { background: rgba(255,107,107,.16); color: var(--danger); }
.update-badge.normal { background: rgba(74,222,128,.15); color: var(--ok); }
.update-notes { margin: 0 22px; padding: 14px 16px; background: var(--bg-2);
border: 1px solid var(--border); border-radius: 12px; max-height: 300px; overflow-y: auto;
font-size: 13px; line-height: 1.65; color: var(--text); white-space: pre-wrap;
word-break: break-word; }
.update-notes:empty::before { content: "本次更新暂无详细说明。"; color: var(--muted); }
.update-foot { display: flex; gap: 10px; justify-content: flex-end; padding: 16px 22px 20px; }
.update-foot .btn { min-width: 96px; }
/* ===========================================================================
插件市场 —— 内置工具卡片 + 分区标题 + 返回按钮
=========================================================================== */
.tools-title { font-size: 13px; font-weight: 700; color: var(--muted);
letter-spacing: .5px; margin: 6px 2px 12px; }
.tools-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 14px; margin-bottom: 26px; }
.tool-card { background: var(--card); border: 1px solid var(--border);
border-radius: var(--radius); padding: 16px; display: flex; flex-direction: column;
gap: 10px; transition: .15s; }
.tool-card:hover { transform: translateY(-2px); box-shadow: var(--shadow); border-color: var(--accent); }
.tool-top { display: flex; align-items: center; gap: 11px; }
.tool-ico { width: 40px; height: 40px; flex-shrink: 0; display: grid; place-items: center;
font-size: 21px; background: var(--card-2); border: 1px solid var(--border); border-radius: 11px; }
.tool-name { font-weight: 700; font-size: 15px; }
.tool-desc { color: var(--muted); font-size: 13px; line-height: 1.6; flex: 1; }
.tool-foot { display: flex; justify-content: flex-end; }
.back-btn { margin-right: 8px; vertical-align: middle; padding: 4px 10px; }
/* ============================================================
创意画布(Drawflow 节点画布)
复用 :root 设计变量,覆写 Drawflow 默认外观,明/暗色协调。
============================================================ */
/* 画布面板占满内容区高度:工具条固定 + 画布撑满剩余空间 */
/* 画布面板占满内容区高度:抵消 .content 的 22px padding,铺满更大气 */
#tab-canvas.active { display: flex; flex-direction: column; min-height: 0;
margin: -22px; height: calc(100% + 44px); padding: 22px; }
.cv-toolbar { display: flex; align-items: center; justify-content: space-between;
gap: 12px; flex-wrap: wrap; margin-bottom: 12px; }
.cv-toolbar-left { display: flex; align-items: center; gap: 12px; }
.cv-title { font-size: 18px; }
.cv-status { border: 1px solid var(--border); background: var(--card-2); color: var(--muted); }
.cv-status.on { background: rgba(74,222,128,.18); color: var(--ok); border-color: transparent; }
.cv-status.off { background: rgba(255,107,107,.16); color: var(--danger); border-color: transparent; }
.cv-toolbar-right { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.cv-add-label { color: var(--muted); font-size: 12px; font-weight: 600; margin-right: 2px; }
/* 画布舞台:明确高度(Drawflow 容器需要),点阵底纹 */
.cv-stage { position: relative; flex: 1; min-height: 560px;
border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden;
box-shadow: var(--shadow); }
#drawflow { position: absolute; inset: 0; width: 100%; height: 100%;
background-color: var(--bg-2);
background-image: radial-gradient(var(--border) 1px, transparent 1px);
background-size: 22px 22px; }
/* 悬浮缩放控件(右下角,覆盖在画布上) */
.cv-zoom { position: absolute; right: 14px; bottom: 14px; z-index: 15;
display: flex; flex-direction: column; gap: 1px; border-radius: 10px; overflow: hidden;
border: 1px solid var(--border); box-shadow: var(--shadow); }
.cv-zoom-btn { border: 0; background: var(--card); color: var(--text);
width: 38px; height: 34px; cursor: pointer; font-size: 17px; font-weight: 700;
line-height: 1; display: flex; align-items: center; justify-content: center;
transition: .12s; user-select: none; }
.cv-zoom-btn:hover { background: var(--accent); color: #fff; }
.cv-zoom-btn:active { transform: scale(.94); }
.cv-zoom-level { font-size: 11px; font-weight: 600; color: var(--muted);
border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); }
/* 未就绪遮罩 */
.cv-mask { position: absolute; inset: 0; z-index: 20; display: grid; place-items: center;
background: color-mix(in srgb, var(--bg) 78%, transparent);
backdrop-filter: blur(2px); }
.cv-mask-box { text-align: center; background: var(--card); border: 1px solid var(--border);
border-radius: var(--radius); padding: 28px 32px; box-shadow: var(--shadow); max-width: 380px; }
.cv-mask-ico { font-size: 34px; }
.cv-mask-title { font-weight: 700; font-size: 16px; margin: 8px 0 4px; }
.cv-mask-text { color: var(--muted); font-size: 13px; margin: 0 0 14px; line-height: 1.6; }
/* —— 覆写 Drawflow 节点为卡片风 —— */
.drawflow .drawflow-node { background: transparent; border: 0; padding: 0;
border-radius: 16px; box-shadow: none; width: 250px; }
.drawflow .drawflow-node.selected { box-shadow: none; }
.drawflow .drawflow-node .drawflow_content_node { width: 100%; }
.cv-node { background: var(--card); border: 1px solid var(--border);
border-radius: 16px; overflow: hidden; box-shadow: var(--shadow);
transition: border-color .15s; }
.drawflow-node:hover .cv-node { border-color: var(--accent); }
.drawflow-node.selected .cv-node { border-color: transparent;
box-shadow: 0 0 0 2px var(--accent), var(--shadow); }
.cv-node-head { display: flex; align-items: center; gap: 8px;
padding: 9px 12px; background: var(--card-2); border-bottom: 1px solid var(--border); }
.cv-node-ico { font-size: 15px; }
.cv-node-name { font-weight: 700; font-size: 13px; color: var(--text); }
.cv-node-tag { margin-left: auto; font-size: 10px; font-weight: 700; color: #f59e0b;
background: rgba(245,158,11,.16); padding: 2px 7px; border-radius: 999px; }
.cv-node-body { display: flex; flex-direction: column; gap: 8px; padding: 11px 12px; }
.cv-node-hint { color: var(--muted); font-size: 11px; line-height: 1.5; }
/* 节点内表单控件 */
.cv-field { width: 100%; background: var(--bg-2); border: 1px solid var(--border);
color: var(--text); border-radius: 9px; padding: 8px 10px; font-size: 12px;
font-family: inherit; outline: none; resize: vertical; }
.cv-field:focus { border-color: var(--accent); }
select.cv-field { resize: none; cursor: pointer; }
/* 并排两个下拉(比例 + 清晰度 / 音色 + 格式 等) */
.cv-row2 { display: flex; gap: 8px; }
.cv-row2 .cv-field { flex: 1; min-width: 0; }
/* 模型输入框 + 每节点「拉取」小按钮 */
.cv-model-row { display: flex; gap: 6px; align-items: stretch; }
.cv-model-row .cv-field { flex: 1; min-width: 0; }
.cv-fetch-models { flex: none; border: 1px solid var(--border); background: var(--card-2);
color: var(--muted); border-radius: 9px; padding: 0 10px; cursor: pointer;
font-size: 11px; font-weight: 700; transition: .15s; }
.cv-fetch-models:hover { border-color: var(--accent); color: var(--accent); }
.cv-fetch-models:disabled { opacity: .5; cursor: not-allowed; }
/* 素材上传控件(图片节点参考图 / 视频节点首帧·尾帧·多参考图)——走自建图床 */
.cv-refimg-row, .cv-vmat { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
.cv-refimg-pick { display: inline-flex; align-items: center; gap: 5px; cursor: pointer;
border: 1px dashed var(--accent); color: var(--accent); background: var(--card-2);
border-radius: 9px; padding: 7px 12px; font-size: 11px; font-weight: 700;
transition: .15s; white-space: nowrap; user-select: none; }
.cv-refimg-pick:hover { background: var(--accent); color: #fff; }
.cv-refimg-thumb, .cv-vmat-thumb { border: 1px solid var(--border); }
.cv-refimg-clear, .cv-vmat-clear { flex: none; border: 1px solid var(--border);
background: var(--card-2); color: var(--muted); border-radius: 7px; cursor: pointer;
width: 24px; height: 24px; font-size: 12px; line-height: 1; padding: 0; transition: .15s; }
.cv-refimg-clear:hover, .cv-vmat-clear:hover { border-color: var(--danger); color: var(--danger); }
/* URL 输入 / 提示 / 多图缩略图 各自独占一行 */
.cv-vmat-url { flex: 1 1 100%; }
.cv-refimg-row .cv-node-hint, .cv-vmat .cv-node-hint { flex: 1 1 100%; }
.cv-vmat-refs-thumbs { display: flex; flex-wrap: wrap; gap: 2px; flex: 1 1 100%; }
/* 复选标签(生成音频 / 水印 / 固定镜头)对齐 */
.cv-check { display: inline-flex; align-items: center; gap: 5px; font-size: 11px;
color: var(--muted); cursor: pointer; user-select: none; }
/* 折叠「高级」区(接口地址覆盖),别把节点撑太高 */
.cv-adv { border-top: 1px dashed var(--border); padding-top: 6px; }
.cv-adv > summary { cursor: pointer; color: var(--muted); font-size: 11px;
font-weight: 600; list-style: none; user-select: none; }
.cv-adv > summary::-webkit-details-marker { display: none; }
.cv-adv > summary::before { content: "▸ "; }
.cv-adv[open] > summary::before { content: "▾ "; }
.cv-adv[open] > summary { margin-bottom: 6px; }
/* 运行按钮(沿用主色渐变观感) */
.cv-run { align-self: stretch; border: 0; background: var(--grad); color: #fff;
padding: 8px 14px; border-radius: 9px; cursor: pointer; font-size: 12px;
font-weight: 700; transition: .15s; }
.cv-run:hover { filter: brightness(1.08); }
.cv-run:disabled { opacity: .6; cursor: not-allowed; }
/* 错误红字 */
.cv-node-err { color: var(--danger); font-size: 11px; line-height: 1.5;
background: rgba(255,107,107,.1); border-radius: 8px; padding: 6px 9px; }
/* 预览区(img/video/audio)圆角、限高、加载骨架 */
.cv-node-preview { border-radius: 10px; overflow: hidden; background: var(--bg-2);
border: 1px solid var(--border); }
.cv-preview-shell { position: relative; background: #000; }
.cv-preview-actions { position: absolute; top: 6px; right: 6px; display: flex; gap: 5px;
z-index: 2; opacity: .92; }
.cv-preview-actions button { width: 26px; height: 26px; display: grid; place-items: center;
border: 1px solid rgba(255,255,255,.22); border-radius: 7px; background: rgba(15,17,23,.78);
color: #fff; cursor: pointer; font-size: 13px; line-height: 1; padding: 0; }
.cv-preview-actions button:hover { background: rgba(90,103,216,.92); border-color: rgba(255,255,255,.4); }
.cv-media { display: block; width: 100%; max-height: 220px; object-fit: contain;
background: #000; }
.cv-media-audio { display: block; width: 100%; }
.cv-meta { font-size: 10px; color: var(--muted); padding: 5px 8px;
border-top: 1px solid var(--border); }
.cv-skeleton { display: flex; align-items: center; justify-content: center; gap: 8px;
height: 120px; color: var(--muted); font-size: 12px;
background: linear-gradient(100deg, var(--bg-2) 30%, var(--card-2) 50%, var(--bg-2) 70%);
background-size: 200% 100%; animation: cv-shimmer 1.2s linear infinite; }
@keyframes cv-shimmer { from { background-position: 200% 0; } to { background-position: -200% 0; } }
.cv-spin { width: 13px; height: 13px; border-radius: 50%;
border: 2px solid var(--border); border-top-color: var(--accent);
animation: cv-rot .7s linear infinite; }
@keyframes cv-rot { to { transform: rotate(360deg); } }
/* —— 端口与连线用主题色 —— */
.drawflow .drawflow-node .input,
.drawflow .drawflow-node .output { background: var(--card);
border: 2px solid var(--accent); width: 13px; height: 13px; }
.drawflow .drawflow-node .output:hover,
.drawflow .drawflow-node .input:hover { background: var(--accent); }
.drawflow .connection .main-path { stroke: var(--accent); stroke-width: 2.4px; }
.drawflow .connection .main-path:hover { stroke: var(--text); }
.drawflow .connection .point { stroke: var(--accent); fill: var(--card); }
/* —— 沉浸式全屏:fixed 覆盖整个应用窗口(含顶部导航) —— */
body.cv-noscroll { overflow: hidden; }
#tab-canvas.cv-fullscreen { position: fixed; inset: 0; z-index: 9999; margin: 0;
height: auto; padding: 14px 16px; background: var(--bg); }
#tab-canvas.cv-fullscreen .cv-stage { min-height: 0; border-radius: 12px; }
+1
View File
@@ -0,0 +1 @@
.drawflow,.drawflow .parent-node{position:relative}.parent-drawflow{display:flex;overflow:hidden;touch-action:none;outline:0}.drawflow{width:100%;height:100%;user-select:none;perspective:0}.drawflow .drawflow-node{display:flex;align-items:center;position:absolute;background:#0ff;width:160px;min-height:40px;border-radius:4px;border:2px solid #000;color:#000;z-index:2;padding:15px}.drawflow .drawflow-node.selected{background:red}.drawflow .drawflow-node:hover{cursor:move}.drawflow .drawflow-node .inputs,.drawflow .drawflow-node .outputs{width:0}.drawflow .drawflow-node .drawflow_content_node{width:100%;display:block}.drawflow .drawflow-node .input,.drawflow .drawflow-node .output{position:relative;width:20px;height:20px;background:#fff;border-radius:50%;border:2px solid #000;cursor:crosshair;z-index:1;margin-bottom:5px}.drawflow .drawflow-node .input{left:-27px;top:2px;background:#ff0}.drawflow .drawflow-node .output{right:-3px;top:2px}.drawflow svg{z-index:0;position:absolute;overflow:visible!important}.drawflow .connection{position:absolute;pointer-events:none;aspect-ratio:1/1}.drawflow .connection .main-path{fill:none;stroke-width:5px;stroke:#4682b4;pointer-events:all}.drawflow .connection .main-path:hover{stroke:#1266ab;cursor:pointer}.drawflow .connection .main-path.selected{stroke:#43b993}.drawflow .connection .point{cursor:move;stroke:#000;stroke-width:2;fill:#fff;pointer-events:all}.drawflow .connection .point.selected,.drawflow .connection .point:hover{fill:#1266ab}.drawflow .main-path{fill:none;stroke-width:5px;stroke:#4682b4}.drawflow-delete{position:absolute;display:block;width:30px;height:30px;background:#000;color:#fff;z-index:4;border:2px solid #fff;line-height:30px;font-weight:700;text-align:center;border-radius:50%;font-family:monospace;cursor:pointer}.drawflow>.drawflow-delete{margin-left:-15px;margin-top:15px}.parent-node .drawflow-delete{right:-15px;top:-15px}
File diff suppressed because one or more lines are too long
+17
View File
@@ -0,0 +1,17 @@
# sing-box 引擎(全局代理功能)
「全局代理」功能内置 [sing-box](https://github.com/SagerNet/sing-box) 做本地中转 / TUN。
这些二进制体积较大(每个约 45–50MB),**不纳入 git**。
打包 / 运行前先拉取一次:
```bash
bash scripts/fetch-singbox.sh
```
会下载到:
- `darwin-arm64/sing-box`
- `darwin-x64/sing-box`
- `windows-x64/sing-box.exe`
electron-builder 会按目标架构把对应二进制打进安装包(见 `electron-builder.yml` 的 `extraResources`)。
+26
View File
@@ -0,0 +1,26 @@
// afterPack 钩子:未购买 Apple 证书时,electron-builder(24) 会「跳过签名」,
// 导致 .app 的 bundle 封印(seal)残缺(codesign --verify 报 "code has no resources
// but signature indicates they must be present")。这里在打 DMG 前,用 ad-hoc 身份
// 对整个 app(含内置 sing-box)做一次完整深度重新签名,得到有效封印——
// 用户去掉隔离标记后即可稳定运行。
const { execFileSync } = require('child_process');
const path = require('path');
exports.default = async function afterPack(context) {
if (context.electronPlatformName !== 'darwin') return;
const appName = `${context.packager.appInfo.productFilename}.app`;
const appPath = path.join(context.appOutDir, appName);
console.log(` • ad-hoc 深度重签名 app=${appPath}`);
// --force 覆盖已有签名,--deep 递归签所有嵌套二进制(sing-box 等),-s - 表示 ad-hoc
execFileSync('codesign', ['--force', '--deep', '--sign', '-', appPath], {
stdio: 'inherit',
});
// 校验封印是否完整(失败则中断构建,避免发出坏包)
execFileSync('codesign', ['--verify', '--deep', '--strict', '--verbose=2', appPath], {
stdio: 'inherit',
});
console.log(' • ad-hoc 签名校验通过 ✔');
};
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
# 下载全局代理功能所需的 sing-box 引擎二进制(macOS arm64/x64 + Windows x64)。
# 这些二进制不进 git(太大),打包前跑一次即可。用法: bash scripts/fetch-singbox.sh
set -euo pipefail
VER="${SINGBOX_VERSION:-1.13.14}"
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
DEST="$ROOT/resources/singbox"
mkdir -p "$DEST"
cd "$DEST"
fetch() { # $1=平台目录 $2=资源名(不含扩展) $3=压缩类型 $4=包内二进制名
local dir="$1" asset="$2" kind="$3" bin="$4"
echo "▶ $dir …"
if [ -f "$dir/$bin" ]; then echo " 已存在,跳过"; return; fi
mkdir -p "$dir"
local url="https://github.com/SagerNet/sing-box/releases/download/v${VER}/${asset}.${kind}"
if [ "$kind" = "tar.gz" ]; then
curl -fsSL "$url" -o t.tgz && tar xzf t.tgz && mv "${asset}/${bin}" "$dir/" && rm -rf t.tgz "$asset"
else
curl -fsSL "$url" -o t.zip && unzip -qo t.zip && mv "${asset}/${bin}" "$dir/" && rm -rf t.zip "$asset"
fi
chmod +x "$dir/$bin" 2>/dev/null || true
echo " ✓ $dir/$bin"
}
fetch "darwin-arm64" "sing-box-${VER}-darwin-arm64" "tar.gz" "sing-box"
fetch "darwin-x64" "sing-box-${VER}-darwin-amd64" "tar.gz" "sing-box"
fetch "windows-x64" "sing-box-${VER}-windows-amd64" "zip" "sing-box.exe"
echo "全部就位 ✅ (sing-box v${VER})"
+124
View File
@@ -0,0 +1,124 @@
#!/usr/bin/env node
// publish-hot.js —— 发布热更新包到自建 gitea。
//
// 用法:
// GIT_TOKEN=xxx node scripts/publish-hot.js --version 1.5.1 [--min 1.5.0] \
// [--notes "更新说明"] [--mandatory] [--requires-full]
// token 也可放在 ~/.tokendun-git-token 文件里(优先用环境变量 GIT_TOKEN)。
//
// 流程:打包 electron/+renderer/ 成 zip → sha256(+可选签名) → 写 hotupdate.json
// → gitea 的 tag=hot release:删旧 bundle/manifest、传新的。
"use strict";
const fs = require("fs");
const os = require("os");
const path = require("path");
const crypto = require("crypto");
const { execFileSync } = require("child_process");
const ROOT = path.join(__dirname, "..");
const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf8"));
// —— 参数 ——
const argv = process.argv.slice(2);
function arg(name, def) {
const i = argv.indexOf(name);
return i >= 0 && argv[i + 1] && !argv[i + 1].startsWith("--") ? argv[i + 1] : def;
}
const has = (name) => argv.includes(name);
const version = arg("--version", pkg.hotContentVersion || pkg.version);
const minAppVersion = arg("--min", pkg.version);
const notes = arg("--notes", "");
const mandatory = has("--mandatory");
const requiresFull = has("--requires-full");
// —— token ——
let token = process.env.GIT_TOKEN || "";
if (!token) {
try { token = fs.readFileSync(path.join(os.homedir(), ".tokendun-git-token"), "utf8").trim(); } catch {}
}
if (!token) { console.error("缺少 gitea token:设置 GIT_TOKEN 或写入 ~/.tokendun-git-token"); process.exit(1); }
const OWNER = "backup-bot", REPO = "backup", TAG = "hot";
const API = `https://up.tokendun.cc/api/v1/repos/${OWNER}/${REPO}`;
const DL = `https://up.tokendun.cc/${OWNER}/${REPO}/releases/download/${TAG}`;
const OUT = path.join(ROOT, "dist-hot");
fs.mkdirSync(OUT, { recursive: true });
const bundleName = `bundle-${version}.zip`;
const bundlePath = path.join(OUT, bundleName);
// —— 1) 打包(zip,deflate,可被 lib/zip.js 解压)——
fs.rmSync(bundlePath, { force: true });
console.log(`打包 electron/ + renderer/ → ${bundleName} …`);
execFileSync("zip", ["-r", "-X", "-q", bundlePath, "electron", "renderer",
"-x", "*.map", "-x", "*/node_modules/*"], { cwd: ROOT, stdio: "inherit" });
// —— 2) sha256(+ 可选 ed25519 签名)——
const buf = fs.readFileSync(bundlePath);
const sha256 = crypto.createHash("sha256").update(buf).digest("hex");
let signature = "";
try {
const keyPath = path.join(os.homedir(), ".codex-hot-key.pem"); // 私钥(不入库)
if (fs.existsSync(keyPath)) {
const key = fs.readFileSync(keyPath);
signature = crypto.sign(null, Buffer.from(`${sha256}:${version}`), key).toString("base64");
console.log("已用本地私钥签名");
}
} catch (e) { console.warn("签名跳过:" + e.message); }
// —— 3) manifest ——
const manifest = {
contentVersion: version,
minAppVersion,
requiresFull,
bundleUrl: `${DL}/${bundleName}`,
size: buf.length,
sha256,
signature,
notes,
mandatory,
};
const manifestPath = path.join(OUT, "hotupdate.json");
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
console.log(`sha256=${sha256} size=${(buf.length / 1048576).toFixed(2)}MB`);
// —— 4) gitea API ——
function api(method, urlPath, body) {
const args = ["-s", "-X", method, "-H", `Authorization: token ${token}`, `${API}${urlPath}`];
if (body !== undefined) { args.push("-H", "Content-Type: application/json", "-d", JSON.stringify(body)); }
return execFileSync("curl", args, { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
}
function apiJson(method, urlPath, body) {
const out = api(method, urlPath, body);
try { return JSON.parse(out); } catch { return null; }
}
// 确保 hot release 存在
let rel = apiJson("GET", `/releases/tags/${TAG}`);
if (!rel || !rel.id) {
console.log("创建 hot release …");
rel = apiJson("POST", `/releases`, { tag_name: TAG, target_commitish: "main", name: "热更新通道", body: "asar 热更新包(自动发布)", draft: false, prerelease: false });
}
if (!rel || !rel.id) { console.error("无法创建/获取 hot release"); process.exit(1); }
// 删除同名旧 asset
for (const a of rel.assets || []) {
if (a.name === bundleName || a.name === "hotupdate.json" || /^bundle-.*\.zip$/.test(a.name)) {
api("DELETE", `/releases/${rel.id}/assets/${a.id}`);
console.log("删除旧 asset:", a.name);
}
}
// 上传 bundle + manifest
function upload(file, name) {
const code = execFileSync("curl", ["-s", "-o", "/dev/null", "-w", "%{http_code}",
"-X", "POST", "-H", `Authorization: token ${token}`,
"-F", `attachment=@${file};type=application/octet-stream`,
`${API}/releases/${rel.id}/assets?name=${encodeURIComponent(name)}`], { encoding: "utf8" });
console.log(`上传 ${name} → HTTP ${code}`);
}
upload(bundlePath, bundleName);
upload(manifestPath, "hotupdate.json");
console.log(`\n✅ 热更 v${version} 已发布。`);
console.log(` minAppVersion=${minAppVersion} mandatory=${mandatory} requiresFull=${requiresFull}`);
console.log(` 客户端将从 ${API}/releases/tags/${TAG} 检测到。`);