feat: add desktop app hot update channel
This commit is contained in:
+15
@@ -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
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
@@ -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,未配置或失败时退化为本地规则提取;可全局或按项目关闭。
|
||||||
|
|
||||||
| 技能 | 分类 | 说明 | 安装包 |
|
## 开发运行
|
||||||
|------|------|------|--------|
|
```bash
|
||||||
| **gen-image · 生图** | 图片生成 | 通过大模型中转站调用 OpenAI 图片接口生图(文生图/图生图/局部重绘/透明背景),零依赖 | `categories/图片生成/28772__gen-image.zip` |
|
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
|
```bash
|
||||||
./gen.sh "提示词" --size 1024x1024 --quality high # 文生图
|
xattr -cr "/Applications/Codex 插件市场.app"
|
||||||
./gen.sh "换成雪山背景" --edit input.png # 图生图
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
之后正常双击即可打开,只需做一次。(若提示权限不足,命令前加 `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`。
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
Executable
+49
@@ -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
|
||||||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 123 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 322 KiB |
@@ -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
|
||||||
Vendored
+54
@@ -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; // 包内都崩 = 真错误
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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 };
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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 };
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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 };
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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 };
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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 };
|
||||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||||
|
},
|
||||||
|
});
|
||||||
Generated
+4579
File diff suppressed because it is too large
Load Diff
@@ -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
File diff suppressed because it is too large
Load Diff
+275655
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -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"}]}
|
||||||
@@ -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; }
|
||||||
Vendored
+1
@@ -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}
|
||||||
Vendored
+1
File diff suppressed because one or more lines are too long
@@ -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`)。
|
||||||
@@ -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 签名校验通过 ✔');
|
||||||
|
};
|
||||||
@@ -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})"
|
||||||
@@ -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} 检测到。`);
|
||||||
Reference in New Issue
Block a user