fix: canvas image/video download - fix data URL parsing in cvDownloadPreview (v1.5.21, hot 1.5.23)
This commit is contained in:
@@ -1,34 +1,45 @@
|
|||||||
<!-- codex-memory:start -->
|
<!-- codex-memory:start -->
|
||||||
## 项目记忆(Codex 插件市场自动维护 · 回答前据此 · 勿手动编辑本段)
|
## 项目记忆(Codex 插件市场自动维护 · 回答前据此 · 勿手动编辑本段)
|
||||||
**当前状态**:当前仅确认助手位于项目目录 `/Users/mac/Documents/备份数据/ProjectDown/codex-market-app`,尚未开始具体代码任务。可继续处理代码、查历史记忆或恢复 Claude/Codex 会话。
|
**关于用户 / 长期事实(务必遵守)**:
|
||||||
|
- 与助手沟通用中文优先
|
||||||
|
**当前状态**:已确认失败原因是后端优先选到了需59aiapi私有token的content_url,导致401;正确应选同一结果中的pulseaify mp4。已修改URL提取逻辑优先选择真实视频文件URL,并验证15秒结果可下载播放。
|
||||||
**已定决策**:
|
**已定决策**:
|
||||||
- 指定历史会话用 claude --resume
|
- 59aiapi content不直接下载
|
||||||
- 最近会话用 claude --continue
|
- 不再优先使用video_url/content_url
|
||||||
- 优先在原项目目录恢复会话
|
- 优先选.mp4等真实视频文件URL
|
||||||
- 不在输出中暴露任何 API key
|
- URL提取不能误选任务API
|
||||||
- Tavily 不可用时用网页搜索兜底
|
- 前端下载应优先使用 dataUrl
|
||||||
- 不破坏 Homebrew 系统 Python
|
- 下载受保护域名需带Bearer鉴权
|
||||||
- 日期按 2026-07-16
|
- Seedance 失败点在成品下载阶段
|
||||||
- 查询 topic 设为 news
|
- 本地craft-website技能已安装校验通过
|
||||||
- 使用 Tavily skill 搜索新闻
|
- URL 提取只认明确视频字段或文件
|
||||||
- latest兼容下载保留但设为预发布
|
- Seedance 成品下载需带鉴权
|
||||||
- 同时发布热更和完整包
|
- skill 源 SKILL.md 移除 version
|
||||||
- 非显式生图请求剥离gen-image声明
|
|
||||||
**下一步**:
|
**下一步**:
|
||||||
- 等待用户指定具体任务
|
- 重启应用加载新代码后再测
|
||||||
**关键文件**:/Users/mac/Documents/备份数据/ProjectDown/codex-market-app
|
- 确认热更包含本次修改
|
||||||
|
- 失败时查看seedance调试日志
|
||||||
|
**关键文件**:/Users/mac/Documents/备份数据/ProjectDown/codex-market-app/electron/lib/canvas.js、/Users/mac/Documents/备份数据/ProjectDown/codex-market-app/renderer/catalog.json、/Users/mac/Documents/备份数据/ProjectDown/codex-market-app/categories/前端/28773__craft-website.zip、/Users/mac/Documents/备份数据/ProjectDown/制作网站 skill/SKILL.md
|
||||||
**历史话题(需其中细节时用下面命令检索)**:
|
**历史话题(需其中细节时用下面命令检索)**:
|
||||||
- 2026-07-16 项目会话恢复
|
- 2026-07-19 Seedance视频下载失败修复
|
||||||
- 2026-07-16 Claude Code 会话恢复
|
- 2026-07-18 Grok视频比例选项补充
|
||||||
- 2026-07-16 今日新闻搜索交接
|
- 2026-07-18 Grok传图与提示词约定
|
||||||
- 2026-07-16 发布1.5.14完整包
|
- 2026-07-18 Seedance参数修复与热更状态
|
||||||
- 2026-07-16 生图 skill 与聊天分组冲突
|
- 2026-07-18 插件市场会话起始
|
||||||
- 2026-07-15 语音输入按钮位置与样式修复
|
- 2026-07-18 gen-image 技能市场更新
|
||||||
- 2026-07-15 Grok多参考图热更排查
|
- 2026-07-18 修复 token 统计不准
|
||||||
- 2026-07-15 会话同步修复与热更发布
|
- 2026-07-17 调整语音连接入口位置
|
||||||
- 2026-07-15 工具箱新增Grok视频与全量会话同步
|
- 2026-07-17 项目闲聊与广汉天气查询
|
||||||
- 2026-07-15 生成魔兽世界宣传海报
|
- 2026-07-17 项目任务待确认
|
||||||
- 2026-07-15 Codex skill软链改造
|
- 2026-07-17 修复 Codex token 计数
|
||||||
- 2026-07-14 列出本地 skills
|
- 2026-07-17 Respond to hello
|
||||||
> 回答涉及本段未列出的历史细节前,先运行 `rg -n "关键词" /Users/mac/.codex/memory/31c67c70b625.md` 查证再答。
|
> 回答涉及本段未列出的历史细节前,先运行 `rg -n "关键词" /Users/mac/.codex/memory/31c67c70b625.md` 查证再答。
|
||||||
<!-- codex-memory:end -->
|
<!-- codex-memory:end -->
|
||||||
|
|
||||||
|
## 发布与热更规则
|
||||||
|
|
||||||
|
- 热更、完整安装包和插件资源的归属仓库统一为 `mhgmh/codex-market-app`。
|
||||||
|
- `backup-bot` 只是 Gitea 上传认证账号,不是资源归属者;禁止把 release、hot 资源或插件资源发布到 `backup-bot/backup`。
|
||||||
|
- 当前 Git 认证远端为 `https://up.tokendun.cc/backup-bot/backup.git`,仅代表上传身份;发布 API 的 owner/repo 必须使用 `mhgmh/codex-market-app`。
|
||||||
|
- 热更 manifest 的 `minAppVersion` 表示最低原生 Electron 壳版本,当前为 `1.5.0`,不能直接使用 `hotContentVersion`。
|
||||||
|
- 发布热更后必须从 `https://update.tokendun.cc/api/v1/repos/mhgmh/codex-market-app/releases/tags/hot` 验证 manifest、bundle 和 SHA256。
|
||||||
|
|||||||
+8
@@ -0,0 +1,8 @@
|
|||||||
|
- [router 双平台配置兼容](router-dual-platform-config.md) — Mac(无tokens/custom直连) 与 Windows(登录态/新格式) 的切回逻辑约束,改 router.js 必读
|
||||||
|
- [router 双激活槽](router-dual-active-slots.md) — activeId(OpenAI族)+activeClaudeId(Anthropic族) 双槽按协议派发,Codex/GPT 与 Claude Code 可同时激活,改 router 激活/派发逻辑必读
|
||||||
|
- [cocoloop 技能镜像](cocoloop-skills-mirror.md) — 10877 技能镜像仓库、技能商店改造、gitea 服务器约束(匿名401/raw截断)与安装架构
|
||||||
|
- [创意画布功能](canvas-studio-feature.md) — Drawflow 画布/节点/中转分组/流式出图的架构、热更交付陷阱、CDP 验证手法,改画布或排查"改了没生效"必读
|
||||||
|
- [发布流程](release-publish-flow.md) — 热更(publish:hot)+完整包(mac/win双平台)的版本号、token位置、产物与gitea上传目标、v1.5.5已安装列表中文化修复
|
||||||
|
- [Codex CDP 注入](codex-cdp-inject.md) — 往Codex桌面版(网页DOM)注入UI的机制:CDP连接+renderer-inject+execCommand写回composer,做"注入Codex界面"类功能(如语音输入按钮)必读
|
||||||
|
- [Claude 桌面 App 第三方推理配置](claude-desktop-3p-config.md) — 接入中转/切回官方登录:托管偏好域 com.anthropic.claudefordesktop、gateway 配置键、deploymentMode 开关、备份还原机制,做 claudedesk 必读
|
||||||
|
- [Codex 切模型会话记录消失](codex-model-catalog-history-loss.md) — 真因是 config 的 model 不在模型目录→Codex以未知模型启动、侧栏历史还原失败(数据无损),applyModelCatalog 漏登记的非对称 bug 与诊断/自愈修复,排查"切模型丢记录"必读
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
---
|
||||||
|
name: codex-model-catalog-history-loss
|
||||||
|
description: 「Codex切模型后会话记录消失」的真因——config指向的model不在模型目录→Codex以未知模型启动、侧栏历史还原失败。排查此类"记录丢失"必读
|
||||||
|
metadata:
|
||||||
|
type: project
|
||||||
|
---
|
||||||
|
|
||||||
|
客户报「Codex 切模型后会话记录莫名丢失」。真因不是数据丢失,是**显示丢失**:`~/.codex/config.toml` 的根级 `model` 指向了一个**不在模型目录 `cc-switch-model-catalog.json` 里的 slug** → Codex 本体以未知模型启动,无法解析该模型 → 侧栏历史线程还原失败,看起来"会话没了"。rollout 文件、`state_*.sqlite`(threads)、`session_index.jsonl` 全部无损,切回一个目录内已有的模型(或把该 model 登记进目录)后重启 Codex 即恢复。
|
||||||
|
|
||||||
|
**触发的代码 bug(electron/lib/router.js,已修)**:`applyModelCatalog(group)` 旧代码首行 `if (!group.contextWindow) return` + `if(!win) return`,即分组没填「上下文窗口」就跳过目录登记;而 `setConfigModel(model)` 却无条件把 `model=` 写进 config.toml → **非对称**:config 指向的模型没进目录。分组的 contextWindow 除 DeepSeek 外普遍为空,所以切到目录里没有的模型必现。
|
||||||
|
|
||||||
|
**修复**:① `applyModelCatalog` 去掉 contextWindow 前置条件,只要有 model 就登记(visibility=list,窗口回退 输入→模板默认→128000);② 新增 `ensureModelInCatalog()` 内嵌进 `setConfigModel`,写 config 前保证该 slug 在目录里 = 不变量「config 指向的 model 必在目录」,任何切换路径+历史残骸环境都自愈。③ **关键补强**:用户是在 Codex 本体的模型选择器里切模型的,那条路径**不经过本 App**,只登记单个 model 不够——切到别的同代模型(如 tokendun 有 gpt-5.6-luna/sol/terra 三个)又会丢。故新增 `syncCatalogFromProvider(group)`:拉分组 `/v1/models`(复用 `fetchModels`),把全部**文本**模型(排除 image/imagine/whisper/audio/tts/embed/rerank/review 等非对话模型,正则 NON_TEXT_MODEL_RE)一次性登记进目录。在 `applyDirectToCodex` / `applyToCodex`(fire-and-forget) 及 **main.js 应用启动时**(`router.llmGroup()` 取当前激活分组) 各触发一次 → 既有安装无需重存分组即自动修复。
|
||||||
|
|
||||||
|
**已发布**:热更 v1.5.20(mandatory=true, min 1.5.0)已上 gitea hot 通道。升级后**仍需用户手动重启 Codex 本体**(App 修好目录,Codex 下次启动才读)。发布见 [[release-publish-flow]]。
|
||||||
|
|
||||||
|
**诊断手法(会话本文不用读,只看分布)**:
|
||||||
|
- `sqlite3 state_*.sqlite "SELECT model_provider,model,COUNT(*) FROM threads GROUP BY 1,2"` → 全 null/同值说明侧栏不是按 model/provider 过滤,排除 provider-sync 方向。
|
||||||
|
- node 读 catalog 打印各 `slug|visibility|context_window`,比对 config.toml 的 `model=` 是否在其中 —— 不在即命中本 bug。
|
||||||
|
- `session_index.jsonl.bak-*` 若有多份,是用户反复跑 provider-sync 抢救历史的痕迹。
|
||||||
|
|
||||||
|
单机即时抢救:把 config 当前 model 用模板克隆登记进 catalog(写 .bak-fix 备份),重启 Codex 即回。相关:[[router-dual-platform-config]] [[router-dual-active-slots]] [[release-publish-flow]]。
|
||||||
+24
-27
@@ -50,17 +50,16 @@ canvasRun: (kind, params) => ipcRenderer.invoke("canvas-run", { kind, params }),
|
|||||||
- `model`:**用户填写的图像模型**(如 `gpt-image-1`/`dall-e-3`/`flux-pro`/`seedream-3` 等)。**为空则直接返回 `{ok:false,error:"请先填写图像模型"}`,不要回退聊天模型。**
|
- `model`:**用户填写的图像模型**(如 `gpt-image-1`/`dall-e-3`/`flux-pro`/`seedream-3` 等)。**为空则直接返回 `{ok:false,error:"请先填写图像模型"}`,不要回退聊天模型。**
|
||||||
- `size`:`"WxH"`(前端按 比例×清晰度 算好传来)。
|
- `size`:`"WxH"`(前端按 比例×清晰度 算好传来)。
|
||||||
- `endpointPath`:默认 `/v1/images/generations`,允许用户覆盖。
|
- `endpointPath`:默认 `/v1/images/generations`,允许用户覆盖。
|
||||||
- **图生图参考图**:`image`(用户上传的 base64 dataURL)→ 主进程 `uploadMedia()` 传兰空图床换公网直链 → 塞进请求体字段 `refField`(默认 `image`,前端高级项可改)。上传失败直接返回 `{ok:false,error}`,不静默降级。流式与非流式两条 body 都带该字段。
|
- **图生图参考图**:`image`(用户上传的 base64 dataURL)→ 主进程 `uploadMedia()` 传图床换公网直链 → 塞进请求体字段 `refField`(默认 `image`,前端高级项可改)。上传失败直接返回 `{ok:false,error}`,不静默降级。流式与非流式两条 body 都带该字段。
|
||||||
- 错误信息里带上**实际请求的 URL 和 model**,方便排查(如 404 时提示 `POST {url} model={model} → 404`)。
|
- 错误信息里带上**实际请求的 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`)
|
- **video** `{ mode, prompt, model?, image?, lastFrameImage?, referenceImages?[], ratio?, quality?, seconds?, referenceField?, endpointPath? }` —— **Seedance 2.0**,使用新中转契约。
|
||||||
- `mode`:`"text"` 文生视频 | `"first"` 首帧 | `"firstlast"` 首尾帧 | `"reference"` 多参考图 | `"video"` 参考视频。旧值 `"image"` 归一为 `"first"`。
|
- 创建端点默认 `/v1/video/generations`;若分组 `baseUrl` 已以 `/v1` 结尾,拼接时不得产生重复的 `/v1/v1`。
|
||||||
- **素材**(主进程 `runVideo` 里经 `resolveMat`:`data:`→兰空图床外链,`http(s)`→原样):`image`(首帧)、`last_frame_image`、`reference_images[]`;**`reference_video` 仅接受公网URL**(图床不托管视频)。
|
- 请求体是扁平结构:`prompt` 必填,固定 `async:true`;`duration` 仅发送整数 `5/10/15`;`ratio`、`quality` 非自动时才发送;`model` 可省略并使用上游默认。
|
||||||
- `input_mode` 仅在 mode≠text 时下发;素材按模式互斥;缺必填素材直接 `{ok:false,error}`。
|
- `mode`:`"text"` 只发送文本 | `"first"` 发送 `image` | `"firstlast"` 将首尾两图作为 `images` | `"reference"` 按 `referenceField` 发送 `images` 或 `ref_assets`。旧值 `"image"` 归一为 `"first"`。
|
||||||
- `metadata`:`ratio / resolution / generate_audio / watermark / camera_fixed / seed`(auto/-1/空不塞)。
|
- 图片经 `resolveMat` 处理:`data:` 先上传图床换为公网 URL,`http(s)` 原样发送;上游请求中禁止出现 base64。
|
||||||
- `model`:用户填写的视频模型(为空返回明确错误)。
|
- 不发送旧版 `input_mode`、`metadata`、`resolution`、`generate_audio`、`watermark`、`camera_fixed`、`seed`、`reference_video` 字段。
|
||||||
- body 组装:`{ model, prompt, ...(mode==='image'?{image}:{}) , size, ratio, duration, ...extra }`(空字段不塞)。
|
- 创建响应任务 ID 兼容 `request_id/requestId/task_id/taskId/id`,并递归检查 `data/result/task`;字符串和数字 ID 都支持。
|
||||||
- `endpointPath` 默认 `/v1/videos`,允许覆盖。仍严禁伪造成功。
|
- **grokVideo** `{ mode, prompt, model, image?, referenceImages?[], resolution?, seconds?, aspectRatio?, endpointPath? }` —— **Grok 视频**。首帧图生视频默认走 `model:"grok-video-1.5"`,提交体为 `{ model, prompt, seconds:"6", size:"1280x720", image:{url:"https://.../image.png"} }`;上传/上游传来的 dataURL 会先转公网 URL,再放入 `image.url`。`resolution + aspectRatio` 只在本地映射为 `size`(如 `9:16 + 720p -> "720x1280"`),不下发 `duration/resolution/image_url/aspect_ratio`。`mode:"reference"` 走旧 Reference-to-Video:强制 `model:"grok-imagine-video"`、`resolution:"720p"`,把 `referenceImages[]` 解析为 `reference_images: [{url}]`,并下发 `aspect_ratio`。
|
||||||
- **grokVideo** `{ mode, prompt, model, image?, referenceImages?[], resolution?, seconds?, aspectRatio?, endpointPath? }` —— **Grok 视频**。`mode:"reference"` 走 Reference-to-Video:强制 `model:"grok-imagine-video"`、`resolution:"720p"`,把 `referenceImages[]` 解析为 `reference_images: [{url}]`;`grok-imagine-video-1.5` 仅用于单图首帧场景,不用于多参考图。
|
|
||||||
- **tts** 增加 `model`(默认 `tts-1` 可保留但允许覆盖)、`format`、可选 `speed`。
|
- **tts** 增加 `model`(默认 `tts-1` 可保留但允许覆盖)、`format`、可选 `speed`。
|
||||||
|
|
||||||
### 新增 canvasModels(后端 + preload)
|
### 新增 canvasModels(后端 + preload)
|
||||||
@@ -130,26 +129,24 @@ canvasRun: (kind, params) => ipcRenderer.invoke("canvas-run", { kind, params }),
|
|||||||
- 最终 `canvasRun` resolve 的成品仍照常渲染覆盖;skeleton 在收到第一帧 partial 后即可替换为图片。
|
- 最终 `canvasRun` resolve 的成品仍照常渲染覆盖;skeleton 在收到第一帧 partial 后即可替换为图片。
|
||||||
- 仅 image 节点用到渐进预览;video/tts 不变。
|
- 仅 image 节点用到渐进预览;video/tts 不变。
|
||||||
|
|
||||||
## 【v5 增补 · 接入 TokenDun/Seedance 真实视频接口】——本轮必须实现
|
## 【v5 增补 · 接入 TokenDun/Seedance 真实视频接口】
|
||||||
参照 `/Users/mac/Documents/备份数据/ProjectDown/提示词提取/server.js`(Seedance 视频 API)。异步任务制:建任务→轮询→下载。gwBase = `{分组baseUrl}/v1`。
|
Seedance 使用异步任务制:建任务→轮询→下载。以下内容按当前 `/v1/video/generations` 文档更新,覆盖早期火山 `metadata/input_mode` 版本。
|
||||||
|
|
||||||
### 后端 runVideo 重写(canvas.js)——完全替换旧 /v1/videos 逻辑
|
### 后端 runVideo 重写(canvas.js)——完全替换旧 /v1/videos 逻辑
|
||||||
- 端点:create = `{base}{endpointPath||"/v1/video/generations"}`;poll = `create + "/" + taskId`(GET)。鉴权 `Bearer <key>`(分组由 groupId 解析,同 v3)。
|
- 端点:create = `joinUrl(base, endpointPath||"/v1/video/generations")`;poll = `create + "/" + taskId`(GET)。鉴权 `Bearer <key>`(分组由 groupId 解析,同 v3)。
|
||||||
- **body 组装**(严格照 Seedance):
|
- **body 组装**(严格照 Seedance):
|
||||||
- `{ model, prompt }`(model 空→报错"请先填写视频模型")
|
- 基础字段 `{ prompt, async:true }`;`model` 可选。
|
||||||
- `seconds`:字符串,params.seconds 非空才加(值 "-1"=自动/"5"/"10")。
|
- `params.seconds` 仅为 `5/10/15` 时转成整数 `duration`;`ratio`、`quality` 非 `auto` 时按顶层字段发送。
|
||||||
- 文生视频(mode!=='image'):不加 input_mode、不加 image。
|
- 文生视频不发送图片字段;首帧发送 `image:string`;多图发送 `images:string[]`;全能参考发送 `ref_assets:string[]`。
|
||||||
- 图生视频(mode==='image'):`input_mode:"first"` + `image: params.imageUrl`。**imageUrl 必须是 http(s) 公网URL**;若为空或以 `data:` 开头 → 直接返回 `{ok:false,error:"图生视频需填公网图片URL(Seedance 不接受 base64/上游图)"}`,**不要把 base64 发出去**。
|
- 图片必须是公网 URL;本地 data URL 先经 `uploadMedia()` 转成公网地址。
|
||||||
- `metadata`:{ ratio(≠auto才加), resolution(≠auto才加), generate_audio(bool 才加) },非空才挂到 body.metadata。
|
- **建任务**:POST create。2xx 才继续;非 2xx 返回状态、实际 URL、model 和上游详情。响应先读取 text 再解析 JSON,确保非 JSON 错误不会丢失原文。任务 ID 由通用 `pickTaskId()` 提取。
|
||||||
- **建任务**: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 一次,最多约 10 分钟(120 次)。用 `normalizeVideoStatus`;succeeded → `findVideoUrl`(深挖 `video_url/result_url/url`)→ 下载转 base64 → `{ok:true,mime:"video/mp4",dataUrl,meta}`;failed/超时 → 明确错误。仍严禁伪造成功。
|
||||||
- **轮询**: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。
|
- port 两个纯函数 `normalizeVideoStatus`、`findVideoUrl` 到 canvas.js。全程 try/catch。
|
||||||
|
|
||||||
### 前端视频节点(renderer/*)
|
### 前端视频节点(renderer/*)
|
||||||
- 模式:文生视频 / 图生视频(保留)。
|
- 模式:首帧图、双图参考、多参考图/全能参考、文生视频。
|
||||||
- **图生视频时显示「公网图片URL」输入框** `.cv-field-imgurl`(hint:Seedance 需公网图片URL,上游 base64 图无法直接用)。文生视频时隐藏。
|
- `prompt` 在所有模式必填;模型可留空使用上游默认,也可通过分组拉取当前可用模型。
|
||||||
- 比例(16:9/9:16/1:1/4:3) + 分辨率(480p/720p/1080p/自动) 保留。
|
- 比例:16:9 / 9:16 / 1:1 / 4:3;画质:自动 / `hd`;时长:自动 / 5s / 10s / 15s。
|
||||||
- **时长改为**:自动(-1) / 5s / 10s(对应 seconds 值 "-1"/"5"/"10","自动"传 "-1" 或空)。
|
- 高级:接口地址(默认 `/v1/video/generations`)和多参考图字段(`images/ref_assets`)。
|
||||||
- **加「生成音频」勾选** `.cv-field-audio`(→ generateAudio bool)。
|
- 不展示旧接口已不支持的分辨率、音频、水印、固定镜头、seed、参考视频控件。
|
||||||
- 高级:接口地址(默认 /v1/video/generations)。
|
- 运行 params:`{ mode, prompt, model?, ratio, quality, seconds, referenceField, image?, lastFrameImage?, referenceImages?, endpointPath?, groupId }`。
|
||||||
- 运行 params:`{ mode:'text'|'image', prompt, model, ratio, resolution, seconds, generateAudio, imageUrl(仅image), endpointPath, groupId }`。图生视频且 imageUrl 空/为 data: → 前端红字拦截。
|
|
||||||
|
|||||||
+42
-35
@@ -3,58 +3,65 @@
|
|||||||
|
|
||||||
> 核心记忆已写进本项目 `AGENTS.md`(Codex 每次会话自动读)。本文件是更详细的近期快照,供需要时参考;关键信息不依赖本文件。
|
> 核心记忆已写进本项目 `AGENTS.md`(Codex 每次会话自动读)。本文件是更详细的近期快照,供需要时参考;关键信息不依赖本文件。
|
||||||
|
|
||||||
## 最近会话 · 2026-07-16 14:32 · 项目会话恢复
|
## 最近会话 · 2026-07-19 08:05 · Seedance视频下载失败修复
|
||||||
|
|
||||||
当前仅确认助手位于项目目录 `/Users/mac/Documents/备份数据/ProjectDown/codex-market-app`,尚未开始具体代码任务。可继续处理代码、查历史记忆或恢复 Claude/Codex 会话。
|
已确认失败原因是后端优先选到了需59aiapi私有token的content_url,导致401;正确应选同一结果中的pulseaify mp4。已修改URL提取逻辑优先选择真实视频文件URL,并验证15秒结果可下载播放。
|
||||||
|
|
||||||
**下一步**:
|
|
||||||
- 等待用户指定具体任务
|
|
||||||
|
|
||||||
**涉及文件**:/Users/mac/Documents/备份数据/ProjectDown/codex-market-app
|
|
||||||
|
|
||||||
## 更早会话 · 2026-07-16 10:37 · Claude Code 会话恢复
|
|
||||||
|
|
||||||
用户想重开 Claude Code 会话并延续之前记录。已说明可在原项目目录用 `claude --continue`/`-c` 接最近会话,用 `claude --resume`/`-r` 或 `/resume` 手动选择历史会话;会话按项目目录保存。
|
|
||||||
|
|
||||||
**已确定的决策**:
|
**已确定的决策**:
|
||||||
- 优先在原项目目录恢复会话
|
- 优先选.mp4等真实视频文件URL
|
||||||
- 最近会话用 claude --continue
|
- 不再优先使用video_url/content_url
|
||||||
- 指定历史会话用 claude --resume
|
- 59aiapi content不直接下载
|
||||||
|
- 前端下载优先使用dataUrl
|
||||||
|
|
||||||
**下一步**:
|
**下一步**:
|
||||||
- 进入原项目目录
|
- 重启应用加载新代码后再测
|
||||||
- 运行恢复命令继续会话
|
- 确认热更包含本次修改
|
||||||
|
- 失败时查看seedance调试日志
|
||||||
|
|
||||||
**涉及文件**:~/.claude/projects/.../*.jsonl
|
**涉及文件**:/Users/mac/Documents/备份数据/ProjectDown/codex-market-app/electron/lib/canvas.js、/Users/mac/Documents/备份数据/ProjectDown/codex-market-app/renderer/catalog.json、/Users/mac/Documents/备份数据/ProjectDown/codex-market-app/categories/前端/28773__craft-website.zip、/Users/mac/Documents/备份数据/ProjectDown/制作网站 skill/SKILL.md
|
||||||
|
|
||||||
## 更早会话 · 2026-07-16 09:25 · 今日新闻搜索交接
|
## 更早会话 · 2026-07-18 15:50 · Grok视频比例选项补充
|
||||||
|
|
||||||
用户要求用 Tavily 搜索今日最新新闻。Tavily 本地 skill 未跑通:缺 tavily-python,且未配置 TAVILY_API_KEY;已改用网页搜索兜底,整理了 2026-07-16 约17:30 的 AP、中国日报等要闻来源。
|
已为 Grok 视频节点补充视频格式/比例选择,并让后端按比例和分辨率组合生成真实 size。验证通过,旧画布节点可能仍需确认是否需要迁移已保存 HTML。
|
||||||
|
|
||||||
**已确定的决策**:
|
**已确定的决策**:
|
||||||
- 不破坏 Homebrew 系统 Python
|
- Grok默认视频格式为16:9
|
||||||
- Tavily 不可用时用网页搜索兜底
|
- 支持16:9、9:16、1:1、4:3、3:4
|
||||||
- 不在输出中暴露任何 API key
|
- 后端用比例+分辨率生成size
|
||||||
|
- 多参考图旧接口继续用aspect_ratio
|
||||||
|
|
||||||
**下一步**:
|
**下一步**:
|
||||||
- 如需 Tavily,配置 TAVILY_API_KEY
|
- 确认旧画布节点是否需迁移
|
||||||
- 用 venv 安装 tavily-python
|
- 如需要则补节点恢复兼容逻辑
|
||||||
- 可继续补充分类新闻来源
|
|
||||||
|
|
||||||
**涉及文件**:/Users/mac/Library/Application Support/tokendun-codex-client/skills/Tavily-AI-Search/SKILL.md、.learnings
|
**涉及文件**:/Users/mac/Documents/备份数据/ProjectDown/codex-market-app/renderer/app.js、/Users/mac/Documents/备份数据/ProjectDown/codex-market-app/electron/lib/canvas.js
|
||||||
|
|
||||||
## 更早会话 · 2026-07-16 05:37 · 发布1.5.14完整包
|
## 更早会话 · 2026-07-18 15:36 · Grok传图与提示词约定
|
||||||
|
|
||||||
已修复普通聊天因gen-image声明触发403的问题,热更1.5.14和完整安装包1.5.14均已发布到mhgmh/codex-market-app;Windows与macOS arm64/x64下载链接已给出,latest正式版指向v1.5.14。
|
Grok 首帧图生视频已按文档对齐:先上传到自家图床,再提交 `image.url` 形式的公网链接;前端比例选项也已补齐并完成 mock 验证。关于指令沟通,中文优先,外部生成模型用中英混写更稳。
|
||||||
|
|
||||||
**已确定的决策**:
|
**已确定的决策**:
|
||||||
- 非显式生图请求剥离gen-image声明
|
- 与助手沟通用中文优先
|
||||||
- 版本统一为1.5.14
|
- 外部生成提示词可中英混写
|
||||||
- 同时发布热更和完整包
|
- Grok 首帧图先传图床再提交 `image.url`
|
||||||
- latest兼容下载保留但设为预发布
|
|
||||||
|
**涉及文件**:/Users/mac/Documents/备份数据/ProjectDown/codex-market-app/electron/lib/canvas.js、/Users/mac/Documents/备份数据/ProjectDown/codex-market-app/renderer/app.js
|
||||||
|
|
||||||
|
## 更早会话 · 2026-07-18 03:24 · Seedance参数修复与热更状态
|
||||||
|
|
||||||
|
已修复 Seedance 新接口参数并真实生成成功,确认水印来自上游渠道且请求未发送 watermark。此前 token 进度条与本地 session 统计已改并发布热更 1.5.22,目标仓库为 mhgmh/codex-market-app,backup-bot 仅作上传账号。
|
||||||
|
|
||||||
|
**已确定的决策**:
|
||||||
|
- 热更目标仓库是 mhgmh/codex-market-app
|
||||||
|
- backup-bot 仅作为上传认证账号
|
||||||
|
- 热更 minAppVersion 固定为 1.5.0
|
||||||
|
- Seedance 使用 /v1/video/generations
|
||||||
|
- Seedance 模型当前为 seedance2.0
|
||||||
|
- 水印由上游烧录,客户端不加水印
|
||||||
|
|
||||||
**下一步**:
|
**下一步**:
|
||||||
- 用户在另一台电脑安装1.5.14验证
|
- 确认上游是否支持 watermark:false
|
||||||
- 若仍403检查所选生图分组权限
|
- 如需上线 Seedance 修复,发布新热更
|
||||||
|
- 必要时实测无水印参数一次
|
||||||
|
|
||||||
**涉及文件**:electron/lib/router.js、package.json、package-lock.json、scripts/publish-hot.js、electron-builder.yml
|
**涉及文件**:electron/lib/token_inject.js、electron/lib/token_usage.js、electron/lib/token_watch.js、renderer/index.html、renderer/styles.css、renderer/app.js、CANVAS_SPEC.md、AGENTS.md
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 322 KiB After Width: | Height: | Size: 198 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 322 KiB |
+162
-43
@@ -48,6 +48,17 @@ function grokDebugLog(entry) {
|
|||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function seedanceDebugLog(entry) {
|
||||||
|
try {
|
||||||
|
const dir = path.join(os.homedir(), "Library", "Application Support", "codex-market");
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
fs.appendFileSync(
|
||||||
|
path.join(dir, "seedance-video-debug.jsonl"),
|
||||||
|
JSON.stringify({ time: new Date().toISOString(), ...entry }) + "\n"
|
||||||
|
);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
function systemProxyUrl() {
|
function systemProxyUrl() {
|
||||||
const envProxy = process.env.HTTPS_PROXY || process.env.https_proxy || process.env.HTTP_PROXY || process.env.http_proxy || "";
|
const envProxy = process.env.HTTPS_PROXY || process.env.https_proxy || process.env.HTTP_PROXY || process.env.http_proxy || "";
|
||||||
if (envProxy) return envProxy;
|
if (envProxy) return envProxy;
|
||||||
@@ -81,7 +92,7 @@ function proxyAgentFor(targetUrl, proxyUrl) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function requestBuffer(url, agent) {
|
function requestBuffer(url, agent, extraHeaders = {}) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
let u;
|
let u;
|
||||||
try {
|
try {
|
||||||
@@ -95,13 +106,13 @@ function requestBuffer(url, agent) {
|
|||||||
url,
|
url,
|
||||||
{
|
{
|
||||||
agent,
|
agent,
|
||||||
headers: { "User-Agent": "codex-market" },
|
headers: { "User-Agent": "codex-market", ...extraHeaders },
|
||||||
},
|
},
|
||||||
(res) => {
|
(res) => {
|
||||||
const loc = res.headers.location;
|
const loc = res.headers.location;
|
||||||
if (res.statusCode >= 300 && res.statusCode < 400 && loc) {
|
if (res.statusCode >= 300 && res.statusCode < 400 && loc) {
|
||||||
res.resume();
|
res.resume();
|
||||||
requestBuffer(new URL(loc, url).toString(), agent).then(resolve, reject);
|
requestBuffer(new URL(loc, url).toString(), agent, extraHeaders).then(resolve, reject);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (res.statusCode < 200 || res.statusCode >= 300) {
|
if (res.statusCode < 200 || res.statusCode >= 300) {
|
||||||
@@ -120,14 +131,14 @@ function requestBuffer(url, agent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// —— 下载任意远程资源 → base64 字符串 ——
|
// —— 下载任意远程资源 → base64 字符串 ——
|
||||||
async function downloadAsBase64(url) {
|
async function downloadAsBase64(url, extraHeaders = {}) {
|
||||||
const proxyUrl = systemProxyUrl();
|
const proxyUrl = systemProxyUrl();
|
||||||
let proxyErr = "";
|
let proxyErr = "";
|
||||||
if (proxyUrl) {
|
if (proxyUrl) {
|
||||||
const agent = proxyAgentFor(url, proxyUrl);
|
const agent = proxyAgentFor(url, proxyUrl);
|
||||||
if (agent) {
|
if (agent) {
|
||||||
try {
|
try {
|
||||||
return (await requestBuffer(url, agent)).toString("base64");
|
return (await requestBuffer(url, agent, extraHeaders)).toString("base64");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
proxyErr = String((e && e.message) || e);
|
proxyErr = String((e && e.message) || e);
|
||||||
// 代理下载失败时再直连兜底,保留原行为。
|
// 代理下载失败时再直连兜底,保留原行为。
|
||||||
@@ -137,7 +148,7 @@ async function downloadAsBase64(url) {
|
|||||||
const ctrl = new AbortController();
|
const ctrl = new AbortController();
|
||||||
const timer = setTimeout(() => ctrl.abort(), 120000);
|
const timer = setTimeout(() => ctrl.abort(), 120000);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(url, { headers: { "User-Agent": "codex-market" }, signal: ctrl.signal });
|
const res = await fetch(url, { headers: { "User-Agent": "codex-market", ...extraHeaders }, signal: ctrl.signal });
|
||||||
if (!res.ok) throw new Error("下载失败 HTTP " + res.status);
|
if (!res.ok) throw new Error("下载失败 HTTP " + res.status);
|
||||||
const buf = Buffer.from(await res.arrayBuffer());
|
const buf = Buffer.from(await res.arrayBuffer());
|
||||||
return buf.toString("base64");
|
return buf.toString("base64");
|
||||||
@@ -149,6 +160,18 @@ async function downloadAsBase64(url) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function trustedDownloadHeaders(url, llm) {
|
||||||
|
let host = "";
|
||||||
|
try {
|
||||||
|
host = new URL(url).host.toLowerCase();
|
||||||
|
} catch {}
|
||||||
|
if (!host || !llm || !llm.key) return {};
|
||||||
|
if (host === "api.tokendun.com" || host.endsWith(".tokendun.com") || host === "pulseaify.com" || host.endsWith(".pulseaify.com")) {
|
||||||
|
return { Authorization: "Bearer " + llm.key };
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
// —— 组装 data URL ——
|
// —— 组装 data URL ——
|
||||||
function toDataUrl(mime, b64) {
|
function toDataUrl(mime, b64) {
|
||||||
return `data:${mime};base64,${b64}`;
|
return `data:${mime};base64,${b64}`;
|
||||||
@@ -399,9 +422,39 @@ async function runTts(base, llm, params) {
|
|||||||
|
|
||||||
// —— 递归深挖视频地址(覆盖 result_url、data.content.video_url、data.data.content.video_url 等嵌套)——
|
// —— 递归深挖视频地址(覆盖 result_url、data.content.video_url、data.data.content.video_url 等嵌套)——
|
||||||
// port 自 提示词提取/server.js
|
// port 自 提示词提取/server.js
|
||||||
function findVideoUrl(o) {
|
function isLikelyVideoUrl(u) {
|
||||||
|
try {
|
||||||
|
const p = new URL(u).pathname.toLowerCase();
|
||||||
|
return /\.(mp4|mov|webm|m4v)(?:$|\.)/.test(p) || p.endsWith(".m3u8");
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function findLikelyVideoUrl(o) {
|
||||||
if (!o) return "";
|
if (!o) return "";
|
||||||
if (typeof o === "string") return /^https?:\/\//.test(o) ? o : "";
|
if (typeof o === "string") return /^https?:\/\//.test(o) && isLikelyVideoUrl(o) ? o : "";
|
||||||
|
if (Array.isArray(o)) {
|
||||||
|
for (const v of o) {
|
||||||
|
const u = findLikelyVideoUrl(v);
|
||||||
|
if (u) return u;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
if (typeof o === "object") {
|
||||||
|
for (const v of Object.values(o)) {
|
||||||
|
const u = findLikelyVideoUrl(v);
|
||||||
|
if (u) return u;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function findVideoUrl(o) {
|
||||||
|
const likely = findLikelyVideoUrl(o);
|
||||||
|
if (likely) return likely;
|
||||||
|
if (!o) return "";
|
||||||
|
if (typeof o === "string") return "";
|
||||||
if (Array.isArray(o)) {
|
if (Array.isArray(o)) {
|
||||||
for (const v of o) {
|
for (const v of o) {
|
||||||
const u = findVideoUrl(v);
|
const u = findVideoUrl(v);
|
||||||
@@ -410,7 +463,7 @@ function findVideoUrl(o) {
|
|||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
if (typeof o === "object") {
|
if (typeof o === "object") {
|
||||||
for (const k of ["video_url", "result_url", "url"]) {
|
for (const k of ["video_url", "result_url", "content_url", "download_url", "file_url", "output_url"]) {
|
||||||
const v = o[k];
|
const v = o[k];
|
||||||
if (typeof v === "string" && /^https?:\/\//.test(v)) return v;
|
if (typeof v === "string" && /^https?:\/\//.test(v)) return v;
|
||||||
}
|
}
|
||||||
@@ -432,13 +485,16 @@ function normalizeVideoStatus(raw) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function joinUrl(base, p) {
|
function joinUrl(base, p) {
|
||||||
return trimSlash(base) + (String(p || "").startsWith("/") ? p : "/" + p);
|
const root = trimSlash(base);
|
||||||
|
const suffix = String(p || "").startsWith("/") ? String(p || "") : "/" + String(p || "");
|
||||||
|
if (/\/v1$/i.test(root) && /^\/v1(?:\/|$)/i.test(suffix)) return root + suffix.slice(3);
|
||||||
|
return root + suffix;
|
||||||
}
|
}
|
||||||
|
|
||||||
function pickTaskId(o) {
|
function pickTaskId(o) {
|
||||||
if (!o || typeof o !== "object") return "";
|
if (!o || typeof o !== "object") return "";
|
||||||
for (const k of ["request_id", "requestId", "task_id", "taskId", "id"]) {
|
for (const k of ["request_id", "requestId", "task_id", "taskId", "id"]) {
|
||||||
if (typeof o[k] === "string" && o[k]) return o[k];
|
if ((typeof o[k] === "string" || typeof o[k] === "number") && String(o[k])) return String(o[k]);
|
||||||
}
|
}
|
||||||
for (const k of ["data", "result", "task"]) {
|
for (const k of ["data", "result", "task"]) {
|
||||||
const v = o[k];
|
const v = o[k];
|
||||||
@@ -456,27 +512,52 @@ function pushRecentPoll(arr, value) {
|
|||||||
if (arr.length > 3) arr.shift();
|
if (arr.length > 3) arr.shift();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function resolveGrokImage(v) {
|
||||||
|
const s = String(v || "").trim();
|
||||||
|
if (!s) return "";
|
||||||
|
if (/^data:image\/[^;,]+;base64,/i.test(s)) return await uploadMedia(s);
|
||||||
|
if (/^data:/i.test(s)) throw new Error("Grok 首帧图需为 image/* 的 base64 data URL 或公网图片 URL");
|
||||||
|
if (/^https?:\/\//i.test(s)) return s;
|
||||||
|
throw new Error("Grok 首帧图需为 base64 data URL 或公网 http(s) 地址");
|
||||||
|
}
|
||||||
|
|
||||||
|
function grokSizeFromParams(params) {
|
||||||
|
const rawSize = String((params && params.size) || "").trim();
|
||||||
|
if (/^\d+x\d+$/i.test(rawSize)) return rawSize.toLowerCase();
|
||||||
|
const resolution = String((params && params.resolution) || "720p").trim().toLowerCase();
|
||||||
|
const shortSide = resolution === "1080p" ? 1080 : resolution === "480p" ? 480 : 720;
|
||||||
|
const roundEven = (n) => Math.max(2, Math.round(n / 2) * 2);
|
||||||
|
const ratio = String((params && (params.aspectRatio || params.ratio)) || "16:9").trim();
|
||||||
|
const match = ratio.match(/^(\d+)\s*:\s*(\d+)$/);
|
||||||
|
if (!match) return `${roundEven((shortSide * 16) / 9)}x${shortSide}`;
|
||||||
|
const rw = Math.max(1, parseInt(match[1], 10));
|
||||||
|
const rh = Math.max(1, parseInt(match[2], 10));
|
||||||
|
if (rw === rh) return `${shortSide}x${shortSide}`;
|
||||||
|
if (rw > rh) return `${roundEven((shortSide * rw) / rh)}x${shortSide}`;
|
||||||
|
return `${shortSide}x${roundEven((shortSide * rh) / rw)}`;
|
||||||
|
}
|
||||||
|
|
||||||
// —— Seedance/TokenDun 视频:异步任务制(建任务 → 轮询 → 下载成品转 base64)——
|
// —— Seedance/TokenDun 视频:异步任务制(建任务 → 轮询 → 下载成品转 base64)——
|
||||||
// mode: "text"(文生视频,只用 prompt) | "image"(图生视频)。model 须用户填写。
|
// mode: "text"(文生视频,只用 prompt) | "image"(图生视频)。model 可省略,使用上游默认。
|
||||||
// 图生视频素材两条来路:params.imageUrl(手填公网URL,优先) 或 params.image(上游 base64)。
|
// 图生视频素材两条来路:params.imageUrl(手填公网URL,优先) 或 params.image(上游 base64)。
|
||||||
// base64 会先经 uploadMedia() 换成公网直链再发上游(Seedance 绝不接受 base64)。
|
// base64 会先经 uploadMedia() 换成公网直链再发上游(Seedance 绝不接受 base64)。
|
||||||
async function runVideo(base, llm, params) {
|
async function runVideo(base, llm, params) {
|
||||||
const model = params.model;
|
const model = String(params.model || "").trim();
|
||||||
if (!model) return { ok: false, error: "请先填写视频模型" };
|
const createUrl = joinUrl(base, params.endpointPath || "/v1/video/generations");
|
||||||
const createUrl = base + (params.endpointPath || "/v1/video/generations");
|
|
||||||
// 输入模式:text/first/firstlast/reference/video;兼容旧值 image→first
|
// 输入模式:text/first/firstlast/reference/video;兼容旧值 image→first
|
||||||
let mode = String(params.mode || "text");
|
let mode = String(params.mode || "text");
|
||||||
if (mode === "image") mode = "first";
|
if (mode === "image") mode = "first";
|
||||||
|
|
||||||
// —— body 组装:严格照中转文档(pulseaify Seedance)——扁平字段,非火山 metadata 格式 ——
|
// —— body 组装:严格照中转文档(pulseaify Seedance)——扁平字段,非火山 metadata 格式 ——
|
||||||
// prompt 必填;duration 整数(5/10/15);ratio 顶层;quality 可选;async:true 走异步轮询
|
// prompt 必填;duration 整数(5/10/15);ratio 顶层;quality 可选;async:true 走异步轮询
|
||||||
const body = { model, prompt: (params.prompt || "").trim(), async: true };
|
const body = { prompt: (params.prompt || "").trim(), async: true };
|
||||||
|
if (model) body.model = model; // 文档允许省略,交给上游使用默认 video-v1
|
||||||
const dur = parseInt(params.seconds, 10);
|
const dur = parseInt(params.seconds, 10);
|
||||||
if (!isNaN(dur) && dur > 0) body.duration = dur; // -1/空/auto → 省略,用中转默认(10)
|
if ([5, 10, 15].includes(dur)) body.duration = dur; // -1/空/auto → 省略,用中转默认(10)
|
||||||
if (params.ratio && params.ratio !== "auto") body.ratio = params.ratio; // 顶层 ratio
|
if (params.ratio && params.ratio !== "auto") body.ratio = params.ratio;
|
||||||
// 注:文档的 quality 字段示例值是 "hd",与 UI 的 720p/1080p 不同类,未确认合法值前不下发,避免 400
|
if (params.quality && params.quality !== "auto") body.quality = params.quality;
|
||||||
try {
|
try {
|
||||||
// 参考图/素材:本中转支持 image(单图) / images(多图) / ref_assets;base64 先传兰空换公网URL
|
// 参考图/素材:本中转支持 image(单图) / images(多图) / ref_assets;base64 先传图床换公网 URL
|
||||||
if (mode === "first") {
|
if (mode === "first") {
|
||||||
const img = await resolveMat(params.imageUrl || params.image);
|
const img = await resolveMat(params.imageUrl || params.image);
|
||||||
if (!img) return { ok: false, error: "首帧图生视频需要首帧图:连上游文生图 / 上传 / 手填公网URL" };
|
if (!img) return { ok: false, error: "首帧图生视频需要首帧图:连上游文生图 / 上传 / 手填公网URL" };
|
||||||
@@ -492,7 +573,7 @@ async function runVideo(base, llm, params) {
|
|||||||
const refs = Array.isArray(params.referenceImages) ? params.referenceImages : [];
|
const refs = Array.isArray(params.referenceImages) ? params.referenceImages : [];
|
||||||
const resolved = (await Promise.all(refs.map((r) => resolveMat(r)))).filter(Boolean);
|
const resolved = (await Promise.all(refs.map((r) => resolveMat(r)))).filter(Boolean);
|
||||||
if (!resolved.length) return { ok: false, error: "多参考图模式需至少 1 张参考图" };
|
if (!resolved.length) return { ok: false, error: "多参考图模式需至少 1 张参考图" };
|
||||||
body.images = resolved;
|
body[params.referenceField === "ref_assets" ? "ref_assets" : "images"] = resolved;
|
||||||
} else if (mode === "video") {
|
} else if (mode === "video") {
|
||||||
return { ok: false, error: "该中转不支持「参考视频」模式,请改用 文生 / 首帧 / 多参考图" };
|
return { ok: false, error: "该中转不支持「参考视频」模式,请改用 文生 / 首帧 / 多参考图" };
|
||||||
}
|
}
|
||||||
@@ -500,6 +581,22 @@ async function runVideo(base, llm, params) {
|
|||||||
if (!body.prompt) return { ok: false, error: "请填写提示词(本接口文生/图生视频都需要 prompt)" };
|
if (!body.prompt) return { ok: false, error: "请填写提示词(本接口文生/图生视频都需要 prompt)" };
|
||||||
|
|
||||||
// —— 建任务 ——
|
// —— 建任务 ——
|
||||||
|
const debugBase = {
|
||||||
|
base: hostOf(base),
|
||||||
|
create_url: createUrl,
|
||||||
|
mode,
|
||||||
|
request: {
|
||||||
|
model: body.model || "<upstream-default>",
|
||||||
|
duration: body.duration || "",
|
||||||
|
ratio: body.ratio || "",
|
||||||
|
quality: body.quality || "",
|
||||||
|
async: body.async,
|
||||||
|
has_image: typeof body.image === "string" && /^https?:\/\//i.test(body.image),
|
||||||
|
image_count: Array.isArray(body.images) ? body.images.length : 0,
|
||||||
|
ref_asset_count: Array.isArray(body.ref_assets) ? body.ref_assets.length : 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
seedanceDebugLog({ type: "seedance_video_create", ...debugBase });
|
||||||
const cres = await fetch(createUrl, {
|
const cres = await fetch(createUrl, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: headers(llm.key),
|
headers: headers(llm.key),
|
||||||
@@ -507,21 +604,23 @@ async function runVideo(base, llm, params) {
|
|||||||
});
|
});
|
||||||
if (!cres.ok) {
|
if (!cres.ok) {
|
||||||
const detail = await cres.text().catch(() => "");
|
const detail = await cres.text().catch(() => "");
|
||||||
return { ok: false, error: `视频建任务失败(${cres.status}):${detail.slice(0, 300)}` };
|
seedanceDebugLog({ type: "seedance_video_create_failed", ...debugBase, status: cres.status, detail: detail.slice(0, 500) });
|
||||||
|
return { ok: false, error: `视频建任务失败(${cres.status}):POST ${createUrl} model=${body.model || "<默认>"} → ${detail.slice(0, 300)}` };
|
||||||
}
|
}
|
||||||
let data;
|
let data;
|
||||||
|
const rawCreate = await cres.text().catch(() => "");
|
||||||
try {
|
try {
|
||||||
data = await cres.json();
|
data = JSON.parse(rawCreate);
|
||||||
} catch {
|
} catch {
|
||||||
const detail = await cres.text().catch(() => "");
|
seedanceDebugLog({ type: "seedance_video_create_non_json", ...debugBase, detail: rawCreate.slice(0, 500) });
|
||||||
return { ok: false, error: `视频建任务返回非 JSON:${detail.slice(0, 300)}` };
|
return { ok: false, error: `视频建任务返回非 JSON:${rawCreate.slice(0, 300)}` };
|
||||||
}
|
}
|
||||||
const taskId =
|
const taskId = pickTaskId(data);
|
||||||
(data.data && (data.data.task_id || data.data.id)) ||
|
if (!taskId) {
|
||||||
data.task_id ||
|
seedanceDebugLog({ type: "seedance_video_missing_task_id", ...debugBase, response: JSON.stringify(data).slice(0, 500) });
|
||||||
data.id ||
|
return { ok: false, error: `视频建任务未返回任务ID:${JSON.stringify(data).slice(0, 300)}` };
|
||||||
(data.result && (data.result.task_id || data.result.id));
|
}
|
||||||
if (!taskId) return { ok: false, error: `视频建任务未返回任务ID:${JSON.stringify(data).slice(0, 300)}` };
|
seedanceDebugLog({ type: "seedance_video_accepted", ...debugBase, task_id: taskId });
|
||||||
|
|
||||||
// —— 轮询:每 5s,最多 120 次(~10min),对齐 提示词提取 ——
|
// —— 轮询:每 5s,最多 120 次(~10min),对齐 提示词提取 ——
|
||||||
// poll 是 GET:只带 Authorization(不要带 Content-Type,个别网关对「GET 携带 body 类头」会返回非 2xx 而被误跳过)
|
// poll 是 GET:只带 Authorization(不要带 Content-Type,个别网关对「GET 携带 body 类头」会返回非 2xx 而被误跳过)
|
||||||
@@ -548,16 +647,25 @@ async function runVideo(base, llm, params) {
|
|||||||
if (st === "succeeded") {
|
if (st === "succeeded") {
|
||||||
const url = findVideoUrl(pd);
|
const url = findVideoUrl(pd);
|
||||||
if (!url) return { ok: false, error: `视频任务完成但未找到视频地址:${JSON.stringify(pd).slice(0, 300)}` };
|
if (!url) return { ok: false, error: `视频任务完成但未找到视频地址:${JSON.stringify(pd).slice(0, 300)}` };
|
||||||
const b64 = await downloadAsBase64(url);
|
let b64;
|
||||||
|
try {
|
||||||
|
b64 = await downloadAsBase64(url, trustedDownloadHeaders(url, llm));
|
||||||
|
} catch (e) {
|
||||||
|
seedanceDebugLog({ type: "seedance_video_download_failed", ...debugBase, task_id: taskId, status: st, url, error: String((e && e.message) || e) });
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
seedanceDebugLog({ type: "seedance_video_done", ...debugBase, task_id: taskId, status: st, url });
|
||||||
return { ok: true, mime: "video/mp4", dataUrl: toDataUrl("video/mp4", b64), url, _model: model };
|
return { ok: true, mime: "video/mp4", dataUrl: toDataUrl("video/mp4", b64), url, _model: model };
|
||||||
}
|
}
|
||||||
if (st === "failed") {
|
if (st === "failed") {
|
||||||
const err = (d && d.error && (d.error.message || d.error)) || (d && (d.fail_reason || d.message || d.reason)) || (pd && pd.message) || st;
|
const err = (d && d.error && (d.error.message || d.error)) || (d && (d.fail_reason || d.message || d.reason)) || (pd && pd.message) || st;
|
||||||
const raw = JSON.stringify(pd).slice(0, 500);
|
const raw = JSON.stringify(pd).slice(0, 500);
|
||||||
|
seedanceDebugLog({ type: "seedance_video_failed", ...debugBase, task_id: taskId, status: st, response: raw });
|
||||||
return { ok: false, error: "视频任务失败:" + (typeof err === "string" ? err : JSON.stringify(err)) + "\n上游原始返回:" + raw };
|
return { ok: false, error: "视频任务失败:" + (typeof err === "string" ? err : JSON.stringify(err)) + "\n上游原始返回:" + raw };
|
||||||
}
|
}
|
||||||
// queued/running → 继续等
|
// queued/running → 继续等
|
||||||
}
|
}
|
||||||
|
seedanceDebugLog({ type: "seedance_video_timeout", ...debugBase, task_id: taskId, last_poll: lastPoll || "" });
|
||||||
return { ok: false, error: "视频轮询 10 分钟未完成(taskId=" + taskId + ",最近一次轮询:" + (lastPoll || "无") + ")" };
|
return { ok: false, error: "视频轮询 10 分钟未完成(taskId=" + taskId + ",最近一次轮询:" + (lastPoll || "无") + ")" };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return { ok: false, error: "视频生成异常:" + String((e && e.message) || e) };
|
return { ok: false, error: "视频生成异常:" + String((e && e.message) || e) };
|
||||||
@@ -566,37 +674,39 @@ async function runVideo(base, llm, params) {
|
|||||||
|
|
||||||
// —— Grok 视频:独立新增接口,按 /v1/videos/generations + /v1/videos/{request_id} 调用 ——
|
// —— Grok 视频:独立新增接口,按 /v1/videos/generations + /v1/videos/{request_id} 调用 ——
|
||||||
async function runGrokVideo(base, llm, params) {
|
async function runGrokVideo(base, llm, params) {
|
||||||
let model = params.model || "grok-imagine-video";
|
let model = params.model || "grok-video-1.5";
|
||||||
const prompt = (params.prompt || "").trim();
|
const prompt = (params.prompt || "").trim();
|
||||||
if (!prompt) return { ok: false, error: "请填写 Grok 视频提示词" };
|
if (!prompt) return { ok: false, error: "请填写 Grok 视频提示词" };
|
||||||
|
|
||||||
const body = { model, prompt };
|
const seconds = String(params.seconds || "6").trim();
|
||||||
if (params.resolution && params.resolution !== "auto") body.resolution = params.resolution;
|
const body = { model, prompt, seconds, size: grokSizeFromParams(params) };
|
||||||
const dur = parseInt(params.seconds, 10);
|
|
||||||
if (!isNaN(dur) && dur > 0) body.duration = dur;
|
|
||||||
|
|
||||||
let mode = String(params.mode || "text");
|
let mode = String(params.mode || "text");
|
||||||
if (mode === "image") mode = "first";
|
if (mode === "image") mode = "first";
|
||||||
try {
|
try {
|
||||||
if (
|
if (
|
||||||
mode === "text" &&
|
mode === "text" &&
|
||||||
/grok-imagine-video-1\.5/i.test(model) &&
|
/grok-(?:imagine-)?video-1\.5/i.test(model) &&
|
||||||
body.resolution === "1080p" &&
|
body.size === "1920x1080" &&
|
||||||
Number(body.duration) === 15
|
Number(body.seconds) === 15
|
||||||
) {
|
) {
|
||||||
return { ok: false, error: "1.5 的 1080p/15s 需要首帧图;请改用首帧图生视频,或降低文生视频规格" };
|
return { ok: false, error: "1.5 的 1080p/15s 需要首帧图;请改用首帧图生视频,或降低文生视频规格" };
|
||||||
}
|
}
|
||||||
if (mode === "first") {
|
if (mode === "first") {
|
||||||
const img = await resolveMat(params.imageUrl || params.image);
|
const img = await resolveGrokImage(params.imageUrl || params.image);
|
||||||
if (!img) return { ok: false, error: "Grok 图生视频需要首帧图:上传 / 手填公网URL / 连接上游文生图" };
|
if (!img) return { ok: false, error: "Grok 图生视频需要首帧图:上传 / 手填公网URL / 连接上游文生图" };
|
||||||
body.image = { image_url: img };
|
body.image = { url: img };
|
||||||
} else if (mode === "reference") {
|
} else if (mode === "reference") {
|
||||||
const refs = Array.isArray(params.referenceImages) ? params.referenceImages : [];
|
const refs = Array.isArray(params.referenceImages) ? params.referenceImages : [];
|
||||||
const resolved = (await Promise.all(refs.map((r) => resolveMat(r)))).filter(Boolean);
|
const resolved = (await Promise.all(refs.map((r) => resolveMat(r)))).filter(Boolean);
|
||||||
if (!resolved.length) return { ok: false, error: "Grok 多参考图模式请至少提供 1 张参考图" };
|
if (!resolved.length) return { ok: false, error: "Grok 多参考图模式请至少提供 1 张参考图" };
|
||||||
model = "grok-imagine-video";
|
model = "grok-imagine-video";
|
||||||
body.model = model;
|
body.model = model;
|
||||||
|
delete body.size;
|
||||||
body.resolution = "720p";
|
body.resolution = "720p";
|
||||||
|
delete body.seconds;
|
||||||
|
const dur = parseInt(params.seconds, 10);
|
||||||
|
if (!isNaN(dur) && dur > 0) body.duration = dur;
|
||||||
if (!body.aspect_ratio) body.aspect_ratio = params.aspectRatio || "16:9";
|
if (!body.aspect_ratio) body.aspect_ratio = params.aspectRatio || "16:9";
|
||||||
body.reference_images = resolved.map((url) => ({ url }));
|
body.reference_images = resolved.map((url) => ({ url }));
|
||||||
} else if (mode !== "text") {
|
} else if (mode !== "text") {
|
||||||
@@ -611,10 +721,13 @@ async function runGrokVideo(base, llm, params) {
|
|||||||
request: {
|
request: {
|
||||||
model: body.model,
|
model: body.model,
|
||||||
prompt: body.prompt,
|
prompt: body.prompt,
|
||||||
|
seconds: body.seconds || "",
|
||||||
|
size: body.size || "",
|
||||||
resolution: body.resolution || "",
|
resolution: body.resolution || "",
|
||||||
duration: body.duration || "",
|
duration: body.duration || "",
|
||||||
aspect_ratio: body.aspect_ratio || "",
|
aspect_ratio: body.aspect_ratio || "",
|
||||||
has_image: !!body.image,
|
has_image: !!body.image,
|
||||||
|
image_is_url: !!(body.image && typeof body.image === "object" && typeof body.image.url === "string" && /^https?:\/\//i.test(body.image.url)),
|
||||||
reference_image_count: Array.isArray(body.reference_images) ? body.reference_images.length : 0,
|
reference_image_count: Array.isArray(body.reference_images) ? body.reference_images.length : 0,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -669,7 +782,13 @@ async function runGrokVideo(base, llm, params) {
|
|||||||
lastPoll = "status=succeeded,但暂未返回视频地址";
|
lastPoll = "status=succeeded,但暂未返回视频地址";
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const b64 = await downloadAsBase64(url);
|
let b64;
|
||||||
|
try {
|
||||||
|
b64 = await downloadAsBase64(url, trustedDownloadHeaders(url, llm));
|
||||||
|
} catch (e) {
|
||||||
|
grokDebugLog({ type: "grok_video_download_failed", ...debugBase, request_id: requestId, status: st, url, error: String((e && e.message) || e) });
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
grokDebugLog({ type: "grok_video_done", ...debugBase, request_id: requestId, status: st, url });
|
grokDebugLog({ type: "grok_video_done", ...debugBase, request_id: requestId, status: st, url });
|
||||||
return { ok: true, mime: "video/mp4", dataUrl: toDataUrl("video/mp4", b64), url, _model: model };
|
return { ok: true, mime: "video/mp4", dataUrl: toDataUrl("video/mp4", b64), url, _model: model };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,259 @@
|
|||||||
|
// claudedesk.js —— 把中转分组接入 Claude 桌面 App(图形客户端),免登录直用中转。
|
||||||
|
// 关键:走 App 自己的「已保存配置库」(configLibrary),不是托管偏好(defaults/.mobileconfig)。
|
||||||
|
// 托管偏好那条路是企业 MDM 通道,会强制 SSO 登录,个人账号卡登录页 —— 早期踩过坑,已弃用。
|
||||||
|
// 做法对照 cc-switch 源码 src-tauri/src/claude_desktop_config.rs(farion1231/cc-switch)核实。
|
||||||
|
// 详见 memory/claude-desktop-3p-config.md。
|
||||||
|
"use strict";
|
||||||
|
const fs = require("fs");
|
||||||
|
const os = require("os");
|
||||||
|
const path = require("path");
|
||||||
|
const router = require("./router");
|
||||||
|
|
||||||
|
const PLATFORM = process.platform; // 'darwin' | 'win32' | ...
|
||||||
|
const CONFIG_FILE = "claude_desktop_config.json";
|
||||||
|
const CONFIG_LIBRARY_DIR = "configLibrary";
|
||||||
|
const PROFILE_ID = "00000000-0000-4000-8000-000000157210";
|
||||||
|
const PROFILE_NAME = "TokenDun";
|
||||||
|
// 备份根:复用现有 ~/.codex/backups_state 约定
|
||||||
|
const BACKUP_DIR = path.join(os.homedir(), ".codex", "backups_state", "claudedesk", "official");
|
||||||
|
|
||||||
|
// —— 平台路径 ——
|
||||||
|
// 返回 { normalConfig, threepConfig, libDir, profilePath, metaPath } 或 null(不支持)
|
||||||
|
function platformPaths() {
|
||||||
|
if (PLATFORM === "darwin") {
|
||||||
|
const appSupport = path.join(os.homedir(), "Library", "Application Support");
|
||||||
|
return dirsToPaths(path.join(appSupport, "Claude"), path.join(appSupport, "Claude-3p"));
|
||||||
|
}
|
||||||
|
if (PLATFORM === "win32") {
|
||||||
|
const localAppData = process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local");
|
||||||
|
return dirsToPaths(pickWinDir(localAppData, "Claude"), pickWinDir(localAppData, "Claude-3p"));
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// Windows 下 App 目录名可能带后缀,先精确后模糊匹配
|
||||||
|
function pickWinDir(base, exact) {
|
||||||
|
const p = path.join(base, exact);
|
||||||
|
if (fs.existsSync(p)) return p;
|
||||||
|
try {
|
||||||
|
const hit = fs.readdirSync(base).find((n) => {
|
||||||
|
const full = path.join(base, n);
|
||||||
|
const isDir = (() => { try { return fs.statSync(full).isDirectory(); } catch { return false; } })();
|
||||||
|
if (!isDir) return false;
|
||||||
|
return exact === "Claude-3p" ? /claude.*3p/i.test(n) : (/claude/i.test(n) && !/3p/i.test(n));
|
||||||
|
});
|
||||||
|
if (hit) return path.join(base, hit);
|
||||||
|
} catch {}
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
function dirsToPaths(normalDir, threepDir) {
|
||||||
|
const libDir = path.join(threepDir, CONFIG_LIBRARY_DIR);
|
||||||
|
return {
|
||||||
|
normalConfig: path.join(normalDir, CONFIG_FILE),
|
||||||
|
threepConfig: path.join(threepDir, CONFIG_FILE),
|
||||||
|
libDir,
|
||||||
|
profilePath: path.join(libDir, `${PROFILE_ID}.json`),
|
||||||
|
metaPath: path.join(libDir, "_meta.json"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// —— JSON 读写小工具 ——
|
||||||
|
const readJson = (p) => { try { return JSON.parse(fs.readFileSync(p, "utf-8")); } catch { return null; } };
|
||||||
|
const readJsonOrEmpty = (p) => { const v = readJson(p); return v && typeof v === "object" && !Array.isArray(v) ? v : {}; };
|
||||||
|
function writeJson(p, obj) {
|
||||||
|
fs.mkdirSync(path.dirname(p), { recursive: true });
|
||||||
|
fs.writeFileSync(p, JSON.stringify(obj, null, 2) + "\n");
|
||||||
|
}
|
||||||
|
const isSupported = () => PLATFORM === "darwin" || PLATFORM === "win32";
|
||||||
|
|
||||||
|
// —— gateway profile 本体(写进 configLibrary/<id>.json)——
|
||||||
|
function buildGatewayProfile(group) {
|
||||||
|
const oneM = new Set(Array.isArray(group.oneMillionModels) ? group.oneMillionModels : []);
|
||||||
|
const profile = {
|
||||||
|
coworkEgressAllowedHosts: ["*"],
|
||||||
|
disableDeploymentModeChooser: true,
|
||||||
|
inferenceGatewayApiKey: group.key || "",
|
||||||
|
inferenceGatewayAuthScheme: "bearer",
|
||||||
|
inferenceGatewayBaseUrl: String(group.baseUrl || "").replace(/\/+$/, ""),
|
||||||
|
inferenceProvider: "gateway",
|
||||||
|
};
|
||||||
|
if (group.model) {
|
||||||
|
const item = { id: group.model };
|
||||||
|
if (oneM.has(group.model)) item.supports1m = true; // 1M 长上下文标记(不是 beta 头)
|
||||||
|
profile.inferenceModels = [item];
|
||||||
|
}
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
// —— deploymentMode 写入(就地改,不动其它字段,尤其保留官方账号绑定 preferences)——
|
||||||
|
function writeDeploymentMode(p, mode) {
|
||||||
|
const v = readJsonOrEmpty(p);
|
||||||
|
v.deploymentMode = mode;
|
||||||
|
writeJson(p, v);
|
||||||
|
}
|
||||||
|
|
||||||
|
// —— _meta.json:登记/清除本 profile ——
|
||||||
|
function writeMeta(metaPath, applied) {
|
||||||
|
const v = readJsonOrEmpty(metaPath);
|
||||||
|
let entries = Array.isArray(v.entries) ? v.entries : [];
|
||||||
|
entries = entries.filter((e) => !(e && e.id === PROFILE_ID)); // 去重
|
||||||
|
if (applied) {
|
||||||
|
entries.push({ id: PROFILE_ID, name: PROFILE_NAME });
|
||||||
|
v.appliedId = PROFILE_ID;
|
||||||
|
} else if (v.appliedId === PROFILE_ID) {
|
||||||
|
// 取消:若当前 applied 指向本 profile,切到剩余任一或清空
|
||||||
|
const next = entries.find((e) => e && e.id);
|
||||||
|
if (next) v.appliedId = next.id; else delete v.appliedId;
|
||||||
|
}
|
||||||
|
v.entries = entries;
|
||||||
|
writeJson(metaPath, v);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 备份 / 还原(防「回不去官方登录」)—— 幂等 write-once 快照那 4 个文件
|
||||||
|
// ============================================================================
|
||||||
|
const SNAP_FILES = (p) => [
|
||||||
|
["normalConfig", p.normalConfig],
|
||||||
|
["threepConfig", p.threepConfig],
|
||||||
|
["profile", p.profilePath],
|
||||||
|
["meta", p.metaPath],
|
||||||
|
];
|
||||||
|
|
||||||
|
function snapshotOfficial() {
|
||||||
|
fs.mkdirSync(BACKUP_DIR, { recursive: true });
|
||||||
|
const marker = path.join(BACKUP_DIR, "done.json");
|
||||||
|
if (fs.existsSync(marker)) return { firstTime: false, dir: BACKUP_DIR };
|
||||||
|
const p = platformPaths();
|
||||||
|
const meta = { ts: Date.now(), platform: PLATFORM, files: {} };
|
||||||
|
if (p) {
|
||||||
|
for (const [key, src] of SNAP_FILES(p)) {
|
||||||
|
const dst = path.join(BACKUP_DIR, key + ".json");
|
||||||
|
if (fs.existsSync(src)) {
|
||||||
|
try { fs.copyFileSync(src, dst); meta.files[key] = { existed: true }; try { fs.chmodSync(dst, 0o600); } catch {} }
|
||||||
|
catch { meta.files[key] = { existed: true, copyFailed: true }; }
|
||||||
|
} else {
|
||||||
|
meta.files[key] = { existed: false }; // 记录原本不存在 → 还原时删除
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fs.writeFileSync(marker, JSON.stringify(meta, null, 2));
|
||||||
|
return { firstTime: true, dir: BACKUP_DIR };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 还原官方:把中转配置抹掉,deploymentMode 回 1p,App 恢复官方账号登录。
|
||||||
|
// 优先用首份快照精确复原;无快照则按规则清理(删 profile、meta 清 entry、config 回 1p)。
|
||||||
|
function restoreOfficial() {
|
||||||
|
if (!isSupported()) return { ok: false, error: "当前系统不支持(仅 macOS / Windows)" };
|
||||||
|
const p = platformPaths();
|
||||||
|
if (!p) return { ok: false, error: "无法定位 Claude 桌面 App 配置目录" };
|
||||||
|
const marker = path.join(BACKUP_DIR, "done.json");
|
||||||
|
const meta = readJson(marker);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (meta && meta.files) {
|
||||||
|
// 精确复原:有备份写回,标记「原本不存在」的删掉
|
||||||
|
for (const [key, src] of SNAP_FILES(p)) {
|
||||||
|
const info = meta.files[key] || {};
|
||||||
|
const bak = path.join(BACKUP_DIR, key + ".json");
|
||||||
|
if (info.existed && fs.existsSync(bak)) {
|
||||||
|
fs.mkdirSync(path.dirname(src), { recursive: true });
|
||||||
|
fs.copyFileSync(bak, src);
|
||||||
|
} else if (!info.existed && fs.existsSync(src)) {
|
||||||
|
try { fs.unlinkSync(src); } catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 无备份:按规则清理回官方
|
||||||
|
writeDeploymentMode(p.normalConfig, "1p");
|
||||||
|
writeDeploymentMode(p.threepConfig, "1p");
|
||||||
|
if (fs.existsSync(p.profilePath)) { try { fs.unlinkSync(p.profilePath); } catch {} }
|
||||||
|
writeMeta(p.metaPath, null);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, error: "还原失败:" + (e.message || e) };
|
||||||
|
}
|
||||||
|
return { ok: true, message: "已还原 Claude 官方配置,请重启 Claude 桌面 App(会回到账号登录)" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 一键接入(写 configLibrary,免登录)
|
||||||
|
// ============================================================================
|
||||||
|
function applyGroup(groupId) {
|
||||||
|
if (!isSupported()) return { ok: false, error: "当前系统暂不支持(仅 macOS / Windows)" };
|
||||||
|
const g = router.groupById(groupId);
|
||||||
|
if (!g) return { ok: false, error: "未找到该分组" };
|
||||||
|
if (g.isDefault) return { ok: false, error: "默认直连分组不能接入" };
|
||||||
|
if (!g.baseUrl || !g.key) return { ok: false, error: "该分组缺少 API 地址或密钥" };
|
||||||
|
if ((g.mode || "responses") !== "claude") {
|
||||||
|
return { ok: false, error: "Claude 桌面 App 只接受 Anthropic 协议(/v1/messages)分组,请把该分组的接口类型改成 Claude Code" };
|
||||||
|
}
|
||||||
|
const p = platformPaths();
|
||||||
|
if (!p) return { ok: false, error: "无法定位 Claude 桌面 App 配置目录(未安装?)" };
|
||||||
|
|
||||||
|
// 写前强制备份官方现状(幂等 write-once)
|
||||||
|
let backup;
|
||||||
|
try { backup = snapshotOfficial(); } catch (e) { return { ok: false, error: "备份官方配置失败:" + (e.message || e) }; }
|
||||||
|
|
||||||
|
// 失败回滚:先记住本次要写的 4 个文件的当前内容
|
||||||
|
const rollback = SNAP_FILES(p).map(([k, f]) => ({ f, content: fs.existsSync(f) ? fs.readFileSync(f) : null }));
|
||||||
|
try {
|
||||||
|
const profile = buildGatewayProfile(g);
|
||||||
|
writeDeploymentMode(p.normalConfig, "3p");
|
||||||
|
writeDeploymentMode(p.threepConfig, "3p");
|
||||||
|
writeJson(p.profilePath, profile);
|
||||||
|
writeMeta(p.metaPath, true);
|
||||||
|
} catch (e) {
|
||||||
|
// 回滚
|
||||||
|
for (const r of rollback) {
|
||||||
|
try { if (r.content === null) { if (fs.existsSync(r.f)) fs.unlinkSync(r.f); } else fs.writeFileSync(r.f, r.content); } catch {}
|
||||||
|
}
|
||||||
|
return { ok: false, error: "写入 Claude 桌面配置失败:" + (e.message || e) };
|
||||||
|
}
|
||||||
|
const has1m = Array.isArray(g.oneMillionModels) && g.model && g.oneMillionModels.includes(g.model);
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
firstTimeBackup: backup.firstTime,
|
||||||
|
has1m: !!has1m,
|
||||||
|
message: "已接入 Claude 桌面 App,请重启 App 生效(无需登录,直接用中转)。官方配置已备份,可随时一键还原。",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出 profile JSON(供高级用户手动放置 / 备份)
|
||||||
|
function exportConfig(groupId, format) {
|
||||||
|
const g = router.groupById(groupId);
|
||||||
|
if (!g) return { ok: false, error: "未找到该分组" };
|
||||||
|
if (!g.baseUrl || !g.key) return { ok: false, error: "该分组缺少 API 地址或密钥" };
|
||||||
|
const profile = buildGatewayProfile(g);
|
||||||
|
const content = JSON.stringify(profile, null, 2) + "\n";
|
||||||
|
return { ok: true, content, filename: `${PROFILE_ID}.json`, ext: "json" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 复制片段(configLibrary profile 内容)
|
||||||
|
function snippet(groupId) {
|
||||||
|
const r = exportConfig(groupId, "json");
|
||||||
|
return r.ok ? { ok: true, text: r.content } : r;
|
||||||
|
}
|
||||||
|
|
||||||
|
function status() {
|
||||||
|
const marker = path.join(BACKUP_DIR, "done.json");
|
||||||
|
const meta = readJson(marker);
|
||||||
|
const p = platformPaths();
|
||||||
|
let applied = false;
|
||||||
|
if (p) {
|
||||||
|
const cfg = readJson(p.threepConfig) || readJson(p.normalConfig);
|
||||||
|
const m = readJson(p.metaPath);
|
||||||
|
applied = !!(cfg && cfg.deploymentMode === "3p" && m && m.appliedId === PROFILE_ID);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
supported: isSupported(),
|
||||||
|
platform: PLATFORM,
|
||||||
|
hasBackup: !!meta,
|
||||||
|
backupTs: meta ? meta.ts : 0,
|
||||||
|
currentApplied: applied,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
status, applyGroup, restoreOfficial, exportConfig, snippet,
|
||||||
|
// 供测试
|
||||||
|
buildGatewayProfile, platformPaths, snapshotOfficial, PROFILE_ID,
|
||||||
|
};
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
// prompt-analysis.js —— 图片反推生图提示词(移植自「提示词提取」项目)
|
||||||
|
// LLM 通道走本应用的 router 分组(直接读中转分组列表),与 team.js 同模式。
|
||||||
|
"use strict";
|
||||||
|
const router = require("./router");
|
||||||
|
const { SYSTEM_PROMPT, USER_INSTRUCTION } = require("./team-prompt");
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 以下 <CORE> 自 提示词提取/server.js 原样提取(extractJson / callChat),未改动逻辑。
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
function extractJson(text) {
|
||||||
|
if (!text) throw new Error('模型返回为空');
|
||||||
|
let t = text.trim();
|
||||||
|
const fence = t.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
||||||
|
if (fence) t = fence[1].trim();
|
||||||
|
const start = t.indexOf('{');
|
||||||
|
const end = t.lastIndexOf('}');
|
||||||
|
if (start !== -1 && end !== -1 && end > start) t = t.slice(start, end + 1);
|
||||||
|
|
||||||
|
const tryParse = (s) => { try { return JSON.parse(s); } catch (_) { return undefined; } };
|
||||||
|
let r = tryParse(t);
|
||||||
|
if (r !== undefined) return r;
|
||||||
|
r = tryParse(t.replace(/,\s*([}\]])/g, '$1'));
|
||||||
|
if (r !== undefined) return r;
|
||||||
|
throw new Error('模型输出的 JSON 格式有误,请重试');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 流式调用:空闲超时——120 秒收不到任何数据就判定挂起并中止
|
||||||
|
const CHAT_IDLE_MS = 120000;
|
||||||
|
function chatTimeoutError() {
|
||||||
|
return Object.assign(new Error('模型长时间无响应(可能渠道不稳或模型太慢),请重试或换个更快的模型'), { status: 504 });
|
||||||
|
}
|
||||||
|
async function callChat(gwBase, apiKey, model, messages, maxTokens) {
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
let idle;
|
||||||
|
const resetIdle = () => { clearTimeout(idle); idle = setTimeout(() => ctrl.abort(), CHAT_IDLE_MS); };
|
||||||
|
resetIdle();
|
||||||
|
|
||||||
|
let upstream;
|
||||||
|
try {
|
||||||
|
upstream = await fetch(`${gwBase}/chat/completions`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
|
||||||
|
body: JSON.stringify({ model, temperature: 0.4, max_tokens: maxTokens, messages, stream: true }),
|
||||||
|
signal: ctrl.signal,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
clearTimeout(idle);
|
||||||
|
if (e.name === 'AbortError') throw chatTimeoutError();
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
if (!upstream.ok) {
|
||||||
|
clearTimeout(idle);
|
||||||
|
const detail = await upstream.text().catch(() => '');
|
||||||
|
const err = new Error(`上游模型调用失败 (${upstream.status})`);
|
||||||
|
err.status = upstream.status === 401 ? 401 : 502;
|
||||||
|
err.detail = detail.slice(0, 300);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = upstream.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buf = '';
|
||||||
|
let content = '';
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
resetIdle();
|
||||||
|
buf += decoder.decode(value, { stream: true });
|
||||||
|
let nl;
|
||||||
|
while ((nl = buf.indexOf('\n')) >= 0) {
|
||||||
|
const line = buf.slice(0, nl).trim();
|
||||||
|
buf = buf.slice(nl + 1);
|
||||||
|
if (!line.startsWith('data:')) continue;
|
||||||
|
const data = line.slice(5).trim();
|
||||||
|
if (!data || data === '[DONE]') continue;
|
||||||
|
try {
|
||||||
|
const j = JSON.parse(data);
|
||||||
|
const delta = j.choices?.[0]?.delta;
|
||||||
|
if (delta?.content) content += delta.content;
|
||||||
|
else if (j.choices?.[0]?.message?.content) content += j.choices[0].message.content;
|
||||||
|
} catch (_) { /* 跳过 keep-alive / 非 JSON 行 */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (e.name === 'AbortError') throw chatTimeoutError();
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(idle);
|
||||||
|
}
|
||||||
|
if (!content) throw new Error('模型未返回内容(可能超时或渠道异常)');
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 适配层:LLM 走 router 分组
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// 把分组 baseUrl 归一成「+ /chat/completions 即正确」的基址(对齐 team.js)
|
||||||
|
function chatBase(baseUrl) {
|
||||||
|
const base = String(baseUrl || "").replace(/\/+$/, "");
|
||||||
|
if (/\/chat\/completions$/.test(base)) return base.replace(/\/chat\/completions$/, "");
|
||||||
|
if (/\/v\d+$/.test(base)) return base;
|
||||||
|
return base + "/v1";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 取分组:优先按 id,否则用默认 LLM 分组
|
||||||
|
function resolveGroup(groupId) {
|
||||||
|
let g = null;
|
||||||
|
if (groupId != null && groupId !== "" && typeof router.groupById === "function") {
|
||||||
|
g = router.groupById(groupId);
|
||||||
|
}
|
||||||
|
if (!g && typeof router.llmGroup === "function") g = router.llmGroup();
|
||||||
|
if (!g) throw new Error("没有可用的中转分组,请先在「路由」里配置好带 baseUrl 和密钥的分组");
|
||||||
|
if (!g.baseUrl || !g.key) throw new Error(`分组「${g.name || g.id}」缺少 baseUrl 或密钥`);
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取所有可用分组列表(给前端下拉用)
|
||||||
|
function listGroups() {
|
||||||
|
try {
|
||||||
|
return router.usableGroups().map((g) => ({
|
||||||
|
id: g.id,
|
||||||
|
name: g.name || g.id,
|
||||||
|
host: (() => { try { return new (require("url").URL)(g.baseUrl).host; } catch (_) { return ""; } })(),
|
||||||
|
mode: g.mode || "chat",
|
||||||
|
}));
|
||||||
|
} catch (_) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 拉取指定分组的模型列表
|
||||||
|
async function listModels(groupId) {
|
||||||
|
const g = resolveGroup(groupId);
|
||||||
|
const models = await router.fetchModels(g.baseUrl, g.key);
|
||||||
|
return models;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 核心:图片 → 提示词分析
|
||||||
|
async function analyze({ image, model, groupId } = {}) {
|
||||||
|
if (!image || typeof image !== "string") throw new Error("缺少图片数据");
|
||||||
|
if (!model) throw new Error("未选择模型");
|
||||||
|
const g = resolveGroup(groupId);
|
||||||
|
const gwBase = chatBase(g.baseUrl);
|
||||||
|
|
||||||
|
const content = await callChat(gwBase, g.key, model, [
|
||||||
|
{ role: "system", content: SYSTEM_PROMPT },
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: [
|
||||||
|
{ type: "text", text: USER_INSTRUCTION },
|
||||||
|
{ type: "image_url", image_url: { url: image } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
], 2000);
|
||||||
|
|
||||||
|
return { result: extractJson(content), model };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { analyze, listGroups, listModels };
|
||||||
+321
-51
@@ -36,12 +36,18 @@ function dec(s) {
|
|||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
// —— 配置持久化 —— config: { port, routerKey, activeId, groups:[{id,name,baseUrl,key,model}] }
|
// —— 配置持久化 —— config: { port, routerKey, activeId, activeClaudeId, groups:[{id,name,baseUrl,key,model}] }
|
||||||
|
// 两个激活槽,按协议族各留一个,让 Codex/GPT 与 Claude Code 能同时激活、互不顶替:
|
||||||
|
// activeId —— OpenAI 协议族(Codex/GPT:mode = responses / chat,含默认分组)
|
||||||
|
// activeClaudeId —— Anthropic 协议族(Claude Code:mode = claude)
|
||||||
let config = null;
|
let config = null;
|
||||||
|
|
||||||
function defaultConfig() {
|
function defaultConfig() {
|
||||||
return { port: 8787, routerKey: "rk-" + crypto.randomBytes(16).toString("hex"), activeId: null, groups: [] };
|
return { port: 8787, routerKey: "rk-" + crypto.randomBytes(16).toString("hex"), activeId: null, activeClaudeId: null, groups: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 分组所属协议族:claude 模式 = Anthropic 族,其余(responses/chat/默认)= OpenAI 族
|
||||||
|
function isClaudeGroup(g) { return !!g && g.mode === "claude"; }
|
||||||
function load() {
|
function load() {
|
||||||
if (config) return config;
|
if (config) return config;
|
||||||
try {
|
try {
|
||||||
@@ -60,8 +66,28 @@ function load() {
|
|||||||
config = defaultConfig();
|
config = defaultConfig();
|
||||||
}
|
}
|
||||||
ensureDefault();
|
ensureDefault();
|
||||||
|
migrateActiveSlots();
|
||||||
return config;
|
return config;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 老配置只有单一 activeId:若它指向的是 claude 分组,迁移到 activeClaudeId,
|
||||||
|
// 并为空出来的 OpenAI 槽自动选一个非 claude 分组,保证两族各有默认激活。
|
||||||
|
function migrateActiveSlots() {
|
||||||
|
if (!("activeClaudeId" in config)) config.activeClaudeId = null;
|
||||||
|
const act = config.groups.find((g) => g.id === config.activeId);
|
||||||
|
if (act && isClaudeGroup(act)) {
|
||||||
|
if (!config.activeClaudeId) config.activeClaudeId = act.id;
|
||||||
|
// 优先选真正的中转分组(非默认、非 claude);没有再退到默认分组
|
||||||
|
const proxy = config.groups.find((g) => !isClaudeGroup(g) && !g.isDefault);
|
||||||
|
const fallback = config.groups.find((g) => !isClaudeGroup(g));
|
||||||
|
config.activeId = (proxy || fallback || {}).id || null;
|
||||||
|
}
|
||||||
|
// 补齐 claude 槽:若从未设过,且存在 claude 分组,默认选第一个
|
||||||
|
if (!config.activeClaudeId) {
|
||||||
|
const cg = config.groups.find((g) => isClaudeGroup(g));
|
||||||
|
if (cg) config.activeClaudeId = cg.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
function save() {
|
function save() {
|
||||||
const out = {
|
const out = {
|
||||||
...config,
|
...config,
|
||||||
@@ -82,9 +108,17 @@ function save() {
|
|||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
function activeGroup() {
|
// 取激活分组。wantClaude=true 取 Anthropic 族(activeClaudeId),否则取 OpenAI 族(activeId)。
|
||||||
|
function activeGroup(wantClaude) {
|
||||||
load();
|
load();
|
||||||
return config.groups.find((g) => g.id === config.activeId) || null;
|
const id = wantClaude ? config.activeClaudeId : config.activeId;
|
||||||
|
return config.groups.find((g) => g.id === id) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 判断一个进来的请求是否是 Anthropic 协议(Claude Code):走 /v1/messages,或带 anthropic 头。
|
||||||
|
function reqIsAnthropic(req) {
|
||||||
|
const h = req.headers || {};
|
||||||
|
return /\/v1\/messages/.test(req.url || "") || "anthropic-version" in h || "x-api-key" in h;
|
||||||
}
|
}
|
||||||
|
|
||||||
// —— 默认分组:客户原本的 Codex 配置(自己账号,直连)——
|
// —— 默认分组:客户原本的 Codex 配置(自己账号,直连)——
|
||||||
@@ -243,8 +277,12 @@ function catalogPath() {
|
|||||||
return path.isAbsolute(name) ? name : path.join(CODEX_HOME, name);
|
return path.isAbsolute(name) ? name : path.join(CODEX_HOME, name);
|
||||||
}
|
}
|
||||||
// 把分组的模型 + 上下文窗口写进 Codex 模型目录,让 Codex 按真实窗口自动压缩
|
// 把分组的模型 + 上下文窗口写进 Codex 模型目录,让 Codex 按真实窗口自动压缩
|
||||||
|
// 铁律:只要分组指定了 model,就必须保证它在目录里存在(visibility=list)。
|
||||||
|
// 否则 config.toml 的根级 model 指向一个「目录里没有的模型」,Codex 会以未知模型启动、
|
||||||
|
// 无法解析出该模型 → 侧栏历史线程还原失败 = 用户看到「切模型后会话记录消失」。
|
||||||
|
// contextWindow 只是「可选的窗口覆盖」,绝不能作为「是否登记进目录」的前置条件。
|
||||||
function applyModelCatalog(group) {
|
function applyModelCatalog(group) {
|
||||||
if (!group || group.isDefault || !group.model || !group.contextWindow) return;
|
if (!group || group.isDefault || !group.model) return; // 不再要求 contextWindow
|
||||||
const p = catalogPath();
|
const p = catalogPath();
|
||||||
let cat;
|
let cat;
|
||||||
try {
|
try {
|
||||||
@@ -253,20 +291,23 @@ function applyModelCatalog(group) {
|
|||||||
return; // 无目录文件就不动
|
return; // 无目录文件就不动
|
||||||
}
|
}
|
||||||
if (!cat.models || !cat.models.length) return;
|
if (!cat.models || !cat.models.length) return;
|
||||||
const win = parseInt(group.contextWindow, 10);
|
const win = parseInt(group.contextWindow, 10); // 未填 → NaN(假值),此时保留/沿用既有窗口
|
||||||
if (!win) return;
|
|
||||||
const existing = cat.models.find((m) => m.slug === group.model);
|
const existing = cat.models.find((m) => m.slug === group.model);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
existing.context_window = win;
|
if (win) {
|
||||||
existing.max_context_window = win;
|
existing.context_window = win;
|
||||||
existing.visibility = "list";
|
existing.max_context_window = win;
|
||||||
|
}
|
||||||
|
existing.visibility = "list"; // 已在目录:无论有无窗口,都确保可见
|
||||||
} else {
|
} else {
|
||||||
const tpl = JSON.parse(JSON.stringify(cat.models[0])); // 克隆模板(含 base_instructions 等必需字段)
|
const tpl = JSON.parse(JSON.stringify(cat.models[0])); // 克隆模板(含 base_instructions 等必需字段)
|
||||||
|
// 未指定窗口时,用模板自带窗口兜底(再退到 128000),保证新模型有合法窗口
|
||||||
|
const w = win || parseInt(tpl.context_window, 10) || 128000;
|
||||||
tpl.slug = group.model;
|
tpl.slug = group.model;
|
||||||
tpl.display_name = group.model;
|
tpl.display_name = group.model;
|
||||||
tpl.description = group.model;
|
tpl.description = group.model;
|
||||||
tpl.context_window = win;
|
tpl.context_window = w;
|
||||||
tpl.max_context_window = win;
|
tpl.max_context_window = w;
|
||||||
tpl.priority = 900;
|
tpl.priority = 900;
|
||||||
tpl.visibility = "list";
|
tpl.visibility = "list";
|
||||||
cat.models.push(tpl);
|
cat.models.push(tpl);
|
||||||
@@ -276,9 +317,100 @@ function applyModelCatalog(group) {
|
|||||||
} catch {}
|
} catch {}
|
||||||
fs.writeFileSync(p, JSON.stringify(cat, null, 2), "utf-8");
|
fs.writeFileSync(p, JSON.stringify(cat, null, 2), "utf-8");
|
||||||
}
|
}
|
||||||
// 把 config.toml 根级 model 设成指定值
|
// 【根治「切模型丢会话」】把分组中转站 /models 返回的全部文本模型,先登记进 Codex 模型目录。
|
||||||
|
// 关键:用户是在 Codex 本体的模型选择器里切模型的,那条路径不经过本 App,App 的 applyModelCatalog
|
||||||
|
// 不会触发;若目标 slug 不在目录里,Codex 会以未知模型启动、侧栏历史还原失败 = 会话"消失"。
|
||||||
|
// 因此必须【先回填】:把该中转能用的模型全部登记为 visibility=list,用户在 Codex 里选哪个都不丢。
|
||||||
|
// 非文本模型(画图/审阅/语音等)不是对话模型,排除;任何网络/解析失败都不动既有目录(离线安全)。
|
||||||
|
const NON_TEXT_MODEL_RE = /(image|imagine|whisper|audio|voice|tts|stt|embed|rerank|review|moderation|vision-ocr)/i;
|
||||||
|
async function syncCatalogFromProvider(group) {
|
||||||
|
try {
|
||||||
|
if (!group || group.isDefault || !group.baseUrl || !group.key) return { ok: false, skipped: true };
|
||||||
|
if (group.mode === "claude") return { ok: false, skipped: true }; // Claude 族不写 Codex 目录
|
||||||
|
let ids = [];
|
||||||
|
try {
|
||||||
|
ids = await fetchModels(group.baseUrl, group.key); // 复用既有 /v1/models · /models 探测
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, error: (e && e.message) || String(e) }; // 网络/接口失败 → 不动既有目录
|
||||||
|
}
|
||||||
|
const textIds = ids.filter((s) => !NON_TEXT_MODEL_RE.test(s));
|
||||||
|
if (!textIds.length) return { ok: false, error: "无可登记的文本模型" };
|
||||||
|
const p = catalogPath();
|
||||||
|
let cat;
|
||||||
|
try {
|
||||||
|
cat = JSON.parse(fs.readFileSync(p, "utf-8"));
|
||||||
|
} catch {
|
||||||
|
return { ok: false, error: "无模型目录文件" };
|
||||||
|
}
|
||||||
|
if (!cat.models || !cat.models.length) return { ok: false, error: "模型目录为空" };
|
||||||
|
const tpl0 = cat.models[0];
|
||||||
|
const w0 = parseInt(tpl0.context_window, 10) || 128000;
|
||||||
|
let added = 0;
|
||||||
|
let revealed = 0;
|
||||||
|
for (const slug of textIds) {
|
||||||
|
const ex = cat.models.find((m) => m.slug === slug);
|
||||||
|
if (ex) {
|
||||||
|
if (ex.visibility !== "list") { ex.visibility = "list"; revealed++; }
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const t = JSON.parse(JSON.stringify(tpl0)); // 克隆模板(含 base_instructions 等必需字段)
|
||||||
|
t.slug = slug;
|
||||||
|
t.display_name = slug;
|
||||||
|
t.description = slug;
|
||||||
|
t.context_window = w0;
|
||||||
|
t.max_context_window = w0;
|
||||||
|
t.priority = 900;
|
||||||
|
t.visibility = "list";
|
||||||
|
cat.models.push(t);
|
||||||
|
added++;
|
||||||
|
}
|
||||||
|
if (added || revealed) {
|
||||||
|
try { fs.writeFileSync(p + ".bak-router", fs.readFileSync(p)); } catch {}
|
||||||
|
fs.writeFileSync(p, JSON.stringify(cat, null, 2), "utf-8");
|
||||||
|
}
|
||||||
|
return { ok: true, added, revealed, total: textIds.length };
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, error: (e && e.message) || String(e) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 兜底:确保某个裸 model slug 一定在目录里(visibility=list)。用于 setConfigModel 前的自愈,
|
||||||
|
// 覆盖「config.toml 指向的模型没有对应分组(手改/历史残骸)」这种也会导致侧栏历史还原失败的情况。
|
||||||
|
function ensureModelInCatalog(model) {
|
||||||
|
if (!model) 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 existing = cat.models.find((m) => m.slug === model);
|
||||||
|
if (existing) {
|
||||||
|
if (existing.visibility !== "list") {
|
||||||
|
existing.visibility = "list";
|
||||||
|
try { fs.writeFileSync(p + ".bak-router", fs.readFileSync(p)); } catch {}
|
||||||
|
fs.writeFileSync(p, JSON.stringify(cat, null, 2), "utf-8");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const tpl = JSON.parse(JSON.stringify(cat.models[0]));
|
||||||
|
const w = parseInt(tpl.context_window, 10) || 128000;
|
||||||
|
tpl.slug = model;
|
||||||
|
tpl.display_name = model;
|
||||||
|
tpl.description = model;
|
||||||
|
tpl.context_window = w;
|
||||||
|
tpl.max_context_window = w;
|
||||||
|
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 设成指定值(写入前先确保该模型已在目录里,避免 Codex 以未知模型启动)
|
||||||
function setConfigModel(model) {
|
function setConfigModel(model) {
|
||||||
if (!model) return;
|
if (!model) return;
|
||||||
|
ensureModelInCatalog(model);
|
||||||
const cfgFile = path.join(CODEX_HOME, "config.toml");
|
const cfgFile = path.join(CODEX_HOME, "config.toml");
|
||||||
let text = readConfigToml();
|
let text = readConfigToml();
|
||||||
if (/^\s*model\s*=/m.test(text)) text = text.replace(/^\s*model\s*=.*$/m, `model = "${model}"`);
|
if (/^\s*model\s*=/m.test(text)) text = text.replace(/^\s*model\s*=.*$/m, `model = "${model}"`);
|
||||||
@@ -468,10 +600,13 @@ function rewriteResponsesBody(body, group) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handle(req, res) {
|
function handle(req, res) {
|
||||||
const g = activeGroup();
|
// 按请求协议派发:Claude Code(Anthropic) → activeClaudeId;Codex/GPT(OpenAI) → activeId。
|
||||||
|
// 这样两族各转发到各自激活的分组,可同时使用、互不顶替。
|
||||||
|
const wantClaude = reqIsAnthropic(req);
|
||||||
|
const g = activeGroup(wantClaude);
|
||||||
if (!g) {
|
if (!g) {
|
||||||
res.writeHead(503, { "content-type": "text/plain; charset=utf-8" });
|
res.writeHead(503, { "content-type": "text/plain; charset=utf-8" });
|
||||||
res.end("本地路由:未选择中转分组");
|
res.end(wantClaude ? "本地路由:未选择 Claude Code 分组" : "本地路由:未选择中转分组");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// 校验路由 key(Codex 用 auth.json 里的 routerKey 作 Bearer 发来)
|
// 校验路由 key(Codex 用 auth.json 里的 routerKey 作 Bearer 发来)
|
||||||
@@ -612,6 +747,17 @@ function handle(req, res) {
|
|||||||
} else {
|
} else {
|
||||||
headers["authorization"] = "Bearer " + g.key; // 非 Anthropic(Codex 等):换成分组密钥
|
headers["authorization"] = "Bearer " + g.key; // 非 Anthropic(Codex 等):换成分组密钥
|
||||||
}
|
}
|
||||||
|
// 1M 长上下文:若本次请求的模型被该分组标记为「1M」,加上 Anthropic 的 1M beta 头解锁百万窗口。
|
||||||
|
// 对 GPT/GLM 等 OpenAI 兼容网关该头无害(被忽略);对 Claude 生效。
|
||||||
|
if (Array.isArray(g.oneMillionModels) && g.oneMillionModels.length && body.length) {
|
||||||
|
let reqModel = "";
|
||||||
|
try { reqModel = (JSON.parse(body.toString("utf-8")) || {}).model || ""; } catch {}
|
||||||
|
if (reqModel && g.oneMillionModels.includes(reqModel)) {
|
||||||
|
const beta = "context-1m-2025-08-07";
|
||||||
|
const existing = headers["anthropic-beta"];
|
||||||
|
headers["anthropic-beta"] = existing ? (existing.includes(beta) ? existing : existing + "," + beta) : beta;
|
||||||
|
}
|
||||||
|
}
|
||||||
delete headers["content-length"];
|
delete headers["content-length"];
|
||||||
if (body.length) headers["content-length"] = Buffer.byteLength(body);
|
if (body.length) headers["content-length"] = Buffer.byteLength(body);
|
||||||
if (doCapture) {
|
if (doCapture) {
|
||||||
@@ -681,6 +827,7 @@ function status() {
|
|||||||
routerKey: config.routerKey,
|
routerKey: config.routerKey,
|
||||||
address: `http://127.0.0.1:${config.port}`,
|
address: `http://127.0.0.1:${config.port}`,
|
||||||
activeId: config.activeId,
|
activeId: config.activeId,
|
||||||
|
activeClaudeId: config.activeClaudeId,
|
||||||
claudeApplied: claudeApplied(),
|
claudeApplied: claudeApplied(),
|
||||||
groups: config.groups.map((g) => ({
|
groups: config.groups.map((g) => ({
|
||||||
id: g.id,
|
id: g.id,
|
||||||
@@ -694,6 +841,7 @@ function status() {
|
|||||||
codexImage: !!g.codexImage,
|
codexImage: !!g.codexImage,
|
||||||
stt: !!g.stt,
|
stt: !!g.stt,
|
||||||
sttModel: g.sttModel || "",
|
sttModel: g.sttModel || "",
|
||||||
|
oneMillionModels: Array.isArray(g.oneMillionModels) ? g.oneMillionModels : [],
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -765,9 +913,10 @@ function syncGenImage() {
|
|||||||
function upsertGroup(g) {
|
function upsertGroup(g) {
|
||||||
load();
|
load();
|
||||||
let id;
|
let id;
|
||||||
|
let prevMode = null; // 编辑前的接口类型(用于判断是否从 claude 改成别的)
|
||||||
if (g.id) {
|
if (g.id) {
|
||||||
const i = config.groups.findIndex((x) => x.id === g.id);
|
const i = config.groups.findIndex((x) => x.id === g.id);
|
||||||
if (i >= 0) config.groups[i] = { ...config.groups[i], ...g };
|
if (i >= 0) { prevMode = config.groups[i].mode || "responses"; config.groups[i] = { ...config.groups[i], ...g }; }
|
||||||
else config.groups.push({ ...g });
|
else config.groups.push({ ...g });
|
||||||
id = g.id;
|
id = g.id;
|
||||||
} else {
|
} else {
|
||||||
@@ -775,7 +924,10 @@ function upsertGroup(g) {
|
|||||||
config.groups.push(g);
|
config.groups.push(g);
|
||||||
id = g.id;
|
id = g.id;
|
||||||
}
|
}
|
||||||
if (!config.activeId) config.activeId = g.id;
|
// 首个分组自动激活到所属协议族的槽:claude→activeClaudeId,其余→activeId
|
||||||
|
const curG = config.groups.find((x) => x.id === id);
|
||||||
|
if (isClaudeGroup(curG)) { if (!config.activeClaudeId) config.activeClaudeId = id; }
|
||||||
|
else { if (!config.activeId) config.activeId = id; }
|
||||||
// 生图来源唯一:勾选的分组独占,清掉其它分组的标记
|
// 生图来源唯一:勾选的分组独占,清掉其它分组的标记
|
||||||
const cur = config.groups.find((x) => x.id === id);
|
const cur = config.groups.find((x) => x.id === id);
|
||||||
if (cur && cur.codexImage) {
|
if (cur && cur.codexImage) {
|
||||||
@@ -783,28 +935,49 @@ function upsertGroup(g) {
|
|||||||
}
|
}
|
||||||
save();
|
save();
|
||||||
try { syncGenImage(); } catch {}
|
try { syncGenImage(); } catch {}
|
||||||
// 保存中转分组后:把它的直连中转配置写进 Codex config.toml(不经路由、不起进程),
|
// 保存分组后按接口类型分流:
|
||||||
// 并设为激活分组。用户重启 Codex 即用当前分组。只有显式「启用本地路由」才走路由进程。
|
// - claude 模式:把分组直连配置写进 ~/.claude/settings.json(接入 Claude Code CLI),不碰 Codex
|
||||||
|
// - responses/chat 模式:把直连中转配置写进 Codex config.toml(不经路由、不起进程)
|
||||||
const st = status();
|
const st = status();
|
||||||
if (cur && !cur.isDefault) {
|
if (cur && !cur.isDefault) {
|
||||||
// 路由进程正在跑(用户处于「启用路由」高级模式)→ 保持走本地路由,不改成直连
|
if (cur.mode === "claude") {
|
||||||
if (isRunning()) {
|
// 接入 Claude Code
|
||||||
st._directSkipped = true;
|
if (!cur.baseUrl || !cur.key) {
|
||||||
st._directReason = "本地路由运行中,保存的分组走路由生效(切分组无需重启)";
|
st._claudeSkipped = true;
|
||||||
} else if (!cur.baseUrl || !cur.key) {
|
st._claudeReason = "该分组缺少 API 地址或密钥,未写入 Claude Code 配置";
|
||||||
st._directSkipped = true;
|
} else {
|
||||||
st._directReason = "该分组缺少 API 地址或密钥,未写入 Codex 配置";
|
try {
|
||||||
} else if (cur.mode === "chat") {
|
const rc = applyDirectToClaude(cur.id);
|
||||||
st._directSkipped = true;
|
if (rc.ok) st._claudeApplied = true;
|
||||||
st._directNeedRouter = true;
|
else { st._claudeSkipped = true; st._claudeReason = rc.error || "写入失败"; }
|
||||||
st._directReason = "该分组为 Chat 协议转换模式,需启用本地路由才能接入 Codex";
|
} catch (e) {
|
||||||
|
st._claudeError = String((e && e.message) || e);
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
try {
|
// 接入 Codex(responses 直连 / chat 协议转换)
|
||||||
const r = applyDirectToCodex(cur.id);
|
// 仅当【本分组】之前是 claude 模式、现改回 Codex 模式 → 还原它写过的 Claude Code 配置
|
||||||
if (r.ok) { st._directApplied = true; st.activeId = config.activeId; }
|
if (prevMode === "claude" && config.claudePrev && claudeDirectApplied()) {
|
||||||
else { st._directSkipped = true; st._directReason = r.error || "写入失败"; }
|
try { restoreClaudeCode(); st._claudeRestored = true; } catch {}
|
||||||
} catch (e) {
|
}
|
||||||
st._directError = String((e && e.message) || e);
|
if (isRunning()) {
|
||||||
|
st._directSkipped = true;
|
||||||
|
st._directReason = "本地路由运行中,保存的分组走路由生效(切分组无需重启)";
|
||||||
|
} else if (!cur.baseUrl || !cur.key) {
|
||||||
|
st._directSkipped = true;
|
||||||
|
st._directReason = "该分组缺少 API 地址或密钥,未写入 Codex 配置";
|
||||||
|
} else if (cur.mode === "chat") {
|
||||||
|
st._directSkipped = true;
|
||||||
|
st._directNeedRouter = true;
|
||||||
|
st._directReason = "该分组为 Chat 协议转换模式,需启用本地路由才能接入 Codex";
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const r = applyDirectToCodex(cur.id);
|
||||||
|
if (r.ok) { st._directApplied = true; st.activeId = config.activeId; }
|
||||||
|
else { st._directSkipped = true; st._directReason = r.error || "写入失败"; }
|
||||||
|
} catch (e) {
|
||||||
|
st._directError = String((e && e.message) || e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -814,8 +987,20 @@ function deleteGroup(id) {
|
|||||||
load();
|
load();
|
||||||
const g = config.groups.find((x) => x.id === id);
|
const g = config.groups.find((x) => x.id === id);
|
||||||
if (g && g.isDefault) return { ...status(), error: "默认分组不可删除" };
|
if (g && g.isDefault) return { ...status(), error: "默认分组不可删除" };
|
||||||
|
const wasClaude = isClaudeGroup(g);
|
||||||
config.groups = config.groups.filter((x) => x.id !== id);
|
config.groups = config.groups.filter((x) => x.id !== id);
|
||||||
if (config.activeId === id) config.activeId = config.groups[0] ? config.groups[0].id : null;
|
// 删的是哪族分组,就补选同族的另一个(找不到则清空该族激活槽),不影响另一族。
|
||||||
|
if (wasClaude) {
|
||||||
|
if (config.activeClaudeId === id) {
|
||||||
|
const next = config.groups.find((x) => isClaudeGroup(x));
|
||||||
|
config.activeClaudeId = next ? next.id : null;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (config.activeId === id) {
|
||||||
|
const next = config.groups.find((x) => !isClaudeGroup(x));
|
||||||
|
config.activeId = next ? next.id : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
save();
|
save();
|
||||||
try { syncGenImage(); } catch {} // 删掉的可能正是生图来源,同步清除 .env
|
try { syncGenImage(); } catch {} // 删掉的可能正是生图来源,同步清除 .env
|
||||||
return status();
|
return status();
|
||||||
@@ -824,6 +1009,25 @@ function setActive(id) {
|
|||||||
load();
|
load();
|
||||||
const target = config.groups.find((g) => g.id === id);
|
const target = config.groups.find((g) => g.id === id);
|
||||||
if (!target) return status();
|
if (!target) return status();
|
||||||
|
// Claude Code(Anthropic 族):落 activeClaudeId,写 ~/.claude/settings.json,绝不碰 Codex 配置。
|
||||||
|
// 与 OpenAI 族互不顶替 —— 两族可同时激活。
|
||||||
|
if (isClaudeGroup(target)) {
|
||||||
|
config.activeClaudeId = id;
|
||||||
|
let claudeMode = "proxy";
|
||||||
|
let claudeSkipped, claudeReason;
|
||||||
|
if (isRunning()) {
|
||||||
|
// 路由在跑:settings.json 指向本地路由即可,路由按 activeClaudeId 动态转发,无需改文件、无需重启
|
||||||
|
claudeMode = "proxy";
|
||||||
|
if (!claudeApplied()) applyToClaudeCode();
|
||||||
|
} else {
|
||||||
|
// 默认模式:直连中转,把分组地址写进 settings.json(新开 claude 会话生效)
|
||||||
|
claudeMode = "direct";
|
||||||
|
const r = applyDirectToClaude(id);
|
||||||
|
if (!r.ok) { claudeSkipped = r.skipped; claudeReason = r.error; }
|
||||||
|
}
|
||||||
|
save();
|
||||||
|
return { ...status(), _mode: claudeMode, _claudeSkipped: claudeSkipped, _claudeReason: claudeReason };
|
||||||
|
}
|
||||||
const before = currentModelProvider();
|
const before = currentModelProvider();
|
||||||
const wasTakenOver = before === PROVIDER_ID || before === DIRECT_PROVIDER_ID;
|
const wasTakenOver = before === PROVIDER_ID || before === DIRECT_PROVIDER_ID;
|
||||||
let needsRestart = false;
|
let needsRestart = false;
|
||||||
@@ -873,10 +1077,10 @@ function codexSnippet() {
|
|||||||
|
|
||||||
function applyToCodex() {
|
function applyToCodex() {
|
||||||
load();
|
load();
|
||||||
// 本地路由需要一个中转分组做上游;若当前是默认/空,自动选第一个中转分组
|
// 本地路由需要一个中转分组做上游;若当前是默认/空,自动选第一个中转分组(排除 claude 模式)
|
||||||
const cur = config.groups.find((g) => g.id === config.activeId);
|
const cur = config.groups.find((g) => g.id === config.activeId);
|
||||||
if (!cur || cur.isDefault) {
|
if (!cur || cur.isDefault || cur.mode === "claude") {
|
||||||
const proxy = config.groups.find((g) => !g.isDefault);
|
const proxy = config.groups.find((g) => !g.isDefault && g.mode !== "claude");
|
||||||
if (proxy) config.activeId = proxy.id;
|
if (proxy) config.activeId = proxy.id;
|
||||||
}
|
}
|
||||||
const cfgFile = path.join(CODEX_HOME, "config.toml");
|
const cfgFile = path.join(CODEX_HOME, "config.toml");
|
||||||
@@ -924,6 +1128,9 @@ function applyToCodex() {
|
|||||||
applyModelCatalog(cur2);
|
applyModelCatalog(cur2);
|
||||||
setConfigModel(cur2.model);
|
setConfigModel(cur2.model);
|
||||||
}
|
}
|
||||||
|
// 先回填该中转站的全部文本模型进目录(异步、不阻塞、失败不动目录),
|
||||||
|
// 保证用户之后在 Codex 本体里切到任何模型都不会因"模型不在目录"而丢会话。
|
||||||
|
if (cur2 && !cur2.isDefault) syncCatalogFromProvider(cur2).catch(() => {});
|
||||||
save();
|
save();
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
@@ -947,6 +1154,9 @@ function applyDirectToCodex(groupId) {
|
|||||||
if (g.mode === "chat") {
|
if (g.mode === "chat") {
|
||||||
return { ok: false, skipped: true, needRouter: true, error: "该分组为 Chat 协议转换模式,直连 Codex 不支持,需启用本地路由" };
|
return { ok: false, skipped: true, needRouter: true, error: "该分组为 Chat 协议转换模式,直连 Codex 不支持,需启用本地路由" };
|
||||||
}
|
}
|
||||||
|
if (g.mode === "claude") {
|
||||||
|
return { ok: false, skipped: true, error: "该分组为 Claude Code 类型,不写入 Codex 配置" };
|
||||||
|
}
|
||||||
|
|
||||||
// 接管前先确保官方登录已完整备份(幂等)
|
// 接管前先确保官方登录已完整备份(幂等)
|
||||||
const def = config.groups.find((x) => x.isDefault);
|
const def = config.groups.find((x) => x.isDefault);
|
||||||
@@ -991,6 +1201,10 @@ function applyDirectToCodex(groupId) {
|
|||||||
applyModelCatalog(g);
|
applyModelCatalog(g);
|
||||||
setConfigModel(g.model);
|
setConfigModel(g.model);
|
||||||
}
|
}
|
||||||
|
// 先回填:把该中转站能用的全部文本模型登记进 Codex 模型目录(异步、不阻塞保存、失败不动目录)。
|
||||||
|
// 这样即便用户之后在 Codex 本体的模型选择器里切到别的模型(那条路径不经过本 App),
|
||||||
|
// 目标模型也已在目录里 → Codex 能正常启动、侧栏历史不再"消失"。
|
||||||
|
syncCatalogFromProvider(g).catch(() => {});
|
||||||
config.activeId = g.id;
|
config.activeId = g.id;
|
||||||
save();
|
save();
|
||||||
return { ok: true, direct: true, group: g.name };
|
return { ok: true, direct: true, group: g.name };
|
||||||
@@ -1001,15 +1215,8 @@ function claudeSettingsPath() {
|
|||||||
|
|
||||||
function applyToClaudeCode() {
|
function applyToClaudeCode() {
|
||||||
load();
|
load();
|
||||||
// 本地路由需要一个中转分组做上游;若当前是默认/空,自动选第一个中转分组
|
// Anthropic 请求由路由按 activeClaudeId 派发(见 handle);这里只把 settings.json 指向本地路由。
|
||||||
const cur = config.groups.find((g) => g.id === config.activeId);
|
// 不再动 activeId(OpenAI/Codex 槽)—— 两族独立,接入 Claude Code 不该顶掉 Codex 的选择。
|
||||||
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 });
|
fs.mkdirSync(CLAUDE_HOME, { recursive: true });
|
||||||
const p = claudeSettingsPath();
|
const p = claudeSettingsPath();
|
||||||
let settings = {};
|
let settings = {};
|
||||||
@@ -1055,8 +1262,8 @@ function restoreClaudeCode() {
|
|||||||
return { ok: true }; // 没有文件就无需还原
|
return { ok: true }; // 没有文件就无需还原
|
||||||
}
|
}
|
||||||
settings.env = settings.env || {};
|
settings.env = settings.env || {};
|
||||||
const prev = config.claudePrev || { base: null, token: null };
|
const prev = config.claudePrev || { base: null, token: null, model: null };
|
||||||
for (const [k, v] of [["ANTHROPIC_BASE_URL", prev.base], ["ANTHROPIC_AUTH_TOKEN", prev.token]]) {
|
for (const [k, v] of [["ANTHROPIC_BASE_URL", prev.base], ["ANTHROPIC_AUTH_TOKEN", prev.token], ["ANTHROPIC_MODEL", prev.model]]) {
|
||||||
if (v == null) delete settings.env[k];
|
if (v == null) delete settings.env[k];
|
||||||
else settings.env[k] = v;
|
else settings.env[k] = v;
|
||||||
}
|
}
|
||||||
@@ -1077,6 +1284,66 @@ function claudeApplied() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// —— Claude Code 直连中转(不经本地路由)——
|
||||||
|
// 把指定分组的中转地址/key 直接写进 ~/.claude/settings.json 的 env,Claude Code 直连中转站。
|
||||||
|
// 与 applyToClaudeCode(写本地路由地址)对称,但 base_url = 分组主地址(非 127.0.0.1)。
|
||||||
|
function applyDirectToClaude(groupId) {
|
||||||
|
load();
|
||||||
|
const g = config.groups.find((x) => x.id === (groupId || config.activeId));
|
||||||
|
if (!g) return { ok: false, error: "分组不存在" };
|
||||||
|
if (g.isDefault) return { ok: false, error: "默认分组不接入 Claude Code" };
|
||||||
|
// Claude Code 直连用分组主字段:API 地址 baseUrl、API 密钥 key、默认模型 model
|
||||||
|
if (!g.baseUrl || !g.key) {
|
||||||
|
return { ok: false, skipped: true, error: "该分组缺少 API 地址或密钥,未写入 Claude Code 配置" };
|
||||||
|
}
|
||||||
|
const base = g.baseUrl.replace(/\/+$/, "");
|
||||||
|
|
||||||
|
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 || {};
|
||||||
|
// 首次接入时记住用户原有值,便于「取消接入」还原(与 applyToClaudeCode 共用 claudePrev)
|
||||||
|
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,
|
||||||
|
model: Object.prototype.hasOwnProperty.call(settings.env, "ANTHROPIC_MODEL") ? settings.env.ANTHROPIC_MODEL : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
settings.env.ANTHROPIC_BASE_URL = base;
|
||||||
|
settings.env.ANTHROPIC_AUTH_TOKEN = g.key;
|
||||||
|
if (g.model && g.model.trim()) settings.env.ANTHROPIC_MODEL = g.model.trim();
|
||||||
|
else delete settings.env.ANTHROPIC_MODEL;
|
||||||
|
fs.writeFileSync(p, JSON.stringify(settings, null, 2), "utf-8");
|
||||||
|
try { fs.chmodSync(p, 0o600); } catch {}
|
||||||
|
save();
|
||||||
|
return { ok: true, direct: true, path: p, group: g.name };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Claude Code 是否已指向某个中转直连地址(非本地路由)
|
||||||
|
function claudeDirectApplied() {
|
||||||
|
try {
|
||||||
|
const s = JSON.parse(fs.readFileSync(claudeSettingsPath(), "utf-8"));
|
||||||
|
const url = s.env && s.env.ANTHROPIC_BASE_URL;
|
||||||
|
return !!(url && !/127\.0\.0\.1/.test(url));
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
load,
|
load,
|
||||||
start,
|
start,
|
||||||
@@ -1091,8 +1358,10 @@ module.exports = {
|
|||||||
applyDirectToCodex,
|
applyDirectToCodex,
|
||||||
codexSnippet,
|
codexSnippet,
|
||||||
applyToClaudeCode,
|
applyToClaudeCode,
|
||||||
|
applyDirectToClaude,
|
||||||
restoreClaudeCode,
|
restoreClaudeCode,
|
||||||
claudeApplied,
|
claudeApplied,
|
||||||
|
claudeDirectApplied,
|
||||||
getGroupKey,
|
getGroupKey,
|
||||||
syncGenImage,
|
syncGenImage,
|
||||||
groupById,
|
groupById,
|
||||||
@@ -1100,4 +1369,5 @@ module.exports = {
|
|||||||
llmGroup,
|
llmGroup,
|
||||||
sttGroup,
|
sttGroup,
|
||||||
fetchModels,
|
fetchModels,
|
||||||
|
syncCatalogFromProvider,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,284 @@
|
|||||||
|
// ============================================================================
|
||||||
|
// 核心提示词工程 —— 整个产品的命门
|
||||||
|
// 这段 System Prompt 决定了视觉模型"如何看图 + 输出什么结构的结果"。
|
||||||
|
// 想提升效果,绝大多数时候是改这里,而不是改界面。
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const SYSTEM_PROMPT = `You are a world-class visual analyst and prompt engineer. Your single job: look at an image and reverse-engineer the most effective text-to-image generation prompt that would let a modern model (Flux / 即梦 / 可灵 / SDXL 等自然语言生图模型) reproduce an image as close as possible to the original.
|
||||||
|
|
||||||
|
# How to look (analyze systematically, do NOT skip a dimension)
|
||||||
|
1. Subject 主体:who/what is the focus, count, pose, action, expression, clothing, key objects.
|
||||||
|
2. Scene / environment 场景:location, background, foreground, time of day, weather, season.
|
||||||
|
3. Style / medium 风格媒介:photography? 3D render? oil painting? anime? watercolor? pixel art? Name the concrete style and, if it clearly matches a well-known aesthetic, name it (e.g. cyberpunk, Ghibli-like, film photography, cinematic).
|
||||||
|
4. Composition & camera 构图镜头:shot type (close-up / wide / bird's-eye / low-angle), framing, rule of thirds, symmetry, subject placement, aspect ratio if obvious.
|
||||||
|
5. Lighting 光影:source & direction (backlight, rim light, soft light, hard light), golden hour, neon, studio, dramatic shadows.
|
||||||
|
6. Color 色彩:palette, temperature (warm/cool), saturation, contrast, dominant colors, any grading (teal-orange, Morandi, pastel).
|
||||||
|
7. Texture & detail 材质细节:skin texture, fabric, metal reflection, depth of field / bokeh, grain, sharpness.
|
||||||
|
8. Technical cues 技术线索:if photographic — plausible lens/focal length, aperture, camera feel; if rendered — engine/render look (octane, unreal, cel-shading).
|
||||||
|
9. Mood / atmosphere 氛围:the overall feeling the image conveys.
|
||||||
|
|
||||||
|
# Rules that decide quality
|
||||||
|
- The ENGLISH prompt is the primary deliverable — make it the most effective one. Modern T2I models are trained mostly on English; English gives the best results.
|
||||||
|
- Write the English prompt as ONE flowing, natural, descriptive paragraph (natural-language style, NOT a bare comma-list). Front-load the most important elements (subject → style → composition → lighting → color → detail). Be concrete and specific; never rely on vague adjectives alone.
|
||||||
|
- NEVER invent content that is not supported by the image. If something is uncertain, describe only what is actually visible. Do not hallucinate text, logos, or details you cannot see.
|
||||||
|
- Provide a faithful Chinese translation that mirrors the English prompt one-to-one (same information, natural Chinese).
|
||||||
|
- Provide a negative prompt: things a generator should AVOID to keep quality high and stay faithful (e.g. deformed hands, extra fingers, blurry, watermark, low-res, plus anything clearly absent from THIS image's style).
|
||||||
|
- Provide a per-dimension breakdown so the user understands and can tweak individual aspects.
|
||||||
|
- Provide a compact English keyword tag list (for users who prefer Midjourney-style comma prompts).
|
||||||
|
- Give an honest confidence level about how faithfully the prompt can reproduce the original.
|
||||||
|
|
||||||
|
# Output format — CRITICAL
|
||||||
|
Return ONLY a single valid JSON object. No markdown, no code fences, no commentary before or after. Use exactly this schema:
|
||||||
|
|
||||||
|
{
|
||||||
|
"prompt_en": "the primary English generation prompt, one rich natural-language paragraph",
|
||||||
|
"prompt_zh": "对应的中文提示词(与英文一一对应)",
|
||||||
|
"negative_prompt_en": "comma-separated things to avoid",
|
||||||
|
"negative_prompt_zh": "需要规避的内容(中文)",
|
||||||
|
"tags_en": ["keyword1", "keyword2", "..."],
|
||||||
|
"breakdown": [
|
||||||
|
{ "label_zh": "主体", "label_en": "Subject", "value_zh": "中文描述", "value_en": "english description" },
|
||||||
|
{ "label_zh": "风格", "label_en": "Style", "value_zh": "…", "value_en": "…" },
|
||||||
|
{ "label_zh": "构图/镜头", "label_en": "Composition & Camera", "value_zh": "…", "value_en": "…" },
|
||||||
|
{ "label_zh": "光影", "label_en": "Lighting", "value_zh": "…", "value_en": "…" },
|
||||||
|
{ "label_zh": "色彩", "label_en": "Color", "value_zh": "…", "value_en": "…" },
|
||||||
|
{ "label_zh": "材质/细节", "label_en": "Texture & Detail", "value_zh": "…", "value_en": "…" },
|
||||||
|
{ "label_zh": "氛围", "label_en": "Mood", "value_zh": "…", "value_en": "…" }
|
||||||
|
],
|
||||||
|
"confidence": "high | medium | low",
|
||||||
|
"notes_zh": "一句话给出复现建议或需要注意的地方"
|
||||||
|
}`;
|
||||||
|
|
||||||
|
// 用户消息里附带的文字指令(图片作为 image_url 一起发送)
|
||||||
|
const USER_INSTRUCTION =
|
||||||
|
'请精准分析这张图片,反推出能复现它的生图提示词,严格按 System 里的 JSON schema 输出,只返回 JSON。';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 员工团队生成 —— 分析客户需求,拆解出一支带约束的 AI agent 团队
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// —— 通用工程协同规范(写死,保证每次生成都一致;会注入进每个 agent 的 prompt)——
|
||||||
|
const TEAM_CONVENTIONS = {
|
||||||
|
// 每个 Agent 完成任务后必须固定输出的结构化报告
|
||||||
|
output_format: [
|
||||||
|
'变更摘要:一句话说明这次做了什么',
|
||||||
|
'修改文件:列出新增 / 修改 / 删除的文件路径',
|
||||||
|
'测试结果:跑了哪些测试、是否通过(贴关键输出)',
|
||||||
|
'风险与影响:可能波及的其他模块或接口',
|
||||||
|
'需要配合:需要哪个角色接手 / 确认,以及交接内容与格式',
|
||||||
|
'跨职责说明:为什么修改非本职责文件 / 影响哪个角色 / 需要谁复核 / 是否改动契约、数据库结构、安全策略、部署配置(无则写"无")',
|
||||||
|
],
|
||||||
|
// 任务的完成定义(基线,模型可追加项目特有项)
|
||||||
|
definition_of_done: [
|
||||||
|
'Lint / 类型检查通过',
|
||||||
|
'相关单元与集成测试通过',
|
||||||
|
'接口契约与文档同步更新',
|
||||||
|
'数据库迁移可回滚(有 up/down)',
|
||||||
|
'README / 变更说明已更新',
|
||||||
|
'跨职责修改已在报告中说明并获得对应 owner 复核',
|
||||||
|
],
|
||||||
|
// Git / 职责归属规范(开放写权限模式:owner_paths 表达审核归属,不限制编辑)
|
||||||
|
git_rules: [
|
||||||
|
'开放写权限:所有执行角色技术上可改动项目内任意文件',
|
||||||
|
'跨职责(他人 owner_paths)修改必须在完成报告中说明并请对应 owner 复核',
|
||||||
|
'涉及接口契约 / 数据库结构 / 安全策略 / 部署配置的改动,必须提请对应 owner 与总控确认后合入',
|
||||||
|
'文件冲突或边界不清时,交总控 Agent 裁决',
|
||||||
|
'提交信息格式:type(scope): 摘要(如 feat(order): 新增下单接口)',
|
||||||
|
'一个任务对应一组内聚提交,便于审核与回滚',
|
||||||
|
],
|
||||||
|
// 任务交接协议
|
||||||
|
handoff_protocol: [
|
||||||
|
'交接用结构化「交接单」传递:来源角色 / 目标角色 / 交接物 / 约定格式 / 验收点',
|
||||||
|
'接口相关交接必须附契约:路径、方法、入参、出参、错误码、分页、权限码',
|
||||||
|
'数据库变更交接必须附:变更说明、迁移脚本、回滚脚本、受影响表',
|
||||||
|
'接收方动工前先确认契约;有异议退回来源角色或总控,不擅自改契约',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// 总控调度状态机
|
||||||
|
const ORCHESTRATOR_WORKFLOW = [
|
||||||
|
'需求分析:澄清目标、边界与验收标准',
|
||||||
|
'任务拆分:拆成可独立交付的小任务,标注负责角色与依赖关系',
|
||||||
|
'契约收敛:先让契约 owner 定稿接口契约(OpenAPI / DTO / 错误码 / 分页 / 权限码),各方确认',
|
||||||
|
'任务立契:分派前为每个任务产出《任务契约》(套 templates/task-charter.md),必须写明目标、可判定的验收标准、以及至少 2 条「明确不做(out of scope)」;写不出 out of scope 说明任务没想清楚,不得分派',
|
||||||
|
'分派执行:把任务连同《任务契约》、契约、DoD 一起分派给对应角色,并在 docs/coordination/task-ledger.md 登记为 assigned',
|
||||||
|
'进度跟踪:收集各角色的结构化产出报告,更新 task-ledger 状态',
|
||||||
|
'交叉审核:对照该任务《任务契约》审核——产出是否只落在目标内、是否命中 out of scope、契约一致性、安全与 DoD 达成情况',
|
||||||
|
'返工闭环:不达标的退回对应角色,打回理由必须引用《任务契约》或 DoD 的具体条款并写入 task-ledger;同一任务打回 ≥ 2 次时不再简单退回,改为重新澄清任务契约(判断是不是任务边界本身没定义清楚),避免无限返工',
|
||||||
|
'集成验收:全部满足 DoD 后做集成验证,仅当 task-ledger 状态为 accepted 才关闭任务,输出最终验收结论',
|
||||||
|
];
|
||||||
|
|
||||||
|
// 任务契约模板:领域无关的「目标边界」骨架。out of scope 由运行时总控按本次需求填,生成器不预设领域内容。
|
||||||
|
const TASK_CHARTER = `# 任务契约 · <task-id>
|
||||||
|
|
||||||
|
> 分派任何任务前由总控产出。它是「防跑飞」的依据:agent 每一步都对照此表,总控打回也引用此表的条款。
|
||||||
|
|
||||||
|
- 负责角色:
|
||||||
|
- 目标(一句话,可衡量):
|
||||||
|
- 验收标准(逐条可判定真假):
|
||||||
|
- [ ]
|
||||||
|
- [ ]
|
||||||
|
- 明确不做 / out of scope(至少 2 条;越界即视为跑飞):
|
||||||
|
- 不改动 <某模块 / 某能力>
|
||||||
|
- 不新增 <与本任务无关的功能 / 承诺>
|
||||||
|
- 不重构 / 不顺手动 <现有某处>
|
||||||
|
- 允许触碰的文件范围(本角色 owner_paths 的子集或明确列举):
|
||||||
|
- 依赖 / 前置(需要哪个角色先交付什么):
|
||||||
|
- 完成信号:产出 templates/acceptance-report.md 并经总控验收,task-ledger 置为 accepted
|
||||||
|
|
||||||
|
---
|
||||||
|
判定「跑飞」的通用标准(与领域无关):agent 做的事若无法在本契约「目标」里找到依据,或命中「out of scope」,即为跑飞 —— 立即停下、写入 task-ledger、交总控裁决,不得自行扩大范围。
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 安全 / 权限审核清单(涉及鉴权、RBAC、敏感数据时启用)
|
||||||
|
const SECURITY_CHECKLIST = [
|
||||||
|
'越权访问:每个接口都校验身份与资源归属',
|
||||||
|
'水平越权:用户只能访问自己的数据(按 owner 过滤)',
|
||||||
|
'垂直越权:RBAC 权限码校验,前后端权限一致',
|
||||||
|
'敏感字段:密码 / 令牌 / 隐私字段不落日志、不明文返回',
|
||||||
|
'审计日志:关键操作留痕(谁 / 何时 / 做了什么)',
|
||||||
|
'会话 / 令牌:按项目选定的方案(JWT 或 HttpOnly Cookie)——设置过期、可注销失效、防重放',
|
||||||
|
'输入校验:所有外部输入做校验与转义,防注入(Web 另需防 XSS / CSRF)',
|
||||||
|
];
|
||||||
|
|
||||||
|
const EMPLOYEE_SYSTEM_PROMPT = `You are a senior technical lead and AI-agent team architect. The user gives you a project requirement in natural language. Design a COLLABORATING team of specialized AI agents ("employees") plus one orchestrator that could actually build and ship this project together.
|
||||||
|
|
||||||
|
# ⚠️ PROJECT-AGNOSTIC — this is the #1 rule
|
||||||
|
The project can be ANY kind: web app, mobile app, desktop app, CLI tool, backend service/API, data/ML pipeline, game, embedded/firmware, browser extension, library/SDK, automation script, smart-contract, etc. DO NOT assume it is a web app or Node/Next.js. Infer the RIGHT stack, roles, file layout, interfaces, CI commands and security concerns for the ACTUAL project and domain.
|
||||||
|
Everything below that mentions REST / API / Next.js / NextAuth / npm is ONLY an ILLUSTRATIVE EXAMPLE for web projects — replace ALL of it with what THIS project truly needs (e.g. a Python data pipeline has no REST contract; a CLI has commands not endpoints; a Go service uses go test not npm; a mobile app has screens not pages).
|
||||||
|
|
||||||
|
# Analyze
|
||||||
|
1. Understand the real intent and the concrete, realistic tech stack (languages, frameworks, database, infra).
|
||||||
|
🔴 ARCHITECTURE COHERENCE: pick ONE coherent architecture. A full-stack framework (Next.js / Nuxt / SvelteKit) already provides the backend — do NOT also add a separate backend framework (NestJS / Express / Fastify) unless the requirement explicitly demands a separated service. If two apps are genuinely needed, give each a distinct root dir (e.g. apps/web, apps/api) with its own owner and start command, and state this split explicitly in contract_spec. Never list two overlapping frameworks in stack without defining their boundary.
|
||||||
|
2. Decompose into a MINIMAL but COMPLETE set of specialized employees (usually 4-7): typically backend, frontend, database, testing, devops, and — whenever the project involves auth / permissions / RBAC / sensitive data — a dedicated security engineer. No redundant or vague roles.
|
||||||
|
3. Design one orchestrator (总控) whose ONLY job is to break down tasks, converge the API contract, dispatch, review/audit, and drive acceptance. It does NOT write feature code.
|
||||||
|
|
||||||
|
# Collaboration is the point — every agent must be designed to work WITH the others
|
||||||
|
For EACH employee you MUST fill:
|
||||||
|
- collaborators: the ids of the other agents it hands work to or receives work from.
|
||||||
|
- handoff_in: what it receives from others and in what form.
|
||||||
|
- handoff_out: what it produces for others and in what form (be concrete).
|
||||||
|
|
||||||
|
Team-level you MUST decide — make the contract CONCRETE, not generic:
|
||||||
|
- contract_owner: the id of the single role that OWNS the API contract (usually backend). Others must not change the contract unilaterally.
|
||||||
|
- contract_spec: a CONCRETE contract for THIS project — unified response body with ALL fields (e.g. { code, message, data, requestId }), pagination shape (e.g. { items, page, pageSize, total, hasNext }), error-code table, RBAC permission codes, DTO conventions, and the auth scheme.
|
||||||
|
- auth_scheme: if the project needs auth, ONE exact string (e.g. "Bearer JWT" | "NextAuth Credentials" | "HttpOnly session cookie" | "none"). It MUST appear verbatim in contract_spec and every auth-related agent prompt; env_vars must contain exactly that scheme's secrets (JWT→JWT_SECRET; NextAuth→NEXTAUTH_SECRET+NEXTAUTH_URL; cookie→SESSION_SECRET) and NO other scheme's vars. Never mix schemes.
|
||||||
|
- package_manager: if the stack uses one, ONE value (npm|pnpm|yarn|pip|poetry|cargo|go|... ; else "none"). The install/build commands in ci_steps, the lockfile in devops owner_paths, and .gitignore MUST all match this one (npm→package-lock.json, pnpm→pnpm-lock.yaml, yarn→yarn.lock). Never mix npm with pnpm-lock etc.
|
||||||
|
- interface_kind: what this project exposes — one of "rest" / "graphql" / "grpc" / "cli" / "library" / "gui" / "none". Drives the contract shape.
|
||||||
|
- api_endpoints: ONLY if interface_kind is an API (rest/graphql/grpc) — the actual endpoint/RPC list, each { method, path, desc, owner }. For CLI describe commands, for library the public functions — put those in contract_spec instead and leave api_endpoints empty.
|
||||||
|
- ci_steps: the ACTUAL commands to lint/test/build for THIS stack, in order (e.g. Node: ["npm ci","npm test","npm run build"]; Python: ["pip install -r requirements.txt","ruff check .","pytest"]; Go: ["go vet ./...","go test ./..."]; Rust: ["cargo test"]). This generates the CI — do NOT default to npm unless it's actually Node.
|
||||||
|
- env_vars: a COMPLETE list of what THIS project needs, each { name, desc } (only if it has env/config; a pure library may have none). Cover DB/connection, auth/secrets, external services, storage, runtime — whatever fits the stack.
|
||||||
|
- db_models: the main data entities IF the project has a datastore (may be empty for stateless tools).
|
||||||
|
- security_checklist: project-specific security/permission checks (extend the standard ones for this project's sensitive areas).
|
||||||
|
- dod_extra: any project-specific "definition of done" items (e.g. responsive breakpoints, safe Markdown/HTML rendering, draft/preview/publish, public-vs-admin boundary, search/tags/archive, migrations reversible).
|
||||||
|
|
||||||
|
# Hard rules
|
||||||
|
- Every agent MUST be constrained to work ONLY inside the current project scope. Always include such a constraint.
|
||||||
|
- Focused, non-overlapping duties; concrete Chinese "prompt" (system instructions) per agent describing role, what it does, and its boundaries.
|
||||||
|
- ALWAYS include a devops-engineer (CI / deploy / path-boundary enforcement / env / root config).
|
||||||
|
|
||||||
|
# File-responsibility rules (owner_paths — 职责/审核归属, NOT an edit restriction; conflicts here break review attribution)
|
||||||
|
- ⚠️ PERMISSION MODEL: every employee's allowed_paths is set by the SYSTEM to ["**"] (open write — any executor can technically edit any file in the project). Do NOT output allowed_paths for employees. What YOU output per role is owner_paths = the paths that role OWNS (职责归属:谁对这片文件负责、谁需要审核这片文件的改动).
|
||||||
|
- owner_paths MUTUALLY EXCLUSIVE across roles: every path owned by exactly ONE role. Never let two roles list the same dir (only ONE role owns "prisma/", the DB schema, etc.).
|
||||||
|
- NO NESTING across roles: no role's owner_path may be a parent of another role's owner_path. "src/app" (frontend) and "src/app/api" (backend) is a CONFLICT — the parent swallows the child.
|
||||||
|
- CROSS-DUTY COLLABORATION: any executor MAY edit files under another role's owner_paths, but the completion report MUST state why it changed them / which role is affected / who must review / whether it touched the interface contract, DB schema, security policy or deploy config — and request the owner's review before merge.
|
||||||
|
- 🔴 OWNER COVERAGE — be COMPREHENSIVE, not minimal: every file category the project will actually contain must have a review owner; 1-2 dirs per role is NOT enough. For the chosen stack, enumerate each role's FULL typical territory. Web/Next.js illustration (adapt to the real stack):
|
||||||
|
- backend: "src/app/api/**" + business layer ("src/server/**" or "src/lib/server/**") + auth ("src/lib/auth/**") + "src/middleware.ts" + shared server types.
|
||||||
|
- frontend: page groups + "src/components/**" + "src/styles/**" + "public/**" (static assets) + client libs it renders ("src/lib/client/**", feature libs like "src/lib/markdown/**") + layout/globals files.
|
||||||
|
- testing: "tests/**","__tests__/**", e2e dir ("playwright/**"), test configs ("playwright.config.*","vitest.config.*","vitest.setup.*","jest.config.*") + "src/test-utils/**".
|
||||||
|
- security: "security/**" + "docs/security/**" (threat model / audit reports live somewhere owned).
|
||||||
|
- devops: ".github/**","scripts/**", infra ("Dockerfile","docker-compose.*","docker/**","deploy/**") + ALL root configs including the lockfile matching package_manager, ".gitignore", build-tool configs ("postcss.config.*","prettier.config.*", etc.).
|
||||||
|
A config file that exists in the project but has NO owner = invalid output.
|
||||||
|
- 🔴 FRAMEWORK ENTRY IS MANDATORY (this is NOT part of the "illustrative example you may discard"): every framework FIXES the physical location of its HTTP route entry, ORM models & migrations. You may organize business logic freely (clean-arch/layers), but you may NOT relocate or omit the framework's fixed entry paths. The backend/API role MUST own the exact entry path and put THIN handlers there (validate + auth + call the business layer, which lives in another path the SAME role owns). Frontend NEVER owns the API entry nor any parent that swallows it. If the stack has a framework below and no role owns its entry path, your output is INVALID.
|
||||||
|
Framework → fixed paths (owner):
|
||||||
|
- Next.js App Router: HTTP entry "src/app/api/**/route.ts" → backend; pages "src/app/(public)/**","src/app/(admin)/**","src/components/**","src/app/layout.tsx","src/app/globals.css" → frontend (NEVER "src/app/**").
|
||||||
|
- Next.js Pages Router: "src/pages/api/**" → backend; "src/pages/**" non-api → frontend.
|
||||||
|
- Nuxt3: "server/api/**","server/routes/**" → backend; "pages/**","components/**" → frontend.
|
||||||
|
- Django: "**/urls.py","**/views.py","*/settings.py","*/asgi.py" → backend; "**/models.py","**/migrations/**" → db; templates/static → frontend.
|
||||||
|
- Rails: "app/controllers/**","config/routes.rb" → backend; "app/models/**","db/migrate/**" → db; "app/views/**" → frontend.
|
||||||
|
- NestJS: "**/*.controller.ts" thin → backend. Spring Boot: "**/*Controller.java" → backend, "**/migration/**" → db.
|
||||||
|
- Vite SPA / Flutter / library / CLI: NO HTTP api dir — do NOT invent api routes; entry is index.html+src/main.* / lib/main.dart / the public API / the command layer.
|
||||||
|
- 🔴 COLOCATION frameworks (SvelteKit / Remix / Astro) put UI and backend in the SAME route tree or SAME file — directory-level split is impossible. Do NOT fabricate fake sub-splits. Instead: (a) split by FILE-SUFFIX glob when possible — SvelteKit backend owns "src/routes/**/+server.ts" + "src/lib/server/**", frontend owns "src/routes/**/+page.svelte","+layout.svelte","+page.ts"; (b) if backend+UI share one file (Remix loader/action in app/routes/x.tsx), assign the whole feature folder to ONE owner and note it in that role's constraints. Always use glob suffix patterns (not bare dirs) so CODEOWNERS and the path-check script can tell them apart.
|
||||||
|
- Consistent API prefix: pick ONE prefix for ALL endpoints (e.g. all under "/api/v1/…" OR all "/api/…") and use it everywhere in api_endpoints + contract_spec.
|
||||||
|
- ROOT CONFIG files (package.json, tsconfig.json, next.config.*, tailwind.config.*, eslint config, .env.example, etc.) MUST be owned by exactly ONE role → give them to devops-engineer. Never leave them unowned (bootstrap always edits them).
|
||||||
|
- devops-engineer owns ".github/", "scripts/", "infra/", docker/deploy files AND the root config files above.
|
||||||
|
- security-engineer OWNS only its own files ("security/" or "docs/security"); it PROVIDES the checklist and reviews. Security TEST code is implemented by the testing/QA role (security keeps it in review_paths).
|
||||||
|
- 🔴 OWNERSHIP COMPLETENESS (NOT part of the discardable illustrative example):
|
||||||
|
- If the project has a datastore (db_models non-empty OR the stack contains a DB/ORM), exactly ONE role must own that stack's schema + migration path (Prisma → "prisma/**"; Django → "**/migrations/**"; Rails → "db/**"; generic → "migrations/**"). If a dedicated database/data role EXISTS, the schema+migration paths MUST belong to that role — NEVER give them to backend as well (no duplicate claims). Only when there is no dedicated db role does that path go to the backend / contract_owner role.
|
||||||
|
- Exactly ONE role (usually devops-engineer) must own "README.md" and the TOP-LEVEL docs files "docs/*.md" (the collaboration-doc maintainer) — otherwise the DoD item "README / change notes updated" has no legal executor. NEVER give anyone broad "docs/**": role-specific doc subdirs (e.g. "docs/security/**" → security role, "docs/api/**" → backend) belong to their own roles, and a broad docs/** would swallow them AND the shared coordination zone.
|
||||||
|
- "docs/coordination/**" is the SHARED coordination area (handoff / acceptance / decision records that EVERY role writes to). Do NOT assign it to any single role and do NOT list it in any owner_paths — the boundary checker whitelists it globally, and CODEOWNERS explicitly clears its ownership.
|
||||||
|
- SECURITY COLLABORATION: if a security-type role exists, every role in charge of auth / login / session / upload / permissions / RBAC (at least the backend role) MUST include it in collaborators, and the security role must list those roles back in its own collaborators.
|
||||||
|
- If a role needs to REVIEW another's area beyond its own duty, put it in review_paths (read/audit focus), NOT owner_paths.
|
||||||
|
- orchestrator: READ-ONLY — allowed_paths = [] AND owner_paths = [] (writes NO files, ever); review_paths = ["**"]. It only decomposes, dispatches, converges the contract, arbitrates and accepts.
|
||||||
|
- ids: lowercase english, kebab-case, unique. 🔴 EVERY role reference — collaborators / contract_owner / api_endpoints[].owner AND any role name you write inside an agent's "prompt" text — MUST be an exact id from employees[].id. Do NOT invent a role name (e.g. don't write "qa-engineer" if the testing employee's id is "testing-engineer"). Decide each role's id ONCE and use that exact string everywhere.
|
||||||
|
|
||||||
|
# Output — CRITICAL
|
||||||
|
Return ONLY one valid JSON object, no markdown, no code fences.
|
||||||
|
⚠️ The values below are a WEB-PROJECT EXAMPLE just to show the SHAPE — replace EVERY value with what THIS project actually is (roles, paths, interface_kind, ci_steps, contract, env, models must all fit the real stack/domain; a non-web project will look completely different).
|
||||||
|
|
||||||
|
{
|
||||||
|
"project_name": "简短项目名",
|
||||||
|
"stack": ["按真实技术栈", "..."],
|
||||||
|
"summary": "一句话概括你对项目的理解",
|
||||||
|
"interface_kind": "rest | graphql | grpc | cli | library | gui | none",
|
||||||
|
"auth_scheme": "有鉴权才填的单一方案,如 Bearer JWT / NextAuth Credentials / HttpOnly session cookie / none",
|
||||||
|
"package_manager": "如 npm|pnpm|yarn|pip|poetry|cargo|go;无则 none",
|
||||||
|
"contract_owner": "某个角色 id(有对外契约时)",
|
||||||
|
"contract_spec": "本项目的契约/约定——Web 是响应体/分页/错误码/鉴权;CLI 是命令与参数约定;库是公开 API 与语义化版本;数据管道是输入输出 schema 与调度约定。按真实项目写。",
|
||||||
|
"api_endpoints": [
|
||||||
|
{ "method": "GET", "path": "/api/v1/...", "desc": "仅当是 API 项目时填", "owner": "backend-engineer" }
|
||||||
|
],
|
||||||
|
"ci_steps": ["按真实技术栈的 lint/测试/构建命令,如 go test ./... 或 pytest 或 npm test"],
|
||||||
|
"env_vars": [
|
||||||
|
{ "name": "按真实项目", "desc": "有才写,纯库可为空" }
|
||||||
|
],
|
||||||
|
"db_models": ["有数据存储才写,无状态工具可为空"],
|
||||||
|
"security_checklist": ["按本项目真实安全点(Web 是越权/XSS;CLI 是命令注入/密钥;固件是刷写签名…)"],
|
||||||
|
"dod_extra": ["本项目特有的完成标准(按真实项目,不要照搬 Web 博客那套)"],
|
||||||
|
"orchestrator": {
|
||||||
|
"id": "orchestrator",
|
||||||
|
"name": "总控 Agent",
|
||||||
|
"duty": "调度 + 契约收敛 + 审核 + 验收",
|
||||||
|
"responsibilities": ["职责点"],
|
||||||
|
"allowed_paths": [],
|
||||||
|
"review_paths": ["**"],
|
||||||
|
"constraints": ["仅在当前项目内工作", "只做调度与审核,不直接实现业务代码,不写入任何文件"],
|
||||||
|
"prompt": "总控的完整中文系统指令"
|
||||||
|
},
|
||||||
|
"employees": [
|
||||||
|
{
|
||||||
|
"id": "backend-engineer",
|
||||||
|
"name": "后端工程师",
|
||||||
|
"duty": "一句话职责",
|
||||||
|
"responsibilities": ["职责点"],
|
||||||
|
"owner_paths": ["src/server"],
|
||||||
|
"review_paths": ["src/db"],
|
||||||
|
"collaborators": ["frontend-engineer", "db-engineer"],
|
||||||
|
"handoff_in": "从数据库工程师接收表结构与迁移脚本",
|
||||||
|
"handoff_out": "向前端交付定稿的接口契约与联调地址",
|
||||||
|
"constraints": ["仅在当前项目内操作", "不擅自变更接口契约"],
|
||||||
|
"prompt": "该角色的完整中文系统指令"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`;
|
||||||
|
|
||||||
|
function buildEmployeeInstruction(requirement, tool) {
|
||||||
|
const toolNote =
|
||||||
|
tool === 'codex'
|
||||||
|
? '目标工具是 Codex,总控 agent 专门负责调度、契约收敛与代码审核、不直接写业务代码。'
|
||||||
|
: tool === 'claude'
|
||||||
|
? '目标工具是 Claude Code(员工对应 subagent)。'
|
||||||
|
: '生成通用的 agent 团队。';
|
||||||
|
return `${toolNote}\n\n客户的项目需求如下,请严格按 System 的 JSON schema 输出(只返回 JSON):\n\n"""${requirement}"""`;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
SYSTEM_PROMPT,
|
||||||
|
USER_INSTRUCTION,
|
||||||
|
EMPLOYEE_SYSTEM_PROMPT,
|
||||||
|
buildEmployeeInstruction,
|
||||||
|
TEAM_CONVENTIONS,
|
||||||
|
ORCHESTRATOR_WORKFLOW,
|
||||||
|
TASK_CHARTER,
|
||||||
|
SECURITY_CHECKLIST,
|
||||||
|
};
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,691 @@
|
|||||||
|
// token_inject.js —— 注入到 Codex 页面的 Token 用量 UI 脚本,以字符串形式导出。
|
||||||
|
// 由 voice_cdp.js 经 CDP Runtime.evaluate 注入(复用语音 CDP 连接,无需独立端口)。
|
||||||
|
//
|
||||||
|
// 职责(仅 UI 层):
|
||||||
|
// 1. 在输入框下方工具栏内创建 token 用量条(优先放在模型选择左侧)
|
||||||
|
// 2. 读写 localStorage["td-token-daily"](按日期分桶,跨天自动清零)
|
||||||
|
// 3. 暴露 window.__tdTokenAdd(usage) 给主进程调用来累加并触发动画
|
||||||
|
// 4. 暴露 window.__tdTokenGet() / __tdTokenReset() 给主进程调用来读取/重置
|
||||||
|
// 5. 页面内兜底解析 fetch/XHR usage 与 Codex DOM token 文本
|
||||||
|
//
|
||||||
|
// Token 抓取优先由 voice_cdp.js 在 CDP 层面通过 Network.responseReceived 完成,
|
||||||
|
// 页面内 fetch/XHR 解析只作为兜底。
|
||||||
|
//
|
||||||
|
// 注意:脚本体用普通模板字符串包裹;内部避免出现 ${ 与裸反引号。
|
||||||
|
|
||||||
|
const TOKEN_INJECT_SCRIPT = `
|
||||||
|
(() => {
|
||||||
|
const VER = "td-token-v17";
|
||||||
|
if (window.__tdTokenInstalled === VER) return;
|
||||||
|
if (window.__tdTokenObserver) { try { window.__tdTokenObserver.disconnect(); } catch {} }
|
||||||
|
const oldBar = document.getElementById("td-token-bar");
|
||||||
|
if (oldBar) oldBar.remove();
|
||||||
|
const oldRow = document.getElementById("td-token-center-row");
|
||||||
|
if (oldRow) oldRow.remove();
|
||||||
|
["td-token-style-v4", "td-token-style-v5", "td-token-style-v6", "td-token-style-v7", "td-token-style-v8", "td-token-style-v9", "td-token-style-v10", "td-token-style-v11", "td-token-style-v12", "td-token-style-v13", "td-token-style-v14", "td-token-style-v15", "td-token-style-v16", "td-token-style-v17"].forEach((id) => {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el) el.remove();
|
||||||
|
});
|
||||||
|
window.__tdTokenInstalled = VER;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 1. 持久化
|
||||||
|
// ============================================================================
|
||||||
|
const STORE_KEY = "td-token-daily";
|
||||||
|
function todayKey() {
|
||||||
|
const d = new Date();
|
||||||
|
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||||
|
const day = String(d.getDate()).padStart(2, "0");
|
||||||
|
return d.getFullYear() + "-" + m + "-" + day;
|
||||||
|
}
|
||||||
|
function loadDaily() {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORE_KEY);
|
||||||
|
if (!raw) return blankDaily();
|
||||||
|
const d = JSON.parse(raw);
|
||||||
|
if (d.date !== todayKey()) return blankDaily();
|
||||||
|
return normalizeDaily(d);
|
||||||
|
} catch { return blankDaily(); }
|
||||||
|
}
|
||||||
|
function blankDaily() {
|
||||||
|
return { date: todayKey(), total: 0, input: 0, output: 0, cached: 0, estimate: 0, unknown: 0, lastSource: "" };
|
||||||
|
}
|
||||||
|
function cleanNum(n) {
|
||||||
|
n = Number(n);
|
||||||
|
return Number.isFinite(n) && n > 0 ? Math.round(n) : 0;
|
||||||
|
}
|
||||||
|
function normalizeDaily(d) {
|
||||||
|
const out = blankDaily();
|
||||||
|
out.date = d && d.date ? d.date : todayKey();
|
||||||
|
out.input = cleanNum(d && d.input);
|
||||||
|
out.output = cleanNum(d && d.output);
|
||||||
|
out.cached = cleanNum(d && d.cached);
|
||||||
|
out.estimate = cleanNum(d && (d.estimate || d.estimated));
|
||||||
|
out.unknown = cleanNum(d && d.unknown);
|
||||||
|
out.total = cleanNum(d && d.total) || out.input + out.output + out.cached + out.estimate + out.unknown;
|
||||||
|
const known = out.input + out.output + out.cached + out.estimate + out.unknown;
|
||||||
|
if (out.total > known) out.unknown += out.total - known;
|
||||||
|
out.lastSource = String((d && (d.lastSource || d.source)) || "");
|
||||||
|
if (out.unknown && !out.lastSource) out.lastSource = "legacy";
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
function saveDaily(data) {
|
||||||
|
try { localStorage.setItem(STORE_KEY, JSON.stringify(normalizeDaily(data))); } catch {}
|
||||||
|
}
|
||||||
|
let daily = loadDaily();
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 2. 创建 UI
|
||||||
|
// ============================================================================
|
||||||
|
const BAR_ID = "td-token-bar";
|
||||||
|
const STYLE_ID = "td-token-style-v17";
|
||||||
|
const TOKEN_CAP = 5000000000;
|
||||||
|
|
||||||
|
if (!document.getElementById(STYLE_ID)) {
|
||||||
|
const st = document.createElement("style");
|
||||||
|
st.id = STYLE_ID;
|
||||||
|
st.textContent = \`
|
||||||
|
#td-token-bar{
|
||||||
|
display:inline-block;position:relative;
|
||||||
|
flex:0 0 auto;width:228px;max-width:32vw;height:28px;
|
||||||
|
margin:0 6px;padding:0;border-radius:999px;
|
||||||
|
font:11px system-ui,-apple-system,sans-serif;
|
||||||
|
user-select:none;-webkit-app-region:no-drag;
|
||||||
|
pointer-events:none;opacity:.96;transition:opacity .18s,filter .18s;
|
||||||
|
background:transparent;border:0;
|
||||||
|
vertical-align:middle;box-sizing:border-box;
|
||||||
|
}
|
||||||
|
#td-token-bar:hover{opacity:1;filter:saturate(1.08)}
|
||||||
|
.td-tk-track{
|
||||||
|
position:absolute;left:0;right:0;top:50%;height:28px;
|
||||||
|
border-radius:8px;transform:translateY(-50%);
|
||||||
|
background:#fff;overflow:hidden;
|
||||||
|
box-shadow:0 1px 0 rgba(255,255,255,.92) inset,0 0 0 1px rgba(15,23,42,.10),0 5px 16px rgba(15,23,42,.20);
|
||||||
|
}
|
||||||
|
.td-tk-segments{
|
||||||
|
position:absolute;left:18px;right:72px;top:50%;
|
||||||
|
display:grid;grid-template-columns:repeat(8,1fr);gap:3px;
|
||||||
|
height:12px;transform:translateY(-50%);
|
||||||
|
}
|
||||||
|
.td-tk-seg{
|
||||||
|
position:relative;overflow:hidden;border-radius:3px;transform:skewX(-7deg);
|
||||||
|
background:linear-gradient(180deg,rgba(138,217,255,.22),rgba(23,103,204,.22));
|
||||||
|
box-shadow:0 0 0 1px rgba(54,157,239,.18) inset;
|
||||||
|
}
|
||||||
|
.td-tk-seg::before{
|
||||||
|
content:"";position:absolute;left:0;top:0;bottom:0;width:var(--td-tk-seg-fill,0%);
|
||||||
|
border-radius:inherit;background:linear-gradient(180deg,#52c8ff 0%,#2673dd 58%,#1554c5 100%);
|
||||||
|
box-shadow:0 0 9px rgba(42,163,255,.72),0 1px 0 rgba(255,255,255,.45) inset;
|
||||||
|
transition:width .8s cubic-bezier(.25,.8,.25,1);
|
||||||
|
}
|
||||||
|
.td-tk-segments.beat .td-tk-seg::before{animation:tdTkBeat .55s ease}
|
||||||
|
.td-tk-segments::after{
|
||||||
|
content:"";position:absolute;left:-30px;top:-3px;bottom:-3px;width:24px;
|
||||||
|
pointer-events:none;opacity:0;border-radius:999px;
|
||||||
|
background:linear-gradient(90deg,transparent,rgba(255,255,255,.9),transparent);
|
||||||
|
transform:skewX(-12deg);
|
||||||
|
}
|
||||||
|
.td-tk-segments.beat::after{animation:tdTkSweep .7s ease}
|
||||||
|
.td-tk-segments.beat + .td-tk-badge{animation:tdTkBadgeBeat .62s ease}
|
||||||
|
@keyframes tdTkBeat{
|
||||||
|
0%{filter:brightness(1)}
|
||||||
|
45%{filter:brightness(1.26)}
|
||||||
|
100%{filter:brightness(1)}
|
||||||
|
}
|
||||||
|
@keyframes tdTkSweep{
|
||||||
|
0%{left:-30px;opacity:0}
|
||||||
|
18%{opacity:.9}
|
||||||
|
100%{left:calc(100% + 30px);opacity:0}
|
||||||
|
}
|
||||||
|
@keyframes tdTkBadgeBeat{
|
||||||
|
0%{transform:translateY(-50%) scale(1)}
|
||||||
|
45%{transform:translateY(-50%) scale(1.08);filter:brightness(1.18)}
|
||||||
|
100%{transform:translateY(-50%) scale(1)}
|
||||||
|
}
|
||||||
|
.td-tk-badge{
|
||||||
|
position:absolute;right:18px;top:50%;z-index:2;
|
||||||
|
display:inline-flex;align-items:center;gap:3px;height:18px;padding:0 7px 0 5px;
|
||||||
|
border-radius:7px;transform:translateY(-50%);
|
||||||
|
color:#fff;font-size:11px;font-weight:800;letter-spacing:0;white-space:nowrap;
|
||||||
|
background:linear-gradient(180deg,#63c8ff 0%,#2c77df 64%,#1f60c9 100%);
|
||||||
|
box-shadow:0 0 12px rgba(53,159,246,.75),0 1px 0 rgba(255,255,255,.45) inset;
|
||||||
|
}
|
||||||
|
.td-tk-check{
|
||||||
|
display:inline-flex;align-items:center;justify-content:center;width:12px;height:12px;
|
||||||
|
border-radius:50%;background:#fff;color:#266bd5;font-size:10px;line-height:1;
|
||||||
|
}
|
||||||
|
.td-tk-cap{
|
||||||
|
position:absolute;right:7px;bottom:4px;width:10px;height:12px;border-radius:3px 3px 4px 4px;
|
||||||
|
background:linear-gradient(180deg,#dcffff 0%,#61ddfb 100%);
|
||||||
|
box-shadow:0 0 8px rgba(82,222,255,.75);opacity:.95;
|
||||||
|
}
|
||||||
|
.td-tk-cap::before{
|
||||||
|
content:"";position:absolute;left:2px;right:2px;top:-3px;height:3px;border-radius:2px 2px 0 0;
|
||||||
|
background:#cfffff;
|
||||||
|
}
|
||||||
|
.td-tk-delta{
|
||||||
|
position:absolute;right:38px;top:-8px;z-index:3;
|
||||||
|
font-size:10px;font-weight:700;white-space:nowrap;color:#fff;
|
||||||
|
padding:1px 5px;border-radius:999px;background:#2b7ee7;
|
||||||
|
opacity:0;transform:translateY(2px);transition:none;
|
||||||
|
}
|
||||||
|
.td-tk-delta.show{
|
||||||
|
animation:tdTkDeltaIn 1.6s ease forwards;
|
||||||
|
}
|
||||||
|
@keyframes tdTkDeltaIn{
|
||||||
|
0%{opacity:0;transform:translateY(2px)}
|
||||||
|
20%{opacity:1;transform:translateY(0)}
|
||||||
|
85%{opacity:1;transform:translateY(0)}
|
||||||
|
100%{opacity:0;transform:translateY(-4px)}
|
||||||
|
}
|
||||||
|
@media (max-width:640px){#td-token-bar{width:186px;max-width:44vw;margin:0 4px}.td-tk-segments{left:14px;right:58px;gap:2px}.td-tk-badge{right:12px;font-size:10px;padding:0 5px;height:17px}.td-tk-cap{display:none}}
|
||||||
|
@media (prefers-color-scheme:dark){
|
||||||
|
.td-tk-track{background:#fff;box-shadow:0 1px 0 rgba(255,255,255,.92) inset,0 0 0 1px rgba(255,255,255,.2),0 5px 16px rgba(0,0,0,.36)}
|
||||||
|
}
|
||||||
|
\`;
|
||||||
|
(document.head || document.documentElement).appendChild(st);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBar() {
|
||||||
|
const div = document.createElement("div");
|
||||||
|
div.id = BAR_ID;
|
||||||
|
div.innerHTML =
|
||||||
|
'<div class="td-tk-track"><div class="td-tk-segments" id="td-tk-segments">' +
|
||||||
|
'<i class="td-tk-seg"></i><i class="td-tk-seg"></i><i class="td-tk-seg"></i><i class="td-tk-seg"></i>' +
|
||||||
|
'<i class="td-tk-seg"></i><i class="td-tk-seg"></i><i class="td-tk-seg"></i><i class="td-tk-seg"></i>' +
|
||||||
|
'</div><span class="td-tk-badge"><span class="td-tk-check">✓</span><span id="td-tk-badge-text">0 token</span></span><span class="td-tk-cap"></span></div>' +
|
||||||
|
'<span class="td-tk-delta" id="td-tk-delta"></span>';
|
||||||
|
return div;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 3. 格式化 + 颜色 + 渲染
|
||||||
|
// ============================================================================
|
||||||
|
function fmtTokens(n) {
|
||||||
|
if (n < 1000) return String(n);
|
||||||
|
if (n < 1000000) return (n / 1000).toFixed(1) + "K";
|
||||||
|
if (n < 1000000000) return (n / 1000000).toFixed(2) + "M";
|
||||||
|
return (n / 1000000000).toFixed(2) + "B";
|
||||||
|
}
|
||||||
|
function fmtBadgeTokens(n) {
|
||||||
|
n = cleanNum(n);
|
||||||
|
if (n < 1000) return n + " token";
|
||||||
|
if (n < 1000000) return (n / 1000).toFixed(1).replace(/\.0$/, "") + "k token";
|
||||||
|
if (n < 1000000000) return (n / 1000000).toFixed(1).replace(/\.0$/, "") + "M token";
|
||||||
|
return (n / 1000000000).toFixed(1).replace(/\.0$/, "") + "B token";
|
||||||
|
}
|
||||||
|
function fmtDelta(n) {
|
||||||
|
if (n < 1000) return "+" + n;
|
||||||
|
if (n < 1000000) return "+" + (n / 1000).toFixed(1) + "K";
|
||||||
|
if (n < 1000000000) return "+" + (n / 1000000).toFixed(2) + "M";
|
||||||
|
return "+" + (n / 1000000000).toFixed(2) + "B";
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillColor(total) {
|
||||||
|
return "linear-gradient(180deg,#52c8ff 0%,#2673dd 58%,#1554c5 100%)";
|
||||||
|
}
|
||||||
|
|
||||||
|
function progressPct(total) {
|
||||||
|
total = cleanNum(total);
|
||||||
|
if (!total) return 0;
|
||||||
|
return Math.max(0.8, Math.min(100, (total / TOKEN_CAP) * 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5B 上限下,低于几十 MB 的线性变化肉眼几乎不可见;视觉分段采用对数映射,实际百分比仍保留在线程提示中。
|
||||||
|
function visualProgressPct(total) {
|
||||||
|
total = cleanNum(total);
|
||||||
|
if (!total) return 0;
|
||||||
|
const scale = 100000;
|
||||||
|
const pct = Math.log1p(total / scale) / Math.log1p(TOKEN_CAP / scale) * 100;
|
||||||
|
return Math.max(2.4, Math.min(100, pct));
|
||||||
|
}
|
||||||
|
|
||||||
|
function paintSegments(pct) {
|
||||||
|
const segs = Array.from(document.querySelectorAll(".td-tk-seg"));
|
||||||
|
if (!segs.length) return;
|
||||||
|
const span = 100 / segs.length;
|
||||||
|
segs.forEach((seg, i) => {
|
||||||
|
const start = i * span;
|
||||||
|
const fill = Math.max(0, Math.min(100, ((pct - start) / span) * 100));
|
||||||
|
seg.style.setProperty("--td-tk-seg-fill", fill + "%");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let beatTimer = null;
|
||||||
|
function triggerBeat() {
|
||||||
|
const segments = document.getElementById("td-tk-segments");
|
||||||
|
if (!segments) return;
|
||||||
|
render();
|
||||||
|
segments.classList.remove("beat");
|
||||||
|
void segments.offsetWidth;
|
||||||
|
segments.classList.add("beat");
|
||||||
|
clearTimeout(beatTimer);
|
||||||
|
beatTimer = setTimeout(() => {
|
||||||
|
segments.classList.remove("beat");
|
||||||
|
}, 650);
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
daily = normalizeDaily(daily);
|
||||||
|
const bar = document.getElementById(BAR_ID);
|
||||||
|
const badge = document.getElementById("td-tk-badge-text");
|
||||||
|
const pct = visualProgressPct(daily.total);
|
||||||
|
paintSegments(pct);
|
||||||
|
if (badge) badge.textContent = fmtBadgeTokens(daily.total);
|
||||||
|
if (bar) {
|
||||||
|
bar.title = usageTitle(daily);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function usageTitle(d) {
|
||||||
|
d = normalizeDaily(d);
|
||||||
|
const parts = ["今日 " + fmtTokens(d.total), "额度 5B", "进度 " + progressPct(d.total).toFixed(4).replace(/0+$/,"").replace(/\\.$/,"") + "%"];
|
||||||
|
if (d.input) parts.push("输入 " + fmtTokens(d.input));
|
||||||
|
if (d.output) parts.push("输出 " + fmtTokens(d.output));
|
||||||
|
if (d.cached) parts.push("缓存 " + fmtTokens(d.cached));
|
||||||
|
if (d.estimate) parts.push("估算 " + fmtTokens(d.estimate));
|
||||||
|
if (d.unknown) parts.push("未知 " + fmtTokens(d.unknown));
|
||||||
|
if (d.lastSource) parts.push("来源 " + d.lastSource);
|
||||||
|
return parts.join(" · ");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 4. 动画:主进程通过 Runtime.evaluate 调用此函数触发
|
||||||
|
// ============================================================================
|
||||||
|
let lastNetworkAt = 0;
|
||||||
|
let lastPageUsageAt = 0;
|
||||||
|
let estimatedSinceUsage = 0;
|
||||||
|
let recentRealAdds = [];
|
||||||
|
let lastSessionTotal = null;
|
||||||
|
function showDelta(total) {
|
||||||
|
const delta = document.getElementById("td-tk-delta");
|
||||||
|
if (!delta || total <= 0) return;
|
||||||
|
delta.textContent = fmtDelta(total);
|
||||||
|
delta.classList.remove("show");
|
||||||
|
void delta.offsetWidth;
|
||||||
|
delta.classList.add("show");
|
||||||
|
}
|
||||||
|
function normalizeUsagePayload(payload, source) {
|
||||||
|
if (typeof payload === "number" || typeof payload === "string") {
|
||||||
|
return { total: cleanNum(payload), input: 0, output: 0, cached: 0, estimate: 0, source: source || "manual" };
|
||||||
|
}
|
||||||
|
const u = payload && typeof payload === "object" ? payload : {};
|
||||||
|
const input = cleanNum(u.input || u.input_tokens || u.prompt_tokens || u.promptTokenCount || u.prompt_token_count || u.inputTokenCount);
|
||||||
|
const output = cleanNum(u.output || u.output_tokens || u.completion_tokens || u.candidatesTokenCount || u.candidates_token_count || u.outputTokenCount);
|
||||||
|
const reasoning = cleanNum(u.reasoning_tokens || u.thoughtsTokenCount || u.thoughts_token_count);
|
||||||
|
const cached = cleanNum(u.cached || u.cached_tokens || u.cache_creation_input_tokens || 0) +
|
||||||
|
cleanNum(u.cache_read_input_tokens || 0) +
|
||||||
|
cleanNum(u.cachedContentTokenCount || 0) +
|
||||||
|
cleanNum(u.cached_content_token_count || 0);
|
||||||
|
const estimate = cleanNum(u.estimate || u.estimated);
|
||||||
|
const unknown = cleanNum(u.unknown);
|
||||||
|
const total = cleanNum(u.total || u.total_tokens || u.totalTokenCount || u.total_token_count || u.token_count) || input + output + reasoning + cached + estimate + unknown;
|
||||||
|
return { total, input, output: output + reasoning, cached, estimate, unknown, source: String(source || u.source || "manual") };
|
||||||
|
}
|
||||||
|
function animateTokenAdd(payload, source) {
|
||||||
|
const usage = normalizeUsagePayload(payload, source);
|
||||||
|
if (usage.total <= 0) return;
|
||||||
|
const src = usage.source || "manual";
|
||||||
|
const now = Date.now();
|
||||||
|
if (src === "network" || src === "page") {
|
||||||
|
recentRealAdds = recentRealAdds.filter((item) => now - item.at < 15000);
|
||||||
|
if (recentRealAdds.some((item) => item.total === usage.total && item.source !== src)) return;
|
||||||
|
recentRealAdds.push({ total: usage.total, source: src, at: now });
|
||||||
|
}
|
||||||
|
if (src === "network") lastNetworkAt = now;
|
||||||
|
if (src === "page") lastPageUsageAt = now;
|
||||||
|
if (src === "dom" && (now - lastNetworkAt < 12000 || now - lastPageUsageAt < 12000)) return;
|
||||||
|
if (src === "page" && now - lastNetworkAt < 8000) return;
|
||||||
|
if (src === "network" || src === "page") {
|
||||||
|
daily = loadDaily();
|
||||||
|
const subtract = Math.min(estimatedSinceUsage || daily.estimate, daily.total);
|
||||||
|
if (subtract > 0) {
|
||||||
|
daily.total = Math.max(0, daily.total - subtract);
|
||||||
|
daily.estimate = Math.max(0, daily.estimate - subtract);
|
||||||
|
saveDaily(daily);
|
||||||
|
estimatedSinceUsage = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
daily = loadDaily();
|
||||||
|
daily.total += usage.total;
|
||||||
|
daily.input += usage.input;
|
||||||
|
daily.output += usage.output;
|
||||||
|
daily.cached += usage.cached;
|
||||||
|
daily.unknown += usage.unknown;
|
||||||
|
if (src === "estimate") {
|
||||||
|
const est = usage.estimate || usage.total;
|
||||||
|
daily.estimate += est;
|
||||||
|
estimatedSinceUsage += est;
|
||||||
|
}
|
||||||
|
daily.lastSource = src;
|
||||||
|
saveDaily(daily);
|
||||||
|
// 本地 session 已接管时,Network 只保留兜底记账,不重复播放动画。
|
||||||
|
if (lastSessionTotal === null || src === "session") triggerBeat();
|
||||||
|
const shouldShowDelta = src !== "estimate" && src !== "dom";
|
||||||
|
if (shouldShowDelta && (lastSessionTotal === null || src === "session")) showDelta(usage.total);
|
||||||
|
}
|
||||||
|
|
||||||
|
function usageTotal(u) {
|
||||||
|
return u ? cleanNum(u.total) : 0;
|
||||||
|
}
|
||||||
|
function betterUsage(a, b) {
|
||||||
|
return usageTotal(b) > usageTotal(a) ? b : a;
|
||||||
|
}
|
||||||
|
function hasOwn(obj, key) {
|
||||||
|
return Object.prototype.hasOwnProperty.call(obj, key);
|
||||||
|
}
|
||||||
|
function looksLikeUsage(usage) {
|
||||||
|
if (!usage || typeof usage !== "object") return false;
|
||||||
|
const keys = [
|
||||||
|
"input_tokens", "prompt_tokens", "output_tokens", "completion_tokens", "total_tokens",
|
||||||
|
"promptTokenCount", "prompt_token_count", "inputTokenCount", "outputTokenCount",
|
||||||
|
"candidatesTokenCount", "candidates_token_count", "totalTokenCount", "total_token_count",
|
||||||
|
"token_count", "cache_creation_input_tokens", "cache_read_input_tokens",
|
||||||
|
"cachedContentTokenCount", "cached_content_token_count", "reasoning_tokens",
|
||||||
|
"thoughtsTokenCount", "thoughts_token_count"
|
||||||
|
];
|
||||||
|
if (keys.some((key) => hasOwn(usage, key))) return true;
|
||||||
|
return hasOwn(usage, "total") && (hasOwn(usage, "input") || hasOwn(usage, "output") || hasOwn(usage, "cached"));
|
||||||
|
}
|
||||||
|
function extractUsageFromRawUsage(usage) {
|
||||||
|
if (!usage || typeof usage !== "object") return null;
|
||||||
|
if (!looksLikeUsage(usage)) return null;
|
||||||
|
const input = cleanNum(usage.input_tokens || usage.prompt_tokens || usage.promptTokenCount || usage.prompt_token_count || usage.inputTokenCount);
|
||||||
|
const output = cleanNum(usage.output_tokens || usage.completion_tokens || usage.candidatesTokenCount || usage.candidates_token_count || usage.outputTokenCount);
|
||||||
|
const reasoning = cleanNum(usage.reasoning_tokens || usage.thoughtsTokenCount || usage.thoughts_token_count);
|
||||||
|
const details = usage.input_tokens_details || usage.prompt_tokens_details || usage.inputTokenDetails || usage.promptTokenDetails || {};
|
||||||
|
const cached = cleanNum(details.cached_tokens) +
|
||||||
|
cleanNum(usage.cache_creation_input_tokens) +
|
||||||
|
cleanNum(usage.cache_read_input_tokens) +
|
||||||
|
cleanNum(usage.cachedContentTokenCount) +
|
||||||
|
cleanNum(usage.cached_content_token_count);
|
||||||
|
const total = cleanNum(usage.total_tokens || usage.totalTokenCount || usage.total_token_count || usage.token_count || usage.total) || input + output + reasoning + cached;
|
||||||
|
if (!total) return null;
|
||||||
|
return { total, input, output: output + reasoning, cached };
|
||||||
|
}
|
||||||
|
function extractUsageFromObject(obj) {
|
||||||
|
if (!obj || typeof obj !== "object") return null;
|
||||||
|
const direct = extractUsageFromRawUsage(obj.usage) ||
|
||||||
|
extractUsageFromRawUsage(obj.usage_metadata) ||
|
||||||
|
extractUsageFromRawUsage(obj.usageMetadata) ||
|
||||||
|
extractUsageFromRawUsage(obj);
|
||||||
|
if (direct) return direct;
|
||||||
|
let best = null;
|
||||||
|
for (const val of Object.values(obj)) {
|
||||||
|
if (!val || typeof val !== "object") continue;
|
||||||
|
if (Array.isArray(val)) {
|
||||||
|
for (const item of val) best = betterUsage(best, extractUsageFromObject(item));
|
||||||
|
} else {
|
||||||
|
best = betterUsage(best, extractUsageFromObject(val));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractUsageFromText(text) {
|
||||||
|
let best = null;
|
||||||
|
const take = (obj) => { best = betterUsage(best, extractUsageFromObject(obj)); };
|
||||||
|
try { take(JSON.parse(text)); } catch {}
|
||||||
|
const lines = String(text || "").split(String.fromCharCode(13)).join("").split(String.fromCharCode(10));
|
||||||
|
for (const line of lines) {
|
||||||
|
const s = line.trim();
|
||||||
|
if (!s.startsWith("data:")) continue;
|
||||||
|
const payload = s.slice(5).trim();
|
||||||
|
if (!payload || payload === "[DONE]") continue;
|
||||||
|
try { take(JSON.parse(payload)); } catch {}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
function installNetworkFallback() {
|
||||||
|
if (window.__tdTokenNetFallbackInstalled === VER) return;
|
||||||
|
window.__tdTokenNetFallbackInstalled = VER;
|
||||||
|
const API_URL_RE = new RegExp("chat/completions|/responses|/messages|conversation|codex", "i");
|
||||||
|
const TEXT_CT_RE = new RegExp("json|text|event-stream", "i");
|
||||||
|
const rawFetch = window.fetch;
|
||||||
|
if (typeof rawFetch === "function") {
|
||||||
|
window.fetch = async function(...args) {
|
||||||
|
const res = await rawFetch.apply(this, args);
|
||||||
|
try {
|
||||||
|
const url = String((args[0] && args[0].url) || args[0] || "");
|
||||||
|
if (API_URL_RE.test(url)) {
|
||||||
|
const ct = (res.headers && res.headers.get && res.headers.get("content-type")) || "";
|
||||||
|
if (TEXT_CT_RE.test(ct)) {
|
||||||
|
res.clone().text().then((txt) => {
|
||||||
|
const usage = extractUsageFromText(txt);
|
||||||
|
if (usage && usage.total > 0) animateTokenAdd(usage, "page");
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const XHR = window.XMLHttpRequest;
|
||||||
|
if (XHR && XHR.prototype) {
|
||||||
|
const rawOpen = XHR.prototype.open;
|
||||||
|
const rawSend = XHR.prototype.send;
|
||||||
|
XHR.prototype.open = function(method, url, ...rest) {
|
||||||
|
try { this.__tdTokenUrl = String(url || ""); } catch {}
|
||||||
|
return rawOpen.call(this, method, url, ...rest);
|
||||||
|
};
|
||||||
|
XHR.prototype.send = function(...args) {
|
||||||
|
try {
|
||||||
|
this.addEventListener("loadend", () => {
|
||||||
|
try {
|
||||||
|
const url = this.__tdTokenUrl || "";
|
||||||
|
const ct = this.getResponseHeader && (this.getResponseHeader("content-type") || "");
|
||||||
|
if (!API_URL_RE.test(url)) return;
|
||||||
|
if (!TEXT_CT_RE.test(ct)) return;
|
||||||
|
const txt = typeof this.responseText === "string" ? this.responseText : "";
|
||||||
|
const usage = extractUsageFromText(txt);
|
||||||
|
if (usage && usage.total > 0) animateTokenAdd(usage, "page");
|
||||||
|
} catch {}
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
return rawSend.apply(this, args);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTokenText(text) {
|
||||||
|
const matches = String(text || "").matchAll(new RegExp("([0-9.,]+) *([KkMm]?) *tokens?", "gi"));
|
||||||
|
let best = 0;
|
||||||
|
for (const m of matches) {
|
||||||
|
let num = parseFloat(String(m[1]).replace(/,/g, ""));
|
||||||
|
if (!isFinite(num) || num <= 0) continue;
|
||||||
|
const unit = String(m[2] || "").toLowerCase();
|
||||||
|
if (unit === "k") num *= 1000;
|
||||||
|
else if (unit === "m") num *= 1000000;
|
||||||
|
best = Math.max(best, Math.round(num));
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
let domTokenBaseline = 0;
|
||||||
|
let domTokenBest = 0;
|
||||||
|
let domScanTimer = null;
|
||||||
|
function scanDomTokenText() {
|
||||||
|
const text = document.body ? document.body.innerText || "" : "";
|
||||||
|
const best = parseTokenText(text);
|
||||||
|
if (best <= 0) return;
|
||||||
|
if (!domTokenBaseline) {
|
||||||
|
domTokenBaseline = best;
|
||||||
|
domTokenBest = best;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (best > domTokenBest) {
|
||||||
|
const delta = best - domTokenBest;
|
||||||
|
domTokenBest = best;
|
||||||
|
if (delta > 0 && delta < 2000000) animateTokenAdd(delta, "dom");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function primeEstimateBaseline() {
|
||||||
|
const raw = document.body ? document.body.innerText || "" : "";
|
||||||
|
const best = parseTokenText(raw);
|
||||||
|
if (best > 0) {
|
||||||
|
domTokenBaseline = best;
|
||||||
|
domTokenBest = best;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleDomScan() {
|
||||||
|
clearTimeout(domScanTimer);
|
||||||
|
domScanTimer = setTimeout(() => {
|
||||||
|
scanDomTokenText();
|
||||||
|
}, 700);
|
||||||
|
}
|
||||||
|
|
||||||
|
function findToolbar() {
|
||||||
|
const rich = document.querySelector("DIV.ProseMirror, [contenteditable], textarea");
|
||||||
|
if (!rich) return null;
|
||||||
|
let p = rich;
|
||||||
|
for (let i = 0; i < 7 && p; i++) {
|
||||||
|
const foot = p.querySelector("[class*='_footer_'], [class*='composer-footer'], footer.composer, [class*='footer'][class*='composer']");
|
||||||
|
if (foot && foot.querySelectorAll("button, [role='button']").length >= 2) return foot;
|
||||||
|
p = p.parentElement;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function visibleText(el) {
|
||||||
|
return String((el && (el.getAttribute("aria-label") || el.title || el.textContent)) || "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function findModelControl(toolbar) {
|
||||||
|
const controls = Array.from(toolbar.querySelectorAll("button, [role='button'], [aria-haspopup], [data-testid*='model'], [class*='model']"));
|
||||||
|
const modelLike = controls.find((el) => /model|模型|gpt|claude|codex|o[0-9]|gemini|deepseek|glm|kimi/i.test(visibleText(el)));
|
||||||
|
if (modelLike) return modelLike;
|
||||||
|
const buttons = Array.from(toolbar.querySelectorAll("button, [role='button']"));
|
||||||
|
return buttons.length > 1 ? buttons[buttons.length - 2] : buttons[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function centerUnderEditor(bar) {
|
||||||
|
const rich = document.querySelector("DIV.ProseMirror, [contenteditable], textarea");
|
||||||
|
if (!rich || !rich.parentElement) return false;
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.id = "td-token-center-row";
|
||||||
|
row.style.cssText = "display:flex;justify-content:center;align-items:center;width:100%;padding:2px 0 0;pointer-events:none;";
|
||||||
|
row.appendChild(bar);
|
||||||
|
const anchor = rich.parentElement;
|
||||||
|
anchor.insertAdjacentElement("afterend", row);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupCenterRow() {
|
||||||
|
const row = document.getElementById("td-token-center-row");
|
||||||
|
if (row && !row.querySelector("#" + BAR_ID)) row.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
function placeInToolbar(bar) {
|
||||||
|
const toolbar = findToolbar();
|
||||||
|
if (!toolbar) return false;
|
||||||
|
const model = findModelControl(toolbar);
|
||||||
|
if (model && model.parentElement && toolbar.contains(model.parentElement)) {
|
||||||
|
model.parentElement.insertBefore(bar, model);
|
||||||
|
} else if (model && toolbar.contains(model)) {
|
||||||
|
toolbar.insertBefore(bar, model);
|
||||||
|
} else {
|
||||||
|
toolbar.appendChild(bar);
|
||||||
|
}
|
||||||
|
cleanupCenterRow();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mountBar() {
|
||||||
|
const existing = document.getElementById(BAR_ID);
|
||||||
|
if (existing) return;
|
||||||
|
const bar = buildBar();
|
||||||
|
if (!placeInToolbar(bar) && !centerUnderEditor(bar)) {
|
||||||
|
(document.body || document.documentElement).appendChild(bar);
|
||||||
|
}
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
function maybeRelocate() {
|
||||||
|
const bar = document.getElementById(BAR_ID);
|
||||||
|
if (!bar) return false;
|
||||||
|
const row = document.getElementById("td-token-center-row");
|
||||||
|
if (row && row.contains(bar)) return placeInToolbar(bar);
|
||||||
|
if (bar.parentElement === document.body || bar.parentElement === document.documentElement) return placeInToolbar(bar);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 5. MutationObserver:页面重绘后补回悬浮条
|
||||||
|
// ============================================================================
|
||||||
|
function tryMount() {
|
||||||
|
if (document.getElementById(BAR_ID)) return;
|
||||||
|
mountBar();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mo = null;
|
||||||
|
function startObserver() {
|
||||||
|
if (mo) return;
|
||||||
|
installNetworkFallback();
|
||||||
|
mo = new MutationObserver((mutations) => {
|
||||||
|
maybeRelocate();
|
||||||
|
if (!document.getElementById(BAR_ID)) tryMount();
|
||||||
|
scheduleDomScan();
|
||||||
|
});
|
||||||
|
window.__tdTokenObserver = mo;
|
||||||
|
const root = document.body || document.documentElement;
|
||||||
|
if (root) mo.observe(root, { childList: true, subtree: true });
|
||||||
|
tryMount();
|
||||||
|
primeEstimateBaseline();
|
||||||
|
scheduleDomScan();
|
||||||
|
}
|
||||||
|
function ensureStarted() {
|
||||||
|
if (document.readyState === "loading") {
|
||||||
|
document.addEventListener("DOMContentLoaded", startObserver, { once: true });
|
||||||
|
} else {
|
||||||
|
startObserver();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ensureStarted();
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 7. 对外 API(主进程通过 Runtime.evaluate 调用)
|
||||||
|
// ============================================================================
|
||||||
|
window.__tdTokenAdd = (usage, source) => {
|
||||||
|
try {
|
||||||
|
const u = normalizeUsagePayload(usage, source || "external");
|
||||||
|
if (!u.total) return "bad";
|
||||||
|
animateTokenAdd(u, u.source);
|
||||||
|
return "ok";
|
||||||
|
} catch { return "err"; }
|
||||||
|
};
|
||||||
|
window.__tdTokenGet = () => {
|
||||||
|
const d = loadDaily();
|
||||||
|
return JSON.stringify(d);
|
||||||
|
};
|
||||||
|
window.__tdTokenSet = (usage) => {
|
||||||
|
try {
|
||||||
|
const next = normalizeUsagePayload(usage, "session");
|
||||||
|
const previousSessionTotal = lastSessionTotal;
|
||||||
|
lastSessionTotal = next.total;
|
||||||
|
daily = normalizeDaily({ ...next, date: todayKey(), lastSource: "session" });
|
||||||
|
saveDaily(daily);
|
||||||
|
render();
|
||||||
|
const delta = previousSessionTotal === null ? 0 : next.total - previousSessionTotal;
|
||||||
|
if (delta > 0) {
|
||||||
|
triggerBeat();
|
||||||
|
showDelta(delta);
|
||||||
|
}
|
||||||
|
return "ok";
|
||||||
|
} catch { return "err"; }
|
||||||
|
};
|
||||||
|
window.__tdTokenReset = () => {
|
||||||
|
daily = blankDaily();
|
||||||
|
estimatedSinceUsage = 0;
|
||||||
|
recentRealAdds = [];
|
||||||
|
lastSessionTotal = 0;
|
||||||
|
saveDaily(daily);
|
||||||
|
render();
|
||||||
|
return "ok";
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
`;
|
||||||
|
|
||||||
|
module.exports = { TOKEN_INJECT_SCRIPT };
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
// Read Codex's own cumulative token snapshots from ~/.codex/sessions.
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const fs = require("fs");
|
||||||
|
const os = require("os");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const CODEX_HOME = process.env.CODEX_HOME || path.join(os.homedir(), ".codex");
|
||||||
|
const SESSIONS_ROOT = path.join(CODEX_HOME, "sessions");
|
||||||
|
const RESET_PATH = path.join(CODEX_HOME, ".tokendun-token-reset.json");
|
||||||
|
const fileCache = new Map();
|
||||||
|
|
||||||
|
function clean(n) {
|
||||||
|
n = Number(n);
|
||||||
|
return Number.isFinite(n) && n > 0 ? Math.round(n) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dayKey(date = new Date()) {
|
||||||
|
const y = date.getFullYear();
|
||||||
|
const m = String(date.getMonth() + 1).padStart(2, "0");
|
||||||
|
const d = String(date.getDate()).padStart(2, "0");
|
||||||
|
return `${y}-${m}-${d}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function blank(date = dayKey()) {
|
||||||
|
return {
|
||||||
|
available: false,
|
||||||
|
date,
|
||||||
|
total: 0,
|
||||||
|
input: 0,
|
||||||
|
output: 0,
|
||||||
|
cached: 0,
|
||||||
|
reasoning: 0,
|
||||||
|
unknown: 0,
|
||||||
|
lastSource: "session",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshotUsage(info) {
|
||||||
|
const u = info && typeof info === "object" ? info : {};
|
||||||
|
const input = clean(u.input_tokens || u.prompt_tokens || u.inputTokenCount || u.promptTokenCount);
|
||||||
|
const cached = clean(
|
||||||
|
u.cached_input_tokens ||
|
||||||
|
u.cache_read_input_tokens ||
|
||||||
|
u.cached_tokens ||
|
||||||
|
(u.input_tokens_details && u.input_tokens_details.cached_tokens)
|
||||||
|
);
|
||||||
|
const output = clean(u.output_tokens || u.completion_tokens || u.candidatesTokenCount);
|
||||||
|
const reasoning = clean(u.reasoning_output_tokens || u.reasoning_tokens || u.thoughtsTokenCount);
|
||||||
|
const total = clean(u.total_tokens || u.totalTokenCount || u.total) || input + output;
|
||||||
|
return { total, input, output, cached, reasoning };
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshotAt(line) {
|
||||||
|
let item;
|
||||||
|
try { item = JSON.parse(line); } catch { return null; }
|
||||||
|
if (item?.type !== "event_msg" || item?.payload?.type !== "token_count") return null;
|
||||||
|
const timestamp = new Date(item.timestamp);
|
||||||
|
if (!Number.isFinite(timestamp.getTime())) return null;
|
||||||
|
const usage = snapshotUsage(item.payload.info?.total_token_usage);
|
||||||
|
return usage.total > 0 ? { timestamp, usage } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSessionFile(file) {
|
||||||
|
try {
|
||||||
|
const stat = fs.statSync(file);
|
||||||
|
const cached = fileCache.get(file);
|
||||||
|
if (cached && cached.size === stat.size && cached.mtimeMs === stat.mtimeMs) return cached.value;
|
||||||
|
const snapshots = [];
|
||||||
|
const text = fs.readFileSync(file, "utf8");
|
||||||
|
for (const line of text.split("\n")) {
|
||||||
|
const snapshot = snapshotAt(line);
|
||||||
|
if (snapshot) snapshots.push(snapshot);
|
||||||
|
}
|
||||||
|
const value = { snapshots };
|
||||||
|
fileCache.set(file, { size: stat.size, mtimeMs: stat.mtimeMs, value });
|
||||||
|
return value;
|
||||||
|
} catch {
|
||||||
|
return { snapshots: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function utcDatePath(date) {
|
||||||
|
return path.join(
|
||||||
|
SESSIONS_ROOT,
|
||||||
|
String(date.getUTCFullYear()).padStart(4, "0"),
|
||||||
|
String(date.getUTCMonth() + 1).padStart(2, "0"),
|
||||||
|
String(date.getUTCDate()).padStart(2, "0")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sessionFilesForDay(start, end) {
|
||||||
|
const dirs = new Set();
|
||||||
|
for (const cursor = new Date(start); cursor < end; cursor.setDate(cursor.getDate() + 1)) {
|
||||||
|
dirs.add(utcDatePath(cursor));
|
||||||
|
}
|
||||||
|
dirs.add(utcDatePath(new Date(end.getTime() - 1)));
|
||||||
|
const files = [];
|
||||||
|
for (const dir of dirs) {
|
||||||
|
try {
|
||||||
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
if (entry.isFile() && entry.name.endsWith(".jsonl")) files.push(path.join(dir, entry.name));
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
function delta(current, before) {
|
||||||
|
return Math.max(0, clean(current) - clean(before));
|
||||||
|
}
|
||||||
|
|
||||||
|
function readReset() {
|
||||||
|
try {
|
||||||
|
const value = JSON.parse(fs.readFileSync(RESET_PATH, "utf8"));
|
||||||
|
return value && typeof value === "object" ? value : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyReset(value) {
|
||||||
|
const reset = readReset();
|
||||||
|
if (!reset || reset.date !== value.date) return value;
|
||||||
|
return {
|
||||||
|
...value,
|
||||||
|
total: delta(value.total, reset.total),
|
||||||
|
input: delta(value.input, reset.input),
|
||||||
|
output: delta(value.output, reset.output),
|
||||||
|
cached: delta(value.cached, reset.cached),
|
||||||
|
reasoning: delta(value.reasoning, reset.reasoning),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readDailyRaw(now = new Date()) {
|
||||||
|
const date = dayKey(now);
|
||||||
|
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||||
|
const end = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);
|
||||||
|
const result = blank(date);
|
||||||
|
result.available = fs.existsSync(SESSIONS_ROOT);
|
||||||
|
|
||||||
|
for (const file of sessionFilesForDay(start, end)) {
|
||||||
|
const snapshots = parseSessionFile(file).snapshots;
|
||||||
|
let before = null;
|
||||||
|
let current = null;
|
||||||
|
for (const snapshot of snapshots) {
|
||||||
|
if (snapshot.timestamp < start) before = snapshot;
|
||||||
|
else if (snapshot.timestamp < end && (!current || snapshot.timestamp > current.timestamp)) current = snapshot;
|
||||||
|
}
|
||||||
|
if (!current) continue;
|
||||||
|
const b = before?.usage || {};
|
||||||
|
const u = current.usage;
|
||||||
|
result.total += delta(u.total, b.total);
|
||||||
|
result.input += delta(u.input, b.input);
|
||||||
|
result.output += delta(u.output, b.output);
|
||||||
|
result.cached += delta(u.cached, b.cached);
|
||||||
|
result.reasoning += delta(u.reasoning, b.reasoning);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readDaily(now = new Date()) {
|
||||||
|
return applyReset(readDailyRaw(now));
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset(now = new Date()) {
|
||||||
|
const value = readDailyRaw(now);
|
||||||
|
fs.mkdirSync(path.dirname(RESET_PATH), { recursive: true });
|
||||||
|
fs.writeFileSync(RESET_PATH, JSON.stringify({ ...value, date: dayKey(now) }), "utf8");
|
||||||
|
return blank(dayKey(now));
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
readDaily,
|
||||||
|
reset,
|
||||||
|
_snapshotUsage: snapshotUsage,
|
||||||
|
_sessionFilesForDay: sessionFilesForDay,
|
||||||
|
};
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
// token_watch.js —— Token 用量监控(对外的薄包装层)。
|
||||||
|
// 注入脚本由 voice_cdp.js 在连接 Codex 时自动注入(与语音脚本同 session)。
|
||||||
|
// 本模块暴露 status / reset 给 IPC handler 和前端面板。
|
||||||
|
"use strict";
|
||||||
|
const voice = require("./voice_cdp");
|
||||||
|
const tokenUsage = require("./token_usage");
|
||||||
|
|
||||||
|
function status() {
|
||||||
|
const snap = voice.status();
|
||||||
|
return {
|
||||||
|
phase: snap.phase,
|
||||||
|
date: "",
|
||||||
|
total: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tokenStatus() {
|
||||||
|
try {
|
||||||
|
const snap = voice.status();
|
||||||
|
const page = await voice.tokenStatus();
|
||||||
|
const local = tokenUsage.readDaily();
|
||||||
|
if (local && local.available) {
|
||||||
|
await voice.tokenSet(local);
|
||||||
|
return { ok: true, phase: snap.phase, connected: snap.phase === "connected", ...local, network: page.network };
|
||||||
|
}
|
||||||
|
return { ok: true, phase: snap.phase, connected: snap.phase === "connected", ...page };
|
||||||
|
} catch {
|
||||||
|
return { ok: false, error: "无法读取 token 用量(Codex 可能未连接)" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tokenReset() {
|
||||||
|
try {
|
||||||
|
const local = tokenUsage.reset();
|
||||||
|
const ok = await voice.tokenSet(local);
|
||||||
|
await voice.tokenReset();
|
||||||
|
return { ok: ok || true };
|
||||||
|
} catch {
|
||||||
|
return { ok: false, error: "无法重置" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { status, tokenStatus, tokenReset };
|
||||||
+372
-2
@@ -16,10 +16,14 @@ function dlog(...a) { try { console.log("[voice]", ...a); } catch {} }
|
|||||||
const router = require("./router");
|
const router = require("./router");
|
||||||
const providerSync = require("./provider_sync");
|
const providerSync = require("./provider_sync");
|
||||||
const { INJECT_SCRIPT } = require("./voice_inject");
|
const { INJECT_SCRIPT } = require("./voice_inject");
|
||||||
|
const { TOKEN_INJECT_SCRIPT } = require("./token_inject");
|
||||||
|
const tokenUsage = require("./token_usage");
|
||||||
|
|
||||||
const DEFAULT_PORT = 19222; // Codex 调试端口(避开 Chrome 默认 9222)
|
const DEFAULT_PORT = 19222; // Codex 调试端口(避开 Chrome 默认 9222)
|
||||||
const BINDING_NAME = "__voiceBridge"; // 页面 → 主进程 的 CDP binding
|
const BINDING_NAME = "__voiceBridge"; // 页面 → 主进程 的 CDP binding
|
||||||
const CDP_HTTP_TIMEOUT = 3000; // GET /json 超时
|
const CDP_HTTP_TIMEOUT = 3000; // GET /json 超时
|
||||||
|
const CDP_WS_TIMEOUT = 5000; // WebSocket 握手超时
|
||||||
|
const CDP_CMD_TIMEOUT = 5000; // 单条 CDP 命令超时
|
||||||
const READY_POLL_INTERVAL = 500; // 轮询就绪间隔
|
const READY_POLL_INTERVAL = 500; // 轮询就绪间隔
|
||||||
const READY_POLL_MAX_MS = 20000; // 轮询就绪最长等待
|
const READY_POLL_MAX_MS = 20000; // 轮询就绪最长等待
|
||||||
const RECORD_MAX_MS = 60000; // 单次录音上限(注入脚本侧也限)
|
const RECORD_MAX_MS = 60000; // 单次录音上限(注入脚本侧也限)
|
||||||
@@ -35,7 +39,10 @@ let state = {
|
|||||||
target: null, // { id, url, title }
|
target: null, // { id, url, title }
|
||||||
};
|
};
|
||||||
let session = null; // 当前 CdpSession
|
let session = null; // 当前 CdpSession
|
||||||
|
let networkSessions = []; // worker 等附加 target 的 Network 监听 session
|
||||||
|
let networkAttachGeneration = 0;
|
||||||
let onStatusCb = null;
|
let onStatusCb = null;
|
||||||
|
let tokenSyncTimer = null;
|
||||||
|
|
||||||
function snapshot() {
|
function snapshot() {
|
||||||
const { phase, port, codexPath, lastError, model, target } = state;
|
const { phase, port, codexPath, lastError, model, target } = state;
|
||||||
@@ -50,6 +57,29 @@ function setPhase(phase, extra = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function syncTokenUsage() {
|
||||||
|
if (!session) return;
|
||||||
|
const usage = tokenUsage.readDaily();
|
||||||
|
if (!usage || !usage.available) return;
|
||||||
|
try {
|
||||||
|
await session.send("Runtime.evaluate", {
|
||||||
|
expression: "window.__tdTokenSet && window.__tdTokenSet(" + JSON.stringify(usage) + ")",
|
||||||
|
awaitPromise: false,
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startTokenSync() {
|
||||||
|
if (tokenSyncTimer) clearInterval(tokenSyncTimer);
|
||||||
|
tokenSyncTimer = setInterval(() => { syncTokenUsage().catch(() => {}); }, 2500);
|
||||||
|
syncTokenUsage().catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopTokenSync() {
|
||||||
|
if (tokenSyncTimer) clearInterval(tokenSyncTimer);
|
||||||
|
tokenSyncTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
// —— 探测 Codex 可执行路径 ——
|
// —— 探测 Codex 可执行路径 ——
|
||||||
function codexExecutable() {
|
function codexExecutable() {
|
||||||
const home = os.homedir();
|
const home = os.homedir();
|
||||||
@@ -139,6 +169,10 @@ function wsConnect(wsUrl) {
|
|||||||
const socket = net.connect({ host, port: Number(port) });
|
const socket = net.connect({ host, port: Number(port) });
|
||||||
const events = { open: [], message: [], close: [], error: [] };
|
const events = { open: [], message: [], close: [], error: [] };
|
||||||
const emit = (name, arg) => (events[name] || []).forEach((cb) => cb(arg));
|
const emit = (name, arg) => (events[name] || []).forEach((cb) => cb(arg));
|
||||||
|
socket.setTimeout(CDP_WS_TIMEOUT, () => {
|
||||||
|
if (!handshakeDone) emit("error", new Error("WS 连接超时"));
|
||||||
|
try { socket.destroy(); } catch {}
|
||||||
|
});
|
||||||
|
|
||||||
// —— 发送一帧文本(客户端必须掩码)——
|
// —— 发送一帧文本(客户端必须掩码)——
|
||||||
function sendFrame(str) {
|
function sendFrame(str) {
|
||||||
@@ -252,6 +286,7 @@ function wsConnect(wsUrl) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
handshakeDone = true;
|
handshakeDone = true;
|
||||||
|
try { socket.setTimeout(0); } catch {}
|
||||||
emit("open");
|
emit("open");
|
||||||
parseFrames();
|
parseFrames();
|
||||||
} else {
|
} else {
|
||||||
@@ -287,7 +322,20 @@ function openSession(wsUrl, onBindingCalled) {
|
|||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
if (closed) return reject(new Error("CDP 会话已关闭"));
|
if (closed) return reject(new Error("CDP 会话已关闭"));
|
||||||
const id = nextId++;
|
const id = nextId++;
|
||||||
pending.set(id, { resolve, reject });
|
const timer = setTimeout(() => {
|
||||||
|
pending.delete(id);
|
||||||
|
reject(new Error("CDP 命令超时: " + method));
|
||||||
|
}, CDP_CMD_TIMEOUT);
|
||||||
|
pending.set(id, {
|
||||||
|
resolve: (value) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve(value);
|
||||||
|
},
|
||||||
|
reject: (err) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(err);
|
||||||
|
},
|
||||||
|
});
|
||||||
ws.send(JSON.stringify({ id, method, params }));
|
ws.send(JSON.stringify({ id, method, params }));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -406,7 +454,7 @@ function connectedPhase() {
|
|||||||
return session ? "connected" : "disconnected";
|
return session ? "connected" : "disconnected";
|
||||||
}
|
}
|
||||||
|
|
||||||
// —— 注入:addBinding + addScriptToEvaluateOnNewDocument + 立即 evaluate ——
|
// —— 注入:addBinding + addScriptToEvaluateOnNewDocument + 立即 evaluate + Network 监听 ——
|
||||||
async function inject(sessionObj) {
|
async function inject(sessionObj) {
|
||||||
await sessionObj.send("Runtime.enable");
|
await sessionObj.send("Runtime.enable");
|
||||||
try {
|
try {
|
||||||
@@ -414,8 +462,266 @@ async function inject(sessionObj) {
|
|||||||
} catch {}
|
} catch {}
|
||||||
await sessionObj.send("Runtime.addBinding", { name: BINDING_NAME });
|
await sessionObj.send("Runtime.addBinding", { name: BINDING_NAME });
|
||||||
await sessionObj.send("Page.enable");
|
await sessionObj.send("Page.enable");
|
||||||
|
// 语音注入脚本
|
||||||
await sessionObj.send("Page.addScriptToEvaluateOnNewDocument", { source: INJECT_SCRIPT });
|
await sessionObj.send("Page.addScriptToEvaluateOnNewDocument", { source: INJECT_SCRIPT });
|
||||||
await sessionObj.send("Runtime.evaluate", { expression: INJECT_SCRIPT, awaitPromise: false });
|
await sessionObj.send("Runtime.evaluate", { expression: INJECT_SCRIPT, awaitPromise: false });
|
||||||
|
// Token 用量 UI 注入脚本(只负责 UI + localStorage,不管网络抓取)
|
||||||
|
await sessionObj.send("Page.addScriptToEvaluateOnNewDocument", { source: TOKEN_INJECT_SCRIPT });
|
||||||
|
await sessionObj.send("Runtime.evaluate", { expression: TOKEN_INJECT_SCRIPT, awaitPromise: false });
|
||||||
|
// 启用 Network 域,在 CDP 层面拦截 LLM 响应抓 token 用量
|
||||||
|
await sessionObj.send("Network.enable");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Token 用量:CDP Network 域拦截 LLM 响应抓 usage(不受页面 CSP 限制)
|
||||||
|
// ============================================================================
|
||||||
|
// 待完成的请求:requestId → { url, mimeType }
|
||||||
|
const pendingNetworkReqs = new Map();
|
||||||
|
let nextNetworkSessionId = 1;
|
||||||
|
const tokenNet = {
|
||||||
|
matchedCount: 0,
|
||||||
|
usageCount: 0,
|
||||||
|
noUsageCount: 0,
|
||||||
|
lastMatchedUrl: "",
|
||||||
|
lastUsageUrl: "",
|
||||||
|
lastNoUsageUrl: "",
|
||||||
|
lastUsageTotal: 0,
|
||||||
|
lastError: "",
|
||||||
|
attachedWorkers: 0,
|
||||||
|
};
|
||||||
|
const LLM_URL_RE = /\/(chat\/completions|v1\/messages|messages|generateContent|streamGenerateContent|completions|responses|backend-api\/(?:codex|conversation|responses)|codex|conversation)(\/|$|\?)/i;
|
||||||
|
|
||||||
|
function networkReqKey(ownerSession, requestId) {
|
||||||
|
if (!ownerSession._tdTokenNetId) ownerSession._tdTokenNetId = nextNetworkSessionId++;
|
||||||
|
return ownerSession._tdTokenNetId + ":" + requestId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindNetworkHandlers(ownerSession) {
|
||||||
|
if (!ownerSession) return;
|
||||||
|
if (!ownerSession._tdTokenNetId) ownerSession._tdTokenNetId = nextNetworkSessionId++;
|
||||||
|
ownerSession.on("Network.responseReceived", (params) => handleNetworkResponse(params, ownerSession));
|
||||||
|
ownerSession.on("Network.loadingFinished", (params) => handleNetworkLoadingFinished(params, ownerSession));
|
||||||
|
ownerSession.on("Network.loadingFailed", (params) => handleNetworkLoadingFailed(params, ownerSession));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleNetworkResponse(params, ownerSession) {
|
||||||
|
const { requestId, response } = params || {};
|
||||||
|
if (!requestId || !response || !ownerSession) return;
|
||||||
|
const url = response.url || "";
|
||||||
|
if (!LLM_URL_RE.test(url)) return;
|
||||||
|
// 只处理 JSON 响应
|
||||||
|
const mime = (response.mimeType || "").toLowerCase();
|
||||||
|
if (!mime.includes("json") && !mime.includes("text")) return;
|
||||||
|
tokenNet.matchedCount += 1;
|
||||||
|
tokenNet.lastMatchedUrl = url;
|
||||||
|
pendingNetworkReqs.set(networkReqKey(ownerSession, requestId), { url, mimeType: mime });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleNetworkLoadingFailed(params, ownerSession) {
|
||||||
|
const { requestId } = params || {};
|
||||||
|
if (requestId && ownerSession) pendingNetworkReqs.delete(networkReqKey(ownerSession, requestId));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleNetworkLoadingFinished(params, ownerSession) {
|
||||||
|
const { requestId } = params || {};
|
||||||
|
if (!requestId || !ownerSession) return;
|
||||||
|
const key = networkReqKey(ownerSession, requestId);
|
||||||
|
if (!pendingNetworkReqs.has(key)) return;
|
||||||
|
const reqInfo = pendingNetworkReqs.get(key) || {};
|
||||||
|
pendingNetworkReqs.delete(key);
|
||||||
|
if (!session || !ownerSession) return;
|
||||||
|
try {
|
||||||
|
const r = await ownerSession.send("Network.getResponseBody", { requestId });
|
||||||
|
const body = (r && r.body) || "";
|
||||||
|
if (!body || body.length < 10) return;
|
||||||
|
// 可能被 base64 编码
|
||||||
|
let text = body;
|
||||||
|
if (r && r.base64Encoded) {
|
||||||
|
try { text = Buffer.from(body, "base64").toString("utf8"); } catch { return; }
|
||||||
|
}
|
||||||
|
// 解析 JSON 提取 usage
|
||||||
|
const usage = extractUsageFromText(text);
|
||||||
|
if (usage && usage.total > 0) {
|
||||||
|
tokenNet.usageCount += 1;
|
||||||
|
tokenNet.lastUsageUrl = reqInfo.url || "";
|
||||||
|
tokenNet.lastUsageTotal = usage.total || 0;
|
||||||
|
tokenNet.lastError = "";
|
||||||
|
dlog(
|
||||||
|
"token: +" + usage.total +
|
||||||
|
" input=" + (usage.input || 0) +
|
||||||
|
" output=" + (usage.output || 0) +
|
||||||
|
" cached=" + (usage.cached || 0) +
|
||||||
|
" from",
|
||||||
|
reqInfo.url || "?"
|
||||||
|
);
|
||||||
|
// 通过 Runtime.evaluate 调注入脚本更新 UI
|
||||||
|
try {
|
||||||
|
await session.send("Runtime.evaluate", {
|
||||||
|
expression: "window.__tdTokenAdd && window.__tdTokenAdd(" + JSON.stringify(usage) + ", 'network')",
|
||||||
|
awaitPromise: false,
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
} else {
|
||||||
|
tokenNet.noUsageCount += 1;
|
||||||
|
tokenNet.lastNoUsageUrl = reqInfo.url || "";
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
tokenNet.lastError = String((e && e.message) || e || "getResponseBody failed").slice(0, 160);
|
||||||
|
// getResponseBody 可能失败(响应体已被清掉或太大),静默跳过
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractUsageFromText(text) {
|
||||||
|
let best = null;
|
||||||
|
const take = (obj) => {
|
||||||
|
best = betterUsage(best, extractUsageFromObject(obj));
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
take(JSON.parse(text));
|
||||||
|
} catch {}
|
||||||
|
if (usageTotal(best) > 0) return best;
|
||||||
|
|
||||||
|
// Stream/SSE: data: {"usage":...}\n\n or data: [DONE]
|
||||||
|
const lines = String(text || "").split(/\r?\n/);
|
||||||
|
for (const line of lines) {
|
||||||
|
const s = line.trim();
|
||||||
|
const payload = s.startsWith("data:") ? s.slice(5).trim() : s;
|
||||||
|
if (!payload || payload === "[DONE]") continue;
|
||||||
|
try { take(JSON.parse(payload)); } catch {}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanTokenNumber(n) {
|
||||||
|
n = Number(n);
|
||||||
|
return Number.isFinite(n) && n > 0 ? Math.round(n) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function usageTotal(u) {
|
||||||
|
return u ? cleanTokenNumber(u.total) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function betterUsage(a, b) {
|
||||||
|
return usageTotal(b) > usageTotal(a) ? b : a;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasOwn(obj, key) {
|
||||||
|
return Object.prototype.hasOwnProperty.call(obj, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
function looksLikeUsage(usage) {
|
||||||
|
if (!usage || typeof usage !== "object") return false;
|
||||||
|
const keys = [
|
||||||
|
"input_tokens", "prompt_tokens", "output_tokens", "completion_tokens", "total_tokens",
|
||||||
|
"promptTokenCount", "prompt_token_count", "inputTokenCount", "outputTokenCount",
|
||||||
|
"candidatesTokenCount", "candidates_token_count", "totalTokenCount", "total_token_count",
|
||||||
|
"token_count", "cache_creation_input_tokens", "cache_read_input_tokens",
|
||||||
|
"cachedContentTokenCount", "cached_content_token_count", "reasoning_tokens",
|
||||||
|
"thoughtsTokenCount", "thoughts_token_count",
|
||||||
|
];
|
||||||
|
if (keys.some((key) => hasOwn(usage, key))) return true;
|
||||||
|
return hasOwn(usage, "total") && (hasOwn(usage, "input") || hasOwn(usage, "output") || hasOwn(usage, "cached"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractUsageFromRawUsage(usage) {
|
||||||
|
if (!usage || typeof usage !== "object") return null;
|
||||||
|
if (!looksLikeUsage(usage)) return null;
|
||||||
|
const input = cleanTokenNumber(
|
||||||
|
usage.input_tokens ||
|
||||||
|
usage.prompt_tokens ||
|
||||||
|
usage.promptTokenCount ||
|
||||||
|
usage.prompt_token_count ||
|
||||||
|
usage.inputTokenCount
|
||||||
|
);
|
||||||
|
const output = cleanTokenNumber(
|
||||||
|
usage.output_tokens ||
|
||||||
|
usage.completion_tokens ||
|
||||||
|
usage.candidatesTokenCount ||
|
||||||
|
usage.candidates_token_count ||
|
||||||
|
usage.outputTokenCount
|
||||||
|
);
|
||||||
|
const reasoning = cleanTokenNumber(usage.reasoning_tokens || usage.thoughtsTokenCount || usage.thoughts_token_count);
|
||||||
|
const details = usage.input_tokens_details || usage.prompt_tokens_details || usage.inputTokenDetails || usage.promptTokenDetails || {};
|
||||||
|
const cached =
|
||||||
|
cleanTokenNumber(details.cached_tokens) +
|
||||||
|
cleanTokenNumber(usage.cache_creation_input_tokens) +
|
||||||
|
cleanTokenNumber(usage.cache_read_input_tokens) +
|
||||||
|
cleanTokenNumber(usage.cachedContentTokenCount) +
|
||||||
|
cleanTokenNumber(usage.cached_content_token_count);
|
||||||
|
const total = cleanTokenNumber(
|
||||||
|
usage.total_tokens ||
|
||||||
|
usage.totalTokenCount ||
|
||||||
|
usage.total_token_count ||
|
||||||
|
usage.token_count ||
|
||||||
|
usage.total
|
||||||
|
) || input + output + reasoning + cached;
|
||||||
|
if (!total) return null;
|
||||||
|
return { total, input, output: output + reasoning, cached };
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractUsageFromObject(obj) {
|
||||||
|
if (!obj || typeof obj !== "object") return null;
|
||||||
|
const direct =
|
||||||
|
extractUsageFromRawUsage(obj.usage) ||
|
||||||
|
extractUsageFromRawUsage(obj.usage_metadata) ||
|
||||||
|
extractUsageFromRawUsage(obj.usageMetadata) ||
|
||||||
|
extractUsageFromRawUsage(obj);
|
||||||
|
if (direct) return direct;
|
||||||
|
let best = null;
|
||||||
|
for (const val of Object.values(obj)) {
|
||||||
|
if (!val || typeof val !== "object") continue;
|
||||||
|
if (Array.isArray(val)) {
|
||||||
|
for (const item of val) best = betterUsage(best, extractUsageFromObject(item));
|
||||||
|
} else {
|
||||||
|
best = betterUsage(best, extractUsageFromObject(val));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function attachNetworkSession(target, generation) {
|
||||||
|
if (!target || !target.webSocketDebuggerUrl) return;
|
||||||
|
let netSession = null;
|
||||||
|
try {
|
||||||
|
netSession = await openSession(target.webSocketDebuggerUrl, null);
|
||||||
|
bindNetworkHandlers(netSession);
|
||||||
|
await netSession.send("Network.enable");
|
||||||
|
if (!session || generation !== networkAttachGeneration) {
|
||||||
|
try { netSession.close(); } catch {}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
networkSessions.push(netSession);
|
||||||
|
dlog("token network attached:", target.type || "target", target.id || "", target.url || "");
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
if (netSession) {
|
||||||
|
try { netSession.close(); } catch {}
|
||||||
|
}
|
||||||
|
tokenNet.lastError = String((e && e.message) || e || "attach network failed").slice(0, 160);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function attachWorkerNetworkTargets(port, parentId) {
|
||||||
|
const targets = await listTargets(port);
|
||||||
|
const workers = targets.filter((t) =>
|
||||||
|
t &&
|
||||||
|
t.webSocketDebuggerUrl &&
|
||||||
|
(t.type === "worker" || t.type === "service_worker" || t.type === "shared_worker") &&
|
||||||
|
(!parentId || t.parentId === parentId)
|
||||||
|
);
|
||||||
|
const generation = networkAttachGeneration;
|
||||||
|
const results = await Promise.all(workers.map((worker) => attachNetworkSession(worker, generation)));
|
||||||
|
tokenNet.attachedWorkers = results.filter(Boolean).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeNetworkSessions() {
|
||||||
|
networkAttachGeneration += 1;
|
||||||
|
for (const s of networkSessions) {
|
||||||
|
try { s.close(); } catch {}
|
||||||
|
}
|
||||||
|
networkSessions = [];
|
||||||
|
pendingNetworkReqs.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
// —— 连接到已带端口的 Codex ——
|
// —— 连接到已带端口的 Codex ——
|
||||||
@@ -423,17 +729,26 @@ async function connectToTarget(port) {
|
|||||||
const targets = await listTargets(port);
|
const targets = await listTargets(port);
|
||||||
const tgt = pickTarget(targets);
|
const tgt = pickTarget(targets);
|
||||||
if (!tgt) return false;
|
if (!tgt) return false;
|
||||||
|
closeNetworkSessions();
|
||||||
state.target = { id: tgt.id, url: tgt.url, title: tgt.title };
|
state.target = { id: tgt.id, url: tgt.url, title: tgt.title };
|
||||||
setPhase("connecting");
|
setPhase("connecting");
|
||||||
session = await openSession(tgt.webSocketDebuggerUrl, handleBinding);
|
session = await openSession(tgt.webSocketDebuggerUrl, handleBinding);
|
||||||
session.on("Runtime.bindingCalled", handleBinding);
|
session.on("Runtime.bindingCalled", handleBinding);
|
||||||
|
// Token 用量:Network 层拦截 LLM 响应
|
||||||
|
bindNetworkHandlers(session);
|
||||||
session.ws.on("close", () => {
|
session.ws.on("close", () => {
|
||||||
|
stopTokenSync();
|
||||||
|
closeNetworkSessions();
|
||||||
session = null;
|
session = null;
|
||||||
setPhase("disconnected");
|
setPhase("disconnected");
|
||||||
});
|
});
|
||||||
setPhase("injecting");
|
setPhase("injecting");
|
||||||
await inject(session);
|
await inject(session);
|
||||||
|
startTokenSync();
|
||||||
setPhase("connected", { target: state.target });
|
setPhase("connected", { target: state.target });
|
||||||
|
attachWorkerNetworkTargets(port, tgt.id).catch((e) => {
|
||||||
|
tokenNet.lastError = String((e && e.message) || e || "attach workers failed").slice(0, 160);
|
||||||
|
});
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -545,6 +860,8 @@ async function connect(cb, opts = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function disconnect() {
|
function disconnect() {
|
||||||
|
stopTokenSync();
|
||||||
|
closeNetworkSessions();
|
||||||
if (session) {
|
if (session) {
|
||||||
try {
|
try {
|
||||||
session.close();
|
session.close();
|
||||||
@@ -601,6 +918,56 @@ async function probeStt() {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// —— Token 用量读取 / 重置(通过 CDP Runtime.evaluate 调注入脚本的 window API)——
|
||||||
|
async function tokenStatus() {
|
||||||
|
if (!session) return { date: "", total: 0 };
|
||||||
|
try {
|
||||||
|
const r = await session.send("Runtime.evaluate", {
|
||||||
|
expression: "window.__tdTokenGet ? window.__tdTokenGet() : JSON.stringify({date:'',total:0})",
|
||||||
|
awaitPromise: false,
|
||||||
|
});
|
||||||
|
const raw = (r && r.result && r.result.value) || "";
|
||||||
|
const parsed = (() => { try { return JSON.parse(raw); } catch { return { date: "", total: 0 }; } })();
|
||||||
|
return {
|
||||||
|
date: parsed.date || "",
|
||||||
|
total: Number(parsed.total) || 0,
|
||||||
|
input: Number(parsed.input) || 0,
|
||||||
|
output: Number(parsed.output) || 0,
|
||||||
|
cached: Number(parsed.cached) || 0,
|
||||||
|
estimate: Number(parsed.estimate || parsed.estimated) || 0,
|
||||||
|
unknown: Number(parsed.unknown) || 0,
|
||||||
|
lastSource: parsed.lastSource || parsed.source || "",
|
||||||
|
network: { ...tokenNet },
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { date: "", total: 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function tokenSet(usage) {
|
||||||
|
if (!session) return false;
|
||||||
|
try {
|
||||||
|
await session.send("Runtime.evaluate", {
|
||||||
|
expression: "window.__tdTokenSet && window.__tdTokenSet(" + JSON.stringify(usage || {}) + ")",
|
||||||
|
awaitPromise: false,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function tokenReset() {
|
||||||
|
if (!session) return false;
|
||||||
|
try {
|
||||||
|
await session.send("Runtime.evaluate", {
|
||||||
|
expression: "window.__tdTokenReset ? window.__tdTokenReset() : 'no'",
|
||||||
|
awaitPromise: false,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
status,
|
status,
|
||||||
connect,
|
connect,
|
||||||
@@ -608,6 +975,9 @@ module.exports = {
|
|||||||
onStatus,
|
onStatus,
|
||||||
sttInfo,
|
sttInfo,
|
||||||
probeStt,
|
probeStt,
|
||||||
|
tokenStatus,
|
||||||
|
tokenSet,
|
||||||
|
tokenReset,
|
||||||
// 供临时实测脚本复用
|
// 供临时实测脚本复用
|
||||||
_listTargets: listTargets,
|
_listTargets: listTargets,
|
||||||
_pickTarget: pickTarget,
|
_pickTarget: pickTarget,
|
||||||
|
|||||||
+98
-1
@@ -47,4 +47,101 @@ function extractZip(buf, destDir) {
|
|||||||
return written;
|
return written;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { extractZip };
|
// ---- 零依赖 ZIP 压缩(deflate/stored),供员工团队打包等复用 ----
|
||||||
|
// CRC32 表(懒加载)
|
||||||
|
let CRC_TABLE = null;
|
||||||
|
function crc32(buf) {
|
||||||
|
if (!CRC_TABLE) {
|
||||||
|
CRC_TABLE = new Int32Array(256);
|
||||||
|
for (let n = 0; n < 256; n++) {
|
||||||
|
let c = n;
|
||||||
|
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||||
|
CRC_TABLE[n] = c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let crc = ~0;
|
||||||
|
for (let i = 0; i < buf.length; i++) crc = (crc >>> 8) ^ CRC_TABLE[(crc ^ buf[i]) & 0xff];
|
||||||
|
return (~crc) >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// MS-DOS 时间/日期(固定用一个合法值即可,不追求精确时间戳)
|
||||||
|
function dosDateTime(d) {
|
||||||
|
const date = d || new Date();
|
||||||
|
const time = ((date.getHours() & 0x1f) << 11) | ((date.getMinutes() & 0x3f) << 5) | ((date.getSeconds() / 2) & 0x1f);
|
||||||
|
const day = (((date.getFullYear() - 1980) & 0x7f) << 9) | (((date.getMonth() + 1) & 0x0f) << 5) | (date.getDate() & 0x1f);
|
||||||
|
return { time: time & 0xffff, date: day & 0xffff };
|
||||||
|
}
|
||||||
|
|
||||||
|
// files: [{ path, content(string|Buffer), mode?(unix 权限,如 0o755) }]
|
||||||
|
// 返回一个 zip 的 Buffer。目录靠文件路径里的 "/" 隐含,无需单独目录项。
|
||||||
|
function createZip(files) {
|
||||||
|
const { time, date } = dosDateTime();
|
||||||
|
const locals = [];
|
||||||
|
const centrals = [];
|
||||||
|
let offset = 0;
|
||||||
|
for (const f of files) {
|
||||||
|
const nameBuf = Buffer.from(String(f.path).replace(/^\/+/, ""), "utf8");
|
||||||
|
const raw = Buffer.isBuffer(f.content) ? f.content : Buffer.from(String(f.content == null ? "" : f.content), "utf8");
|
||||||
|
const crc = crc32(raw);
|
||||||
|
// 尝试 deflate;若压不小就存储(method 0)
|
||||||
|
let method = 8;
|
||||||
|
let comp = zlib.deflateRawSync(raw, { level: 9 });
|
||||||
|
if (comp.length >= raw.length) { method = 0; comp = raw; }
|
||||||
|
|
||||||
|
// 本地文件头
|
||||||
|
const lh = Buffer.alloc(30);
|
||||||
|
lh.writeUInt32LE(0x04034b50, 0);
|
||||||
|
lh.writeUInt16LE(20, 4); // version needed
|
||||||
|
lh.writeUInt16LE(0, 6); // flags
|
||||||
|
lh.writeUInt16LE(method, 8);
|
||||||
|
lh.writeUInt16LE(time, 10);
|
||||||
|
lh.writeUInt16LE(date, 12);
|
||||||
|
lh.writeUInt32LE(crc, 14);
|
||||||
|
lh.writeUInt32LE(comp.length, 18);
|
||||||
|
lh.writeUInt32LE(raw.length, 22);
|
||||||
|
lh.writeUInt16LE(nameBuf.length, 26);
|
||||||
|
lh.writeUInt16LE(0, 28); // extra len
|
||||||
|
locals.push(lh, nameBuf, comp);
|
||||||
|
|
||||||
|
// 中央目录项
|
||||||
|
const ch = Buffer.alloc(46);
|
||||||
|
// unix 权限写进外部属性高 16 位(低位保留),默认 0644,可执行给 0755
|
||||||
|
const unixMode = f.mode ? f.mode : 0o644;
|
||||||
|
const externalAttr = (unixMode << 16) >>> 0;
|
||||||
|
ch.writeUInt32LE(0x02014b50, 0);
|
||||||
|
ch.writeUInt16LE(0x031e, 4); // version made by (unix)
|
||||||
|
ch.writeUInt16LE(20, 6); // version needed
|
||||||
|
ch.writeUInt16LE(0, 8); // flags
|
||||||
|
ch.writeUInt16LE(method, 10);
|
||||||
|
ch.writeUInt16LE(time, 12);
|
||||||
|
ch.writeUInt16LE(date, 14);
|
||||||
|
ch.writeUInt32LE(crc, 16);
|
||||||
|
ch.writeUInt32LE(comp.length, 20);
|
||||||
|
ch.writeUInt32LE(raw.length, 24);
|
||||||
|
ch.writeUInt16LE(nameBuf.length, 28);
|
||||||
|
ch.writeUInt16LE(0, 30); // extra len
|
||||||
|
ch.writeUInt16LE(0, 32); // comment len
|
||||||
|
ch.writeUInt16LE(0, 34); // disk number
|
||||||
|
ch.writeUInt16LE(0, 36); // internal attr
|
||||||
|
ch.writeUInt32LE(externalAttr, 38);
|
||||||
|
ch.writeUInt32LE(offset, 42);
|
||||||
|
centrals.push(ch, nameBuf);
|
||||||
|
|
||||||
|
offset += lh.length + nameBuf.length + comp.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
const localPart = Buffer.concat(locals);
|
||||||
|
const centralPart = Buffer.concat(centrals);
|
||||||
|
const eocd = Buffer.alloc(22);
|
||||||
|
eocd.writeUInt32LE(0x06054b50, 0);
|
||||||
|
eocd.writeUInt16LE(0, 4);
|
||||||
|
eocd.writeUInt16LE(0, 6);
|
||||||
|
eocd.writeUInt16LE(files.length, 8);
|
||||||
|
eocd.writeUInt16LE(files.length, 10);
|
||||||
|
eocd.writeUInt32LE(centralPart.length, 12);
|
||||||
|
eocd.writeUInt32LE(localPart.length, 16); // central dir offset
|
||||||
|
eocd.writeUInt16LE(0, 20);
|
||||||
|
return Buffer.concat([localPart, centralPart, eocd]);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { extractZip, createZip };
|
||||||
|
|||||||
+102
-1
@@ -1,6 +1,6 @@
|
|||||||
// main.js —— 主进程:窗口、IPC、GitHub(isomorphic-git)、市场 API。
|
// main.js —— 主进程:窗口、IPC、GitHub(isomorphic-git)、市场 API。
|
||||||
// 市场地址/密钥【只在主进程】,绝不下发前端。契约见 CONTRACT.md。
|
// 市场地址/密钥【只在主进程】,绝不下发前端。契约见 CONTRACT.md。
|
||||||
const { app, BrowserWindow, ipcMain, dialog, shell, Tray, Menu, nativeImage } = require("electron");
|
const { app, BrowserWindow, ipcMain, dialog, shell, Tray, Menu, nativeImage, clipboard } = require("electron");
|
||||||
const { autoUpdater } = require("electron-updater");
|
const { autoUpdater } = require("electron-updater");
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
const os = require("os");
|
const os = require("os");
|
||||||
@@ -15,7 +15,11 @@ const sessions = require("./lib/sessions");
|
|||||||
const providerSync = require("./lib/provider_sync");
|
const providerSync = require("./lib/provider_sync");
|
||||||
const router = require("./lib/router");
|
const router = require("./lib/router");
|
||||||
const canvas = require("./lib/canvas");
|
const canvas = require("./lib/canvas");
|
||||||
|
const team = require("./lib/team");
|
||||||
|
const promptAnalysis = require("./lib/prompt-analysis");
|
||||||
|
const claudedesk = require("./lib/claudedesk");
|
||||||
const voice = require("./lib/voice_cdp");
|
const voice = require("./lib/voice_cdp");
|
||||||
|
const tokenWatch = require("./lib/token_watch");
|
||||||
const proxy = require("./lib/proxy");
|
const proxy = require("./lib/proxy");
|
||||||
const memory = require("./lib/memory");
|
const memory = require("./lib/memory");
|
||||||
const { extractZip } = require("./lib/zip");
|
const { extractZip } = require("./lib/zip");
|
||||||
@@ -235,6 +239,20 @@ app.whenReady().then(async () => {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
log(`启动迁移 plugin 异常: ${e.message || e}`);
|
log(`启动迁移 plugin 异常: ${e.message || e}`);
|
||||||
}
|
}
|
||||||
|
// 启动自愈:把当前激活分组的全部文本模型先回填进 Codex 模型目录。
|
||||||
|
// 目的——用户在 Codex 本体的模型选择器里切模型(不经本 App)时,若目标模型不在目录里,
|
||||||
|
// Codex 会以未知模型启动、侧栏历史还原失败 = "切模型后会话记录消失"。先回填即根治,
|
||||||
|
// 且既有安装无需重新保存分组即可自动修复。异步、失败不动目录(离线安全)。
|
||||||
|
try {
|
||||||
|
const active = router.llmGroup(); // 当前激活且配齐 baseUrl+key+model 的中转分组(排除默认直连)
|
||||||
|
if (active) {
|
||||||
|
router.syncCatalogFromProvider(active)
|
||||||
|
.then((r) => { if (r && r.ok && (r.added || r.revealed)) log(`启动同步模型目录:新增 ${r.added} · 显示 ${r.revealed} · 共 ${r.total}`); })
|
||||||
|
.catch((e) => log(`启动同步模型目录失败:${e.message || e}`));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
log(`启动同步模型目录异常:${e.message || e}`);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 统一退出:清理代理(杀 sing-box + 还原网络) → 停本地路由 → 销毁托盘 → 退出。
|
// 统一退出:清理代理(杀 sing-box + 还原网络) → 停本地路由 → 销毁托盘 → 退出。
|
||||||
@@ -1323,6 +1341,28 @@ ipcMain.handle("router-fetch-models", async (_e, { baseUrl, key, groupId }) => {
|
|||||||
return router.fetchModels(baseUrl, k);
|
return router.fetchModels(baseUrl, k);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// —— 接入 Claude 桌面 App(第三方推理 / gateway 模式)——
|
||||||
|
ipcMain.handle("claudedesk-status", () => claudedesk.status());
|
||||||
|
ipcMain.handle("claudedesk-apply", (_e, { groupId }) => claudedesk.applyGroup(groupId));
|
||||||
|
ipcMain.handle("claudedesk-restore", () => claudedesk.restoreOfficial());
|
||||||
|
ipcMain.handle("claudedesk-snippet", (_e, { groupId }) => {
|
||||||
|
const r = claudedesk.snippet(groupId);
|
||||||
|
if (r.ok) { try { clipboard.writeText(r.text); } catch {} }
|
||||||
|
return r.ok ? { ok: true } : r;
|
||||||
|
});
|
||||||
|
ipcMain.handle("claudedesk-export", async (_e, { groupId, format }) => {
|
||||||
|
const r = claudedesk.exportConfig(groupId, format);
|
||||||
|
if (!r.ok) return r;
|
||||||
|
const dl = await dialog.showSaveDialog(mainWindow, {
|
||||||
|
title: "导出 Claude 桌面配置",
|
||||||
|
defaultPath: r.filename,
|
||||||
|
filters: [{ name: r.ext.toUpperCase(), extensions: [r.ext] }],
|
||||||
|
});
|
||||||
|
if (dl.canceled || !dl.filePath) return { ok: false, canceled: true };
|
||||||
|
try { fs.writeFileSync(dl.filePath, r.content); } catch (e) { return { ok: false, error: String(e.message || e) }; }
|
||||||
|
return { ok: true, path: dl.filePath };
|
||||||
|
});
|
||||||
|
|
||||||
// —— 创意画布(中转执行层)——
|
// —— 创意画布(中转执行层)——
|
||||||
ipcMain.handle("canvas-status", () => canvas.status());
|
ipcMain.handle("canvas-status", () => canvas.status());
|
||||||
ipcMain.handle("canvas-run", (e, { kind, params }) =>
|
ipcMain.handle("canvas-run", (e, { kind, params }) =>
|
||||||
@@ -1335,6 +1375,63 @@ ipcMain.handle("canvas-run", (e, { kind, params }) =>
|
|||||||
ipcMain.handle("canvas-groups", () => canvas.groups());
|
ipcMain.handle("canvas-groups", () => canvas.groups());
|
||||||
ipcMain.handle("canvas-models", (_e, { groupId }) => canvas.listModels(groupId));
|
ipcMain.handle("canvas-models", (_e, { groupId }) => canvas.listModels(groupId));
|
||||||
|
|
||||||
|
// —— 多 Agent 员工团队生成(需求 → 团队 → 打包 zip)——
|
||||||
|
ipcMain.handle("team-generate", async (_e, params) => {
|
||||||
|
try {
|
||||||
|
const result = await team.generate(params || {});
|
||||||
|
return { ok: true, result };
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, error: String((err && err.message) || err) };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ipcMain.handle("team-preview", (_e, params) => {
|
||||||
|
try {
|
||||||
|
return { ok: true, ...team.preview(params || {}) };
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, error: String((err && err.message) || err) };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ipcMain.handle("team-pack", async (_e, params) => {
|
||||||
|
try {
|
||||||
|
const { buffer, filename } = team.pack(params || {});
|
||||||
|
const r = await dialog.showSaveDialog(mainWindow, {
|
||||||
|
title: "保存员工团队配置包",
|
||||||
|
defaultPath: filename,
|
||||||
|
filters: [{ name: "Zip 压缩包", extensions: ["zip"] }],
|
||||||
|
});
|
||||||
|
if (r.canceled || !r.filePath) return { ok: false, canceled: true };
|
||||||
|
fs.writeFileSync(r.filePath, buffer);
|
||||||
|
return { ok: true, path: r.filePath };
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, error: String((err && err.message) || err) };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// —— 提示词提取(图片 → 反推生图提示词)——
|
||||||
|
ipcMain.handle("prompt-groups", () => {
|
||||||
|
try {
|
||||||
|
return { ok: true, groups: promptAnalysis.listGroups() };
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, error: String((err && err.message) || err) };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ipcMain.handle("prompt-models", async (_e, { groupId }) => {
|
||||||
|
try {
|
||||||
|
const models = await promptAnalysis.listModels(groupId);
|
||||||
|
return { ok: true, models };
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, error: String((err && err.message) || err) };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ipcMain.handle("prompt-analyze", async (_e, params) => {
|
||||||
|
try {
|
||||||
|
const result = await promptAnalysis.analyze(params || {});
|
||||||
|
return { ok: true, ...result };
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, error: String((err && err.message) || err) };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// —— Codex 语音输入注入(CDP)——
|
// —— Codex 语音输入注入(CDP)——
|
||||||
ipcMain.handle("voice-status", () => voice.status());
|
ipcMain.handle("voice-status", () => voice.status());
|
||||||
ipcMain.handle("voice-connect", (e, opts) =>
|
ipcMain.handle("voice-connect", (e, opts) =>
|
||||||
@@ -1348,6 +1445,10 @@ ipcMain.handle("voice-disconnect", () => voice.disconnect());
|
|||||||
ipcMain.handle("voice-stt-info", () => voice.sttInfo());
|
ipcMain.handle("voice-stt-info", () => voice.sttInfo());
|
||||||
ipcMain.handle("voice-probe-stt", async () => voice.probeStt());
|
ipcMain.handle("voice-probe-stt", async () => voice.probeStt());
|
||||||
|
|
||||||
|
// —— Token 用量监控(注入 Codex 页面,复用 CDP 连接)——
|
||||||
|
ipcMain.handle("token-status", async () => tokenWatch.tokenStatus());
|
||||||
|
ipcMain.handle("token-reset", async () => tokenWatch.tokenReset());
|
||||||
|
|
||||||
// —— 全局 / 系统代理 ——
|
// —— 全局 / 系统代理 ——
|
||||||
ipcMain.handle("proxy-status", async () => proxy.status());
|
ipcMain.handle("proxy-status", async () => proxy.status());
|
||||||
ipcMain.handle("proxy-get-config", async () => proxy.getConfig());
|
ipcMain.handle("proxy-get-config", async () => proxy.getConfig());
|
||||||
|
|||||||
@@ -76,6 +76,12 @@ contextBridge.exposeInMainWorld("api", {
|
|||||||
routerRestoreClaude: () => ipcRenderer.invoke("router-restore-claude"),
|
routerRestoreClaude: () => ipcRenderer.invoke("router-restore-claude"),
|
||||||
routerSnippet: () => ipcRenderer.invoke("router-snippet"),
|
routerSnippet: () => ipcRenderer.invoke("router-snippet"),
|
||||||
routerFetchModels: (baseUrl, key, groupId) => ipcRenderer.invoke("router-fetch-models", { baseUrl, key, groupId }),
|
routerFetchModels: (baseUrl, key, groupId) => ipcRenderer.invoke("router-fetch-models", { baseUrl, key, groupId }),
|
||||||
|
// —— 接入 Claude 桌面 App ——
|
||||||
|
claudedeskStatus: () => ipcRenderer.invoke("claudedesk-status"),
|
||||||
|
claudedeskApply: (groupId) => ipcRenderer.invoke("claudedesk-apply", { groupId }),
|
||||||
|
claudedeskRestore: () => ipcRenderer.invoke("claudedesk-restore"),
|
||||||
|
claudedeskSnippet: (groupId) => ipcRenderer.invoke("claudedesk-snippet", { groupId }),
|
||||||
|
claudedeskExport: (groupId, format) => ipcRenderer.invoke("claudedesk-export", { groupId, format }),
|
||||||
onRouterChanged: (cb) => {
|
onRouterChanged: (cb) => {
|
||||||
const handler = (_e, s) => cb(s);
|
const handler = (_e, s) => cb(s);
|
||||||
ipcRenderer.on("router-changed", handler);
|
ipcRenderer.on("router-changed", handler);
|
||||||
@@ -86,6 +92,14 @@ contextBridge.exposeInMainWorld("api", {
|
|||||||
canvasRun: (kind, params) => ipcRenderer.invoke("canvas-run", { kind, params }),
|
canvasRun: (kind, params) => ipcRenderer.invoke("canvas-run", { kind, params }),
|
||||||
canvasGroups: () => ipcRenderer.invoke("canvas-groups"),
|
canvasGroups: () => ipcRenderer.invoke("canvas-groups"),
|
||||||
canvasModels: (groupId) => ipcRenderer.invoke("canvas-models", { groupId }),
|
canvasModels: (groupId) => ipcRenderer.invoke("canvas-models", { groupId }),
|
||||||
|
// —— 多 Agent 员工团队生成 ——
|
||||||
|
teamGenerate: (params) => ipcRenderer.invoke("team-generate", params || {}),
|
||||||
|
teamPreview: (params) => ipcRenderer.invoke("team-preview", params || {}),
|
||||||
|
teamPack: (params) => ipcRenderer.invoke("team-pack", params || {}),
|
||||||
|
// —— 提示词提取(图片 → 反推生图提示词)——
|
||||||
|
promptGroups: () => ipcRenderer.invoke("prompt-groups"),
|
||||||
|
promptModels: (groupId) => ipcRenderer.invoke("prompt-models", { groupId }),
|
||||||
|
promptAnalyze: (params) => ipcRenderer.invoke("prompt-analyze", params || {}),
|
||||||
onCanvasProgress: (cb) => {
|
onCanvasProgress: (cb) => {
|
||||||
const h = (_e, p) => cb(p);
|
const h = (_e, p) => cb(p);
|
||||||
ipcRenderer.on("canvas-progress", h);
|
ipcRenderer.on("canvas-progress", h);
|
||||||
@@ -102,6 +116,9 @@ contextBridge.exposeInMainWorld("api", {
|
|||||||
ipcRenderer.on("voice-status", h);
|
ipcRenderer.on("voice-status", h);
|
||||||
return () => ipcRenderer.removeListener("voice-status", h);
|
return () => ipcRenderer.removeListener("voice-status", h);
|
||||||
},
|
},
|
||||||
|
// —— Token 用量监控(注入 Codex,复用 CDP 连接)——
|
||||||
|
tokenStatus: () => ipcRenderer.invoke("token-status"),
|
||||||
|
tokenReset: () => ipcRenderer.invoke("token-reset"),
|
||||||
// —— 全局 / 系统代理 ——
|
// —— 全局 / 系统代理 ——
|
||||||
proxyStatus: () => ipcRenderer.invoke("proxy-status"),
|
proxyStatus: () => ipcRenderer.invoke("proxy-status"),
|
||||||
proxyGetConfig: () => ipcRenderer.invoke("proxy-get-config"),
|
proxyGetConfig: () => ipcRenderer.invoke("proxy-get-config"),
|
||||||
|
|||||||
+3
-2
@@ -1,11 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "codex-market",
|
"name": "codex-market",
|
||||||
"version": "1.5.16",
|
"version": "1.5.21",
|
||||||
"description": "TD工具箱 / 技能商店 / 备份工具 —— 跨平台桌面客户端",
|
"description": "TD工具箱 / 技能商店 / 备份工具 —— 跨平台桌面客户端",
|
||||||
"author": "Codex Market",
|
"author": "Codex Market",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"main": "electron/bootstrap.js",
|
"main": "electron/bootstrap.js",
|
||||||
"hotContentVersion": "1.5.16",
|
"hotContentVersion": "1.5.23",
|
||||||
|
"hotMinAppVersion": "1.5.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "electron .",
|
"start": "electron .",
|
||||||
"dist": "electron-builder",
|
"dist": "electron-builder",
|
||||||
|
|||||||
+1009
-131
File diff suppressed because it is too large
Load Diff
+232
-6
@@ -84,6 +84,13 @@
|
|||||||
<p class="muted">tokendun 官方自研插件与内置工具</p>
|
<p class="muted">tokendun 官方自研插件与内置工具</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="head-actions">
|
<div class="head-actions">
|
||||||
|
<div class="codex-link-status" title="Codex 桌面连接状态;语音输入与 Token 用量共用此连接">
|
||||||
|
<span id="codex-link-dot" class="voice-dot" data-state="unknown"></span>
|
||||||
|
<span class="codex-link-label">Codex</span>
|
||||||
|
<span id="codex-link-phase-text" class="muted small">未连接</span>
|
||||||
|
<button class="btn ghost small" id="codex-link-connect-btn" data-voice-link-connect>连接</button>
|
||||||
|
<button class="btn ghost small hidden" id="codex-link-disconnect-btn" data-voice-link-disconnect>断开</button>
|
||||||
|
</div>
|
||||||
<input id="plugin-search" class="search" type="search" placeholder="搜索插件 / 作者…" />
|
<input id="plugin-search" class="search" type="search" placeholder="搜索插件 / 作者…" />
|
||||||
<button id="plugin-refresh" class="btn ghost">刷新</button>
|
<button id="plugin-refresh" class="btn ghost">刷新</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -105,7 +112,22 @@
|
|||||||
<div class="tool-card">
|
<div class="tool-card">
|
||||||
<div class="tool-top"><span class="tool-ico">🎤</span><div class="tool-name">语音输入</div></div>
|
<div class="tool-top"><span class="tool-ico">🎤</span><div class="tool-name">语音输入</div></div>
|
||||||
<div class="tool-desc">给 Codex 输入框注入麦克风按钮,说话即转文字填入。需由工具箱重启 Codex 一次以启用调试通道(登录态保留)。</div>
|
<div class="tool-desc">给 Codex 输入框注入麦克风按钮,说话即转文字填入。需由工具箱重启 Codex 一次以启用调试通道(登录态保留)。</div>
|
||||||
<div class="tool-foot"><button class="btn primary small" data-open-panel="voice">打开</button></div>
|
<div class="tool-foot"><button class="btn primary small" id="voice-enable-card-btn" data-voice-enable data-enable-label="启用" data-enabled-label="已启用">启用</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">用一段话描述项目需求,自动拆解出一支带约束、能协作的 AI 员工团队(含总控 + 契约 + 文件边界 + 协同规范),打包成 Codex / Claude Code 可直接用的配置 zip。</div>
|
||||||
|
<div class="tool-foot"><button class="btn primary small" data-open-panel="team">打开</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">上传图片或视频,AI 自动反推出能复现它的生图提示词。中英双语、七维拆解、负面提示词、关键词标签,直接用你的中转分组运行。</div>
|
||||||
|
<div class="tool-foot"><button class="btn primary small" data-open-panel="prompt">打开</button></div>
|
||||||
|
</div>
|
||||||
|
<div class="tool-card">
|
||||||
|
<div class="tool-top"><span class="tool-ico">⚡</span><div class="tool-name">Token 用量</div></div>
|
||||||
|
<div class="tool-desc">在 Codex 输入框下方、模型选择左侧显示每日 Token 累计消耗量。复用语音 CDP 连接自动注入,无需额外配置。每天 24 点自动清零。</div>
|
||||||
|
<div class="tool-foot"><button class="btn primary small" data-open-panel="token">打开</button></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -130,14 +152,13 @@
|
|||||||
<div class="head-actions">
|
<div class="head-actions">
|
||||||
<span id="voice-dot" class="voice-dot" data-state="unknown"></span>
|
<span id="voice-dot" class="voice-dot" data-state="unknown"></span>
|
||||||
<span id="voice-phase-text" class="muted small">未连接</span>
|
<span id="voice-phase-text" class="muted small">未连接</span>
|
||||||
<button id="voice-connect-btn" class="btn primary">连接</button>
|
<button id="voice-enable-btn" class="btn primary" data-voice-enable data-enable-label="启用语音输入" data-enabled-label="已启用">启用语音输入</button>
|
||||||
<button id="voice-disconnect-btn" class="btn ghost hidden">断开</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="voice-body">
|
<div class="voice-body">
|
||||||
<div class="voice-card">
|
<div class="voice-card">
|
||||||
<div class="voice-card-title">状态</div>
|
<div class="voice-card-title">状态</div>
|
||||||
<div id="voice-detail" class="muted small">点击「连接」开始。若 Codex 正在运行,会提示你确认重启 Codex 以启用调试端口。</div>
|
<div id="voice-detail" class="muted small">点击「启用语音输入」开始。若 Codex 正在运行,会提示你确认重启 Codex 以启用调试端口。</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="voice-card">
|
<div class="voice-card">
|
||||||
<div class="voice-card-title">转写分组 / 模型</div>
|
<div class="voice-card-title">转写分组 / 模型</div>
|
||||||
@@ -155,7 +176,7 @@
|
|||||||
<div class="voice-card">
|
<div class="voice-card">
|
||||||
<div class="voice-card-title">使用说明</div>
|
<div class="voice-card-title">使用说明</div>
|
||||||
<ol class="voice-ol">
|
<ol class="voice-ol">
|
||||||
<li>点击「连接」。若 Codex 在运行且未带调试端口,会弹窗提示重启(当前窗口会关闭一次,登录态保留)。</li>
|
<li>点击「启用语音输入」。若 Codex 在运行且未带调试端口,会弹窗提示重启(当前窗口会关闭一次,登录态保留)。</li>
|
||||||
<li>连接成功后,Codex 输入框旁出现 🎤 按钮。点击 → 授权麦克风权限 → 说话 → 再次点击停止。</li>
|
<li>连接成功后,Codex 输入框旁出现 🎤 按钮。点击 → 授权麦克风权限 → 说话 → 再次点击停止。</li>
|
||||||
<li>转写完成后文字自动填入输入框。首次需在「系统设置 → 隐私 → 麦克风」允许 ChatGPT。</li>
|
<li>转写完成后文字自动填入输入框。首次需在「系统设置 → 隐私 → 麦克风」允许 ChatGPT。</li>
|
||||||
<li>macOS 误杀进程风险已规避:仅在确认后重启。Codex 升级后若按钮消失,可能需等待工具箱适配。</li>
|
<li>macOS 误杀进程风险已规避:仅在确认后重启。Codex 升级后若按钮消失,可能需等待工具箱适配。</li>
|
||||||
@@ -201,6 +222,192 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- 员工团队:需求 → AI 员工团队 → 打包 zip -->
|
||||||
|
<section class="panel" id="tab-team">
|
||||||
|
<div class="tm-wrap">
|
||||||
|
<!-- 左侧:需求输入 + 配置 -->
|
||||||
|
<div class="tm-input-col">
|
||||||
|
<h2 class="tm-title"><button class="btn ghost small back-btn" data-back-plugins>← 插件市场</button> 生成 AI 员工团队</h2>
|
||||||
|
<p class="tm-sub">用一段话描述项目需求,自动拆解出一支带约束、能协作的 AI 员工团队(含总控),打包成 Codex / Claude Code 可直接用的配置。</p>
|
||||||
|
<textarea id="tm-req" class="tm-req" placeholder="例如:做一个电商后台管理系统,需要商品管理、订单处理、库存、用户权限和数据看板,前端要好看,后端要稳。"></textarea>
|
||||||
|
|
||||||
|
<div class="tm-field">
|
||||||
|
<label class="tm-label">目标工具</label>
|
||||||
|
<div class="tm-chips" id="tm-tool">
|
||||||
|
<button class="tm-chip active" data-tool="codex">Codex</button>
|
||||||
|
<button class="tm-chip" data-tool="claude">Claude Code</button>
|
||||||
|
</div>
|
||||||
|
<div class="tm-hint" id="tm-tool-hint">生成 .toml 员工 + 总控(调度 / 审核)+ AGENTS.md</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tm-field">
|
||||||
|
<label class="tm-label">分析模型(中转分组)</label>
|
||||||
|
<div class="tm-row">
|
||||||
|
<select class="tm-select" id="tm-group"></select>
|
||||||
|
<button class="btn small ghost" id="tm-fetch">🔄 拉取模型</button>
|
||||||
|
</div>
|
||||||
|
<select class="tm-select" id="tm-model" style="margin-top:8px"></select>
|
||||||
|
<div class="tm-hint" id="tm-model-hint">先在『路由中转』配置好分组,再选具体模型</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tm-field">
|
||||||
|
<label class="tm-label">员工运行模型(可选)</label>
|
||||||
|
<input id="tm-agent-model" class="tm-text" placeholder="留空=用上面的分析模型,如 gpt-4o" />
|
||||||
|
<div class="tm-hint" id="tm-agent-hint">写进每个员工的 model 字段,改成你运行器支持的模型名</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button id="tm-gen" class="btn primary tm-gen" disabled>生成员工团队</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 右侧:结果 -->
|
||||||
|
<div class="tm-result-col">
|
||||||
|
<div class="tm-result-head">
|
||||||
|
<span class="tm-result-title">员工团队</span>
|
||||||
|
<button class="btn small primary" id="tm-download" hidden>⬇ 打包下载 .zip</button>
|
||||||
|
</div>
|
||||||
|
<div class="tm-result-body">
|
||||||
|
<div class="tm-empty" id="tm-empty">
|
||||||
|
<div class="tm-empty-ico">🧑💼→👥</div>
|
||||||
|
<p class="tm-empty-title">描述需求,自动组建 AI 员工团队</p>
|
||||||
|
<p class="tm-empty-sub">拆解职责 · 定义契约 · 划分文件边界 · 生成协同规范</p>
|
||||||
|
</div>
|
||||||
|
<div class="tm-loading" id="tm-loading" hidden>
|
||||||
|
<div class="tm-spinner"></div>
|
||||||
|
<div class="tm-loading-text" id="tm-loading-text">正在分析需求、拆解团队…</div>
|
||||||
|
</div>
|
||||||
|
<div class="tm-content" id="tm-content" hidden>
|
||||||
|
<div class="tm-team-head" id="tm-team-head"></div>
|
||||||
|
<div id="tm-team-list"></div>
|
||||||
|
</div>
|
||||||
|
<div class="tm-error" id="tm-error" hidden></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ②.⑥ 提示词提取(图片 → 反推生图提示词) -->
|
||||||
|
<section class="panel" id="tab-prompt">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div>
|
||||||
|
<h2><button class="btn ghost small back-btn" data-back-plugins>← 插件市场</button> 提示词提取</h2>
|
||||||
|
<p class="muted">上传图片或视频,AI 自动分析九个视觉维度,反推出能复现它的中英双语生图提示词。直接用你的中转分组运行。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pm-wrap">
|
||||||
|
<!-- 左侧:上传 + 设置 -->
|
||||||
|
<div class="pm-input-col">
|
||||||
|
<div class="pm-dropzone" id="pm-dropzone">
|
||||||
|
<input type="file" id="pm-fileInput" accept="image/*,video/*" hidden />
|
||||||
|
<div class="pm-dz-empty" id="pm-dzEmpty">
|
||||||
|
<div class="pm-dz-icon">📤</div>
|
||||||
|
<div class="pm-dz-title">拖拽图片 / 视频到这里</div>
|
||||||
|
<div class="pm-dz-sub">或点击选择 · JPG / PNG / WebP / MP4</div>
|
||||||
|
</div>
|
||||||
|
<div class="pm-dz-preview" id="pm-dzPreview" hidden>
|
||||||
|
<img id="pm-previewImg" alt="预览" />
|
||||||
|
<button class="pm-dz-clear" id="pm-clearBtn" title="移除">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pm-field">
|
||||||
|
<label class="pm-label">分析模型(中转分组)</label>
|
||||||
|
<div class="pm-row">
|
||||||
|
<select class="pm-select" id="pm-group"></select>
|
||||||
|
<button class="btn small ghost" id="pm-fetch">🔄 拉取模型</button>
|
||||||
|
</div>
|
||||||
|
<select class="pm-select" id="pm-model" style="margin-top:8px"></select>
|
||||||
|
<div class="pm-hint" id="pm-model-hint">先在『路由中转』配置好分组,再选具体模型(需要视觉/多模态模型)</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button id="pm-analyze" class="btn primary pm-gen" disabled>开始分析</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 右侧:结果(分析完成后动态渲染,平时只展示空状态) -->
|
||||||
|
<div class="pm-result-col">
|
||||||
|
<div class="pm-result-body" id="pm-result-body">
|
||||||
|
<div class="pm-empty" id="pm-empty">
|
||||||
|
<div class="pm-empty-ico">🖼️→📝</div>
|
||||||
|
<p class="pm-empty-title">上传素材,反推它的生图提示词</p>
|
||||||
|
<p class="pm-empty-sub">精准分析九个视觉维度,输出可直接复现的双语提示词</p>
|
||||||
|
<div class="pm-empty-feats"><span>七维拆解</span><span>中英双语</span><span>负面提示词</span><span>关键词标签</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ②.⑦ Token 用量(Codex 输入框下方工具栏进度条) -->
|
||||||
|
<section class="panel" id="tab-token">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div>
|
||||||
|
<h2><button class="btn ghost small back-btn" data-back-plugins>← 插件市场</button> Token 用量</h2>
|
||||||
|
<p class="muted">在 Codex 输入框下方、模型选择左侧显示 Token 累计消耗条。复用语音 CDP 连接自动注入,每天 24 点自动清零。</p>
|
||||||
|
</div>
|
||||||
|
<div class="head-actions">
|
||||||
|
<span id="tk-dot" class="voice-dot" data-state="unknown"></span>
|
||||||
|
<span id="tk-phase-text" class="muted small">未连接</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tk-body">
|
||||||
|
<!-- 今日用量 Hero -->
|
||||||
|
<div class="tk-card hero">
|
||||||
|
<div class="tk-hero-num" id="tk-total">—</div>
|
||||||
|
<div class="tk-hero-label">今日累计消耗</div>
|
||||||
|
<div class="tk-breakdown" id="tk-breakdown">
|
||||||
|
<span>输入 <b id="tk-input">0</b></span>
|
||||||
|
<span>输出 <b id="tk-output">0</b></span>
|
||||||
|
<span>缓存 <b id="tk-cached">0</b></span>
|
||||||
|
<span>估算 <b id="tk-estimate">0</b></span>
|
||||||
|
<span>未知 <b id="tk-unknown">0</b></span>
|
||||||
|
</div>
|
||||||
|
<div class="tk-hero-date" id="tk-date"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Codex 效果预览 -->
|
||||||
|
<div class="tk-card">
|
||||||
|
<div class="tk-card-title">Codex 输入框下方效果</div>
|
||||||
|
<div class="tk-preview">
|
||||||
|
<div class="tk-preview-bar" id="tk-preview-bar">
|
||||||
|
<div class="tk-preview-meter">
|
||||||
|
<div class="tk-preview-track">
|
||||||
|
<div class="tk-preview-segments" id="tk-preview-segments">
|
||||||
|
<i class="tk-preview-seg"></i><i class="tk-preview-seg"></i><i class="tk-preview-seg"></i><i class="tk-preview-seg"></i>
|
||||||
|
<i class="tk-preview-seg"></i><i class="tk-preview-seg"></i><i class="tk-preview-seg"></i><i class="tk-preview-seg"></i>
|
||||||
|
</div>
|
||||||
|
<span class="tk-preview-badge"><span class="tk-preview-check">✓</span><span id="tk-preview-badge-text">0 token</span></span>
|
||||||
|
<span class="tk-preview-cap"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="tk-preview-input">输入消息…</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 操作 -->
|
||||||
|
<div class="tk-card">
|
||||||
|
<div class="tk-card-title">操作</div>
|
||||||
|
<div class="tk-row">
|
||||||
|
<button id="tk-refresh" class="btn ghost">🔄 刷新用量</button>
|
||||||
|
<button id="tk-reset" class="btn danger small">↺ 手动清零</button>
|
||||||
|
</div>
|
||||||
|
<div class="muted small" style="margin-top:8px">
|
||||||
|
用量数据保存在 Codex 页面 localStorage 中,跨天自动清零。<br />需要先在「插件市场」主页连接 Codex(Token 脚本随连接一起注入)。
|
||||||
|
</div>
|
||||||
|
<div class="tk-diag muted small" id="tk-diag"></div>
|
||||||
|
</div>
|
||||||
|
<div class="tk-card">
|
||||||
|
<div class="tk-card-title">工作原理</div>
|
||||||
|
<ol class="voice-ol">
|
||||||
|
<li>语音连接 Codex 时,Token 监控脚本自动注入同一个 CDP 连接。</li>
|
||||||
|
<li>主进程优先读取 Codex 本地 session 的 <code>token_count</code> 累计快照,网络 <code>usage</code> 仅作为兼容兜底。</li>
|
||||||
|
<li>用量条挂在输入框下方工具栏,优先放在模型选择左侧。</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- ③ 会话记录 -->
|
<!-- ③ 会话记录 -->
|
||||||
<section class="panel" id="tab-sessions">
|
<section class="panel" id="tab-sessions">
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
@@ -321,7 +528,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="panel-head" style="margin-top:18px">
|
<div class="panel-head" style="margin-top:18px">
|
||||||
<div><h3>中转分组</h3><p class="muted">每个分组:名称 + API 地址 + 密钥 +(可选)模型。选中谁就转发到谁。</p></div>
|
<div><h3>中转分组</h3><p class="muted">每个分组:名称 + API 地址 + 密钥 +(可选)模型。按协议分两栏:Codex/GPT 与 Claude Code 各选一个,<b>两者可同时激活一起用</b>。</p></div>
|
||||||
<button id="grp-add" class="btn">+ 新增分组</button>
|
<button id="grp-add" class="btn">+ 新增分组</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="router-groups" class="checklist"></div>
|
<div id="router-groups" class="checklist"></div>
|
||||||
@@ -337,6 +544,7 @@
|
|||||||
<select id="grp-mode" class="grp-select" style="margin-top:0">
|
<select id="grp-mode" class="grp-select" style="margin-top:0">
|
||||||
<option value="responses">Responses 直连(OpenAI / TokenDun 等 Codex 原生)</option>
|
<option value="responses">Responses 直连(OpenAI / TokenDun 等 Codex 原生)</option>
|
||||||
<option value="chat">Chat 补全 · 自动转换(国产:DeepSeek / GLM / Kimi / 通义)</option>
|
<option value="chat">Chat 补全 · 自动转换(国产:DeepSeek / GLM / Kimi / 通义)</option>
|
||||||
|
<option value="claude">Claude Code(接入 Claude Code CLI,写入 ~/.claude/settings.json)</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</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 地址(base_url)</span><input id="grp-url" placeholder="https://api.xxx.com" /></label>
|
||||||
@@ -362,6 +570,11 @@
|
|||||||
<select id="grp-model-list" class="grp-select hidden"><option value="">— 从下拉选择模型 —</option></select>
|
<select id="grp-model-list" class="grp-select hidden"><option value="">— 从下拉选择模型 —</option></select>
|
||||||
<span class="muted small" id="grp-model-hint"></span>
|
<span class="muted small" id="grp-model-hint"></span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="field grow" id="grp-1m-field" style="margin-top:10px; display:none">
|
||||||
|
<span>1M 长上下文(勾选支持百万上下文的模型;转发这些模型时自动带 1M beta 头)</span>
|
||||||
|
<div id="grp-1m-list" class="grp-1m-list"></div>
|
||||||
|
<span class="muted small">仅对本身支持 1M 的模型有效(如 GPT/GLM/Claude 的 1M 版)。勾选后经此分组转发该模型即解锁 1M 窗口。</span>
|
||||||
|
</div>
|
||||||
<div class="field grow" style="margin-top:10px">
|
<div class="field grow" style="margin-top:10px">
|
||||||
<span>上下文窗口 tokens(国产模型建议填,Codex 据此自动压缩)</span>
|
<span>上下文窗口 tokens(国产模型建议填,Codex 据此自动压缩)</span>
|
||||||
<div class="row" style="gap:6px; flex-wrap:wrap; align-items:center">
|
<div class="row" style="gap:6px; flex-wrap:wrap; align-items:center">
|
||||||
@@ -372,6 +585,19 @@
|
|||||||
</div>
|
</div>
|
||||||
<span class="muted small">DeepSeek≈64K/128K · GLM≈128K · Kimi≈128K/200K · 通义≈128K。留空则用 Codex 默认(可能过大导致超限)</span>
|
<span class="muted small">DeepSeek≈64K/128K · GLM≈128K · Kimi≈128K/200K · 通义≈128K。留空则用 Codex 默认(可能过大导致超限)</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="field grow claudedesk-block" id="grp-claudedesk" style="margin-top:14px; border-top:1px solid var(--border); padding-top:14px">
|
||||||
|
<span>接入 Claude 桌面 App(第三方推理)</span>
|
||||||
|
<div class="muted small" id="grp-cd-hint" style="margin:2px 0 8px">把该分组一键写入 Claude 桌面 App,重启 App 即用你的中转。仅支持 Anthropic 协议分组。</div>
|
||||||
|
<div class="row" style="gap:8px; flex-wrap:wrap; align-items:center">
|
||||||
|
<button type="button" class="btn primary small" id="grp-cd-apply">⚡ 一键接入</button>
|
||||||
|
<button type="button" class="btn ghost small" id="grp-cd-export">导出配置文件</button>
|
||||||
|
<button type="button" class="btn ghost small" id="grp-cd-copy">复制配置</button>
|
||||||
|
<button type="button" class="btn small" id="grp-cd-restore" style="border-color:var(--danger); color:var(--danger)">↩ 还原官方登录</button>
|
||||||
|
</div>
|
||||||
|
<div class="muted small" id="grp-cd-status" style="margin-top:8px"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="row between" style="margin-top:16px">
|
<div class="row between" style="margin-top:16px">
|
||||||
<span></span>
|
<span></span>
|
||||||
<button id="grp-save" class="btn primary">保存</button>
|
<button id="grp-save" class="btn primary">保存</button>
|
||||||
|
|||||||
@@ -137,6 +137,9 @@ h3 { margin: 0; font-size: 16px; }
|
|||||||
.pc-badge.plugin { background: rgba(124,133,255,.16); color: var(--accent); }
|
.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.skill { background: rgba(74,222,128,.15); color: var(--ok); }
|
||||||
.pc-badge.plat { background: rgba(245,158,11,.16); color: #f59e0b; }
|
.pc-badge.plat { background: rgba(245,158,11,.16); color: #f59e0b; }
|
||||||
|
.pc-badge.warn { background: rgba(245,158,11,.16); color: #f59e0b; }
|
||||||
|
.cl-warn { margin-top: 6px; font-size: 12px; color: #f59e0b;
|
||||||
|
background: rgba(245,158,11,.1); border-radius: 8px; padding: 6px 9px; }
|
||||||
.pc-badge.on { background: rgba(74,222,128,.18); color: var(--ok); }
|
.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; }
|
.proxy-modes { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-top: 14px; }
|
||||||
@@ -167,6 +170,12 @@ h3 { margin: 0; font-size: 16px; }
|
|||||||
.cl-item .cl-src { color: var(--muted); font-size: 12px; }
|
.cl-item .cl-src { color: var(--muted); font-size: 12px; }
|
||||||
.cl-item .cl-size { margin-left: auto; color: var(--muted); font-size: 12px; }
|
.cl-item .cl-size { margin-left: auto; color: var(--muted); font-size: 12px; }
|
||||||
|
|
||||||
|
/* 路由分组:按协议族分两栏,各一个单选,可同时激活 */
|
||||||
|
#router-groups { display: flex; gap: 14px; flex-wrap: wrap; margin-bottom: 16px; }
|
||||||
|
.router-col { flex: 1; min-width: 280px; display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.router-col-head { display: flex; align-items: baseline; gap: 8px; padding: 0 2px 2px; }
|
||||||
|
.router-col .empty.small { font-size: 12px; padding: 10px 14px; }
|
||||||
|
|
||||||
/* 子标签 */
|
/* 子标签 */
|
||||||
.subtabs { display: flex; gap: 6px; margin-bottom: 18px; }
|
.subtabs { display: flex; gap: 6px; margin-bottom: 18px; }
|
||||||
.subtab { border: 1px solid var(--border); background: var(--card); color: var(--muted);
|
.subtab { border: 1px solid var(--border); background: var(--card); color: var(--muted);
|
||||||
@@ -316,6 +325,16 @@ h3 { margin: 0; font-size: 16px; }
|
|||||||
border: 1px solid var(--border); color: var(--text); border-radius: 10px;
|
border: 1px solid var(--border); color: var(--text); border-radius: 10px;
|
||||||
padding: 9px 12px; outline: none; font-size: 13px; }
|
padding: 9px 12px; outline: none; font-size: 13px; }
|
||||||
.grp-select:focus { border-color: var(--accent); }
|
.grp-select:focus { border-color: var(--accent); }
|
||||||
|
.grp-1m-list { margin-top: 8px; display: flex; flex-direction: column; gap: 2px;
|
||||||
|
max-height: 180px; overflow-y: auto; border: 1px solid var(--border);
|
||||||
|
border-radius: 10px; padding: 6px; background: var(--bg-2); }
|
||||||
|
.grp-1m-item { display: flex; align-items: center; gap: 8px; padding: 5px 7px;
|
||||||
|
border-radius: 7px; cursor: pointer; font-size: 13px; }
|
||||||
|
.grp-1m-item:hover { background: var(--card); }
|
||||||
|
.grp-1m-item input { width: 15px; height: 15px; accent-color: var(--accent); cursor: pointer; flex: none; }
|
||||||
|
.grp-1m-item span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.claudedesk-block > span:first-child { font-weight: 600; font-size: 13px; }
|
||||||
|
.claudedesk-block .btn:disabled { opacity: .45; cursor: not-allowed; }
|
||||||
|
|
||||||
/* —— 会话记忆 —— */
|
/* —— 会话记忆 —— */
|
||||||
.mem-item { background: var(--card); border: 1px solid var(--border);
|
.mem-item { background: var(--card); border: 1px solid var(--border);
|
||||||
@@ -666,3 +685,209 @@ body.cv-noscroll { overflow: hidden; }
|
|||||||
.voice-dot[data-state="restarting"], .voice-dot[data-state="connecting"], .voice-dot[data-state="injecting"], .voice-dot[data-state="checking"] { background: #60a5fa; }
|
.voice-dot[data-state="restarting"], .voice-dot[data-state="connecting"], .voice-dot[data-state="injecting"], .voice-dot[data-state="checking"] { background: #60a5fa; }
|
||||||
.voice-dot[data-state="error"] { background: #ef4444; box-shadow: 0 0 8px rgba(239,68,68,.7); }
|
.voice-dot[data-state="error"] { background: #ef4444; box-shadow: 0 0 8px rgba(239,68,68,.7); }
|
||||||
.voice-dot[data-state="running_no_port"] { background: #f59e0b; }
|
.voice-dot[data-state="running_no_port"] { background: #f59e0b; }
|
||||||
|
.codex-link-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 5px 7px 5px 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--card);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.codex-link-label { font-weight: 700; font-size: 12px; }
|
||||||
|
.codex-link-status .btn.small { padding: 5px 10px; }
|
||||||
|
|
||||||
|
/* ===== 员工团队 ===== */
|
||||||
|
#tab-team { padding: 0; }
|
||||||
|
.tm-wrap { display: grid; grid-template-columns: 380px 1fr; gap: 16px; height: 100%; padding: 16px 20px; box-sizing: border-box; }
|
||||||
|
.tm-input-col { display: flex; flex-direction: column; gap: 12px; overflow-y: auto; padding-right: 4px; }
|
||||||
|
.tm-title { font-size: 18px; margin: 0; }
|
||||||
|
.tm-sub { color: var(--muted); font-size: 12.5px; line-height: 1.5; margin: 0; }
|
||||||
|
.tm-req { width: 100%; min-height: 130px; resize: vertical; box-sizing: border-box;
|
||||||
|
background: var(--card); border: 1px solid var(--border); color: var(--text);
|
||||||
|
border-radius: 10px; padding: 12px; font-size: 13.5px; line-height: 1.6; font-family: inherit; }
|
||||||
|
.tm-req:focus { border-color: var(--accent); outline: none; }
|
||||||
|
.tm-field { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.tm-label { font-size: 12.5px; font-weight: 600; color: var(--text); }
|
||||||
|
.tm-hint { font-size: 11.5px; color: var(--muted); line-height: 1.4; }
|
||||||
|
.tm-row { display: flex; gap: 8px; align-items: center; }
|
||||||
|
.tm-select, .tm-text { background: var(--card); border: 1px solid var(--border); color: var(--text);
|
||||||
|
border-radius: 9px; padding: 8px 10px; font-size: 13px; font-family: inherit; flex: 1; min-width: 0; }
|
||||||
|
.tm-select:focus, .tm-text:focus { border-color: var(--accent); outline: none; }
|
||||||
|
.tm-chips { display: flex; gap: 6px; }
|
||||||
|
.tm-chip { border: 1px solid var(--border); background: var(--card); color: var(--muted);
|
||||||
|
padding: 6px 14px; border-radius: 8px; font-size: 12.5px; cursor: pointer; }
|
||||||
|
.tm-chip.active { color: #fff; background: var(--grad); border-color: transparent; }
|
||||||
|
.tm-gen { margin-top: 4px; padding: 11px; font-size: 14px; }
|
||||||
|
|
||||||
|
.tm-result-col { display: flex; flex-direction: column; background: var(--bg-2);
|
||||||
|
border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; min-height: 0; }
|
||||||
|
.tm-result-head { display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 12px 16px; border-bottom: 1px solid var(--border); }
|
||||||
|
.tm-result-title { font-weight: 600; font-size: 14px; }
|
||||||
|
.tm-result-body { flex: 1; overflow-y: auto; padding: 16px; min-height: 0; }
|
||||||
|
.tm-empty, .tm-loading, .tm-error { display: flex; flex-direction: column; align-items: center;
|
||||||
|
justify-content: center; text-align: center; height: 100%; gap: 8px; color: var(--muted); }
|
||||||
|
.tm-empty-ico { font-size: 34px; }
|
||||||
|
.tm-empty-title { font-size: 15px; color: var(--text); margin: 0; }
|
||||||
|
.tm-empty-sub { font-size: 12.5px; margin: 0; }
|
||||||
|
.tm-error { color: var(--danger); font-size: 13.5px; padding: 20px; }
|
||||||
|
.tm-spinner { width: 34px; height: 34px; border: 3px solid var(--border);
|
||||||
|
border-top-color: var(--accent); border-radius: 50%; animation: tmspin .8s linear infinite; }
|
||||||
|
@keyframes tmspin { to { transform: rotate(360deg); } }
|
||||||
|
.tm-loading-text { font-size: 13px; }
|
||||||
|
.tm-team-head { margin-bottom: 14px; }
|
||||||
|
.tm-team-name { font-size: 18px; font-weight: 700; }
|
||||||
|
.tm-team-summary { color: var(--muted); font-size: 13px; margin-top: 4px; line-height: 1.5; }
|
||||||
|
.tm-team-stack { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
|
||||||
|
.tm-stack-chip { background: var(--card-2); border: 1px solid var(--border); color: var(--text);
|
||||||
|
padding: 3px 10px; border-radius: 20px; font-size: 11.5px; }
|
||||||
|
.tm-conv-card, .tm-emp-card { background: var(--card); border: 1px solid var(--border);
|
||||||
|
border-radius: 12px; padding: 14px 16px; margin-bottom: 12px; }
|
||||||
|
.tm-emp-card.lead { border-color: var(--accent); box-shadow: 0 0 0 1px rgba(124,133,255,.25); }
|
||||||
|
.tm-conv-title { font-weight: 700; font-size: 14px; margin-bottom: 6px; }
|
||||||
|
.tm-conv-owner { font-size: 12px; color: var(--muted); margin-bottom: 12px; line-height: 1.5; }
|
||||||
|
.tm-conv-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||||
|
.tm-cc-label { font-size: 12px; font-weight: 600; color: var(--accent); margin-bottom: 4px; }
|
||||||
|
.tm-conv-col ul { margin: 0; padding-left: 16px; font-size: 12px; color: var(--muted); line-height: 1.55; }
|
||||||
|
.tm-emp-top { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||||
|
.tm-emp-name { font-weight: 700; font-size: 14.5px; }
|
||||||
|
.tm-emp-badge { background: var(--grad); color: #fff; font-size: 10.5px; padding: 2px 7px; border-radius: 6px; }
|
||||||
|
.tm-emp-id { font-family: ui-monospace, monospace; font-size: 11.5px; color: var(--muted);
|
||||||
|
background: var(--card-2); padding: 1px 6px; border-radius: 5px; }
|
||||||
|
.tm-emp-duty { font-size: 13px; color: var(--text); margin-top: 5px; line-height: 1.5; }
|
||||||
|
.tm-sec-label { font-size: 11.5px; font-weight: 600; color: var(--accent); margin: 10px 0 4px; }
|
||||||
|
.tm-resp, .tm-flow { margin: 0; padding-left: 16px; font-size: 12.5px; color: var(--muted); line-height: 1.55; }
|
||||||
|
.tm-collab { display: flex; flex-direction: column; gap: 3px; margin-top: 8px; }
|
||||||
|
.tm-ec-row { display: flex; gap: 8px; font-size: 12px; color: var(--muted); }
|
||||||
|
.tm-ec-k { color: var(--accent); font-weight: 600; min-width: 32px; }
|
||||||
|
.tm-ec-row code, .tm-collab code { font-family: ui-monospace, monospace; font-size: 11px;
|
||||||
|
background: var(--card-2); padding: 1px 5px; border-radius: 4px; color: var(--text); }
|
||||||
|
.tm-cons { display: flex; flex-wrap: wrap; gap: 5px; }
|
||||||
|
.tm-con-chip { font-size: 11px; background: var(--card-2); border: 1px solid var(--border);
|
||||||
|
color: var(--muted); padding: 2px 8px; border-radius: 6px; }
|
||||||
|
.tm-file-block { margin-top: 10px; }
|
||||||
|
.tm-file-toggle { border: 1px dashed var(--border); background: transparent; color: var(--accent);
|
||||||
|
font-size: 12px; padding: 5px 10px; border-radius: 7px; cursor: pointer; width: 100%; }
|
||||||
|
.tm-file-toggle:hover { border-color: var(--accent); }
|
||||||
|
.tm-file-view { margin-top: 8px; border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
|
||||||
|
.tm-file-head { display: flex; justify-content: space-between; align-items: center;
|
||||||
|
padding: 6px 10px; background: var(--card-2); border-bottom: 1px solid var(--border); }
|
||||||
|
.tm-file-name { font-family: ui-monospace, monospace; font-size: 11.5px; color: var(--muted); }
|
||||||
|
.tm-file-copy { border: 1px solid var(--border); background: var(--card); color: var(--text);
|
||||||
|
font-size: 11px; padding: 3px 10px; border-radius: 6px; cursor: pointer; }
|
||||||
|
.tm-file-pre { margin: 0; padding: 12px; font-family: ui-monospace, monospace; font-size: 11.5px;
|
||||||
|
line-height: 1.55; color: var(--text); white-space: pre-wrap; word-break: break-word;
|
||||||
|
max-height: 340px; overflow-y: auto; background: var(--bg); }
|
||||||
|
@media (max-width: 820px) { .tm-wrap { grid-template-columns: 1fr; } .tm-conv-grid { grid-template-columns: 1fr; } }
|
||||||
|
|
||||||
|
/* —— ②.⑥ 提示词提取 —— */
|
||||||
|
.pm-wrap { display: grid; grid-template-columns: 380px 1fr; gap: 18px; padding: 14px 18px; height: calc(100vh - 220px); overflow: hidden; }
|
||||||
|
.pm-input-col { display: flex; flex-direction: column; gap: 12px; overflow-y: auto; padding-right: 4px; }
|
||||||
|
.pm-result-col { display: flex; flex-direction: column; overflow: hidden; }
|
||||||
|
.pm-result-head { display: flex; align-items: center; gap: 10px; padding: 0 0 8px; border-bottom: 1px solid var(--border); }
|
||||||
|
.pm-result-title { font-weight: 600; font-size: 14px; }
|
||||||
|
.pm-meta-model { font-size: 11px; color: var(--muted); font-family: ui-monospace, monospace; }
|
||||||
|
.pm-meta-conf { font-size: 11px; padding: 2px 8px; border-radius: 10px; font-weight: 600; }
|
||||||
|
.pm-conf-high { background: #1a3a1a; color: #4ade80; }
|
||||||
|
.pm-conf-medium { background: #3a2e0a; color: #fbbf24; }
|
||||||
|
.pm-conf-low { background: #3a1a1a; color: #ff6b6b; }
|
||||||
|
|
||||||
|
.pm-result-body { flex: 1; overflow-y: auto; padding-right: 4px; }
|
||||||
|
|
||||||
|
.pm-dropzone { border: 2px dashed var(--border); border-radius: 12px; padding: 28px 16px;
|
||||||
|
text-align: center; cursor: pointer; transition: border-color .2s; min-height: 150px; display: flex; align-items: center; justify-content: center; }
|
||||||
|
.pm-dropzone:hover, .pm-dropzone.drag { border-color: var(--accent); }
|
||||||
|
.pm-dz-icon { font-size: 36px; margin-bottom: 8px; }
|
||||||
|
.pm-dz-title { font-size: 14px; font-weight: 600; margin-bottom: 4px; }
|
||||||
|
.pm-dz-sub { font-size: 12px; color: var(--muted); }
|
||||||
|
.pm-dz-preview { position: relative; }
|
||||||
|
.pm-dz-preview img { max-width: 100%; max-height: 200px; border-radius: 10px; object-fit: contain; }
|
||||||
|
.pm-dz-clear { position: absolute; top: 6px; right: 6px; background: rgba(0,0,0,0.65); color: #fff;
|
||||||
|
border: none; border-radius: 50%; width: 24px; height: 24px; font-size: 14px; cursor: pointer; line-height: 1; }
|
||||||
|
|
||||||
|
.pm-field { display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.pm-label { font-size: 12.5px; font-weight: 600; color: var(--muted); }
|
||||||
|
.pm-row { display: flex; gap: 8px; align-items: center; }
|
||||||
|
.pm-row .pm-select { flex: 1; min-width: 0; }
|
||||||
|
.pm-row .btn { flex-shrink: 0; white-space: nowrap; }
|
||||||
|
.pm-select { width: 100%; padding: 8px 10px; border-radius: 8px; border: 1px solid var(--border);
|
||||||
|
background: var(--card); color: var(--text); font-size: 13px; }
|
||||||
|
.pm-hint { font-size: 11px; color: var(--muted); line-height: 1.4; }
|
||||||
|
.pm-gen { width: 100%; padding: 12px; font-size: 15px; font-weight: 600; border-radius: 10px; }
|
||||||
|
|
||||||
|
.pm-empty, .pm-loading, .pm-error { display: flex; flex-direction: column; align-items: center;
|
||||||
|
justify-content: center; text-align: center; padding: 40px 20px; }
|
||||||
|
.pm-empty-ico { font-size: 48px; margin-bottom: 12px; }
|
||||||
|
.pm-empty-title { font-size: 15px; font-weight: 600; margin: 0 0 6px; }
|
||||||
|
.pm-empty-sub { font-size: 13px; color: var(--muted); margin: 0 0 14px; }
|
||||||
|
.pm-empty-feats { display: flex; gap: 8px; flex-wrap: wrap; justify-content: center; }
|
||||||
|
.pm-empty-feats span { font-size: 11px; background: var(--card-2); border: 1px solid var(--border);
|
||||||
|
padding: 3px 10px; border-radius: 14px; color: var(--muted); }
|
||||||
|
.pm-spinner { width: 32px; height: 32px; border: 3px solid var(--border); border-top-color: var(--accent);
|
||||||
|
border-radius: 50%; animation: spin .8s linear infinite; margin-bottom: 12px; }
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
.pm-loading-text { font-size: 13px; color: var(--muted); }
|
||||||
|
.pm-error { color: var(--danger); font-size: 13px; }
|
||||||
|
|
||||||
|
.pm-content { display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
.pm-card { border: 1px solid var(--border); border-radius: 10px; padding: 14px; background: var(--card); }
|
||||||
|
.pm-card.primary { border-color: var(--accent); border-width: 1.5px; }
|
||||||
|
.pm-card.subtle { background: var(--card-2); border-style: dashed; }
|
||||||
|
.pm-card-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
|
||||||
|
.pm-card-title { font-size: 12px; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: .5px; }
|
||||||
|
.pm-card-title em { font-style: normal; color: var(--accent); font-size: 10px; margin-left: 4px; }
|
||||||
|
.pm-prompt-text { font-size: 13.5px; line-height: 1.65; margin: 0; color: var(--text); }
|
||||||
|
.pm-prompt-text.mono { font-family: ui-monospace, monospace; font-size: 12px; }
|
||||||
|
.pm-prompt-text.zh-sub { color: var(--muted); font-size: 12px; margin-top: 4px; }
|
||||||
|
.pm-copy-btn { border: 1px solid var(--border); background: var(--card-2); color: var(--text);
|
||||||
|
font-size: 11px; padding: 3px 10px; border-radius: 6px; cursor: pointer; }
|
||||||
|
.pm-copy-btn:hover { border-color: var(--accent); color: var(--accent); }
|
||||||
|
|
||||||
|
.pm-tags-wrap { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||||
|
.pm-tag { font-size: 11px; background: var(--card-2); border: 1px solid var(--border);
|
||||||
|
padding: 3px 10px; border-radius: 14px; color: var(--text); }
|
||||||
|
|
||||||
|
.pm-breakdown { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.pm-bd-row { display: flex; gap: 12px; padding: 8px 10px; border-radius: 8px; background: var(--card-2); border: 1px solid var(--border); }
|
||||||
|
.pm-bd-label { display: flex; flex-direction: column; min-width: 90px; flex-shrink: 0; }
|
||||||
|
.pm-bd-label-zh { font-size: 12px; font-weight: 600; color: var(--text); }
|
||||||
|
.pm-bd-label-en { font-size: 10px; color: var(--muted); }
|
||||||
|
.pm-bd-values { display: flex; flex-direction: column; }
|
||||||
|
.pm-bd-zh { font-size: 12px; color: var(--text); line-height: 1.5; }
|
||||||
|
.pm-bd-en { font-size: 11px; color: var(--muted); line-height: 1.5; margin-top: 1px; }
|
||||||
|
|
||||||
|
.pm-notes { font-size: 12px; color: var(--muted); line-height: 1.5; margin: 0; padding: 10px 12px;
|
||||||
|
border-left: 3px solid var(--accent); background: var(--card-2); border-radius: 0 8px 8px 0; }
|
||||||
|
|
||||||
|
@media (max-width: 820px) { .pm-wrap { grid-template-columns: 1fr; } }
|
||||||
|
|
||||||
|
/* —— ②.⑦ Token 用量面板 —— */
|
||||||
|
.tk-body { padding: 14px 18px; max-width: 600px; display: flex; flex-direction: column; gap: 14px; }
|
||||||
|
.tk-card { border: 1px solid var(--border); border-radius: 10px; padding: 16px; background: var(--card); }
|
||||||
|
.tk-card.hero { text-align: center; padding: 28px 16px; border-color: var(--accent); border-width: 1.5px; background: linear-gradient(135deg, var(--card) 0%, var(--card-2) 100%); }
|
||||||
|
.tk-hero-num { font-size: 42px; font-weight: 700; font-variant-numeric: tabular-nums; background: linear-gradient(135deg, #6366f1, #a855f7, #ec4899); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; }
|
||||||
|
.tk-hero-label { font-size: 13px; color: var(--muted); margin-top: 4px; }
|
||||||
|
.tk-breakdown { display: flex; flex-wrap: wrap; justify-content: center; gap: 8px; margin-top: 12px; font-size: 11px; color: var(--muted); }
|
||||||
|
.tk-breakdown span { display: inline-flex; align-items: center; gap: 4px; padding: 4px 8px; border: 1px solid var(--border); border-radius: 999px; background: var(--card); }
|
||||||
|
.tk-breakdown b { color: var(--text); font-weight: 600; font-variant-numeric: tabular-nums; }
|
||||||
|
.tk-hero-date { font-size: 11px; color: var(--muted); margin-top: 2px; opacity: .7; }
|
||||||
|
|
||||||
|
.tk-card-title { font-size: 12px; font-weight: 600; color: var(--muted); margin-bottom: 10px; text-transform: uppercase; letter-spacing: .5px; }
|
||||||
|
|
||||||
|
.tk-preview { border: 1px solid var(--border); border-radius: 10px; overflow: hidden; background: var(--bg); }
|
||||||
|
.tk-preview-bar { display: flex; justify-content: center; align-items: center; padding: 12px 14px; min-height: 58px; background: #211a16; border-bottom: 1px solid var(--border); }
|
||||||
|
.tk-preview-meter { position: relative; width: min(424px, 100%); height: 32px; }
|
||||||
|
.tk-preview-track { position: absolute; left: 0; right: 0; top: 50%; height: 28px; transform: translateY(-50%); border-radius: 8px; background: #fff; overflow: hidden; box-shadow: 0 1px 0 rgba(255,255,255,.92) inset, 0 0 0 1px rgba(15,23,42,.10), 0 5px 16px rgba(0,0,0,.28); }
|
||||||
|
.tk-preview-segments { position: absolute; left: 18px; right: 72px; top: 50%; display: grid; grid-template-columns: repeat(8,1fr); gap: 3px; height: 12px; transform: translateY(-50%); }
|
||||||
|
.tk-preview-seg { position: relative; overflow: hidden; border-radius: 3px; transform: skewX(-7deg); background: linear-gradient(180deg,rgba(138,217,255,.22),rgba(23,103,204,.22)); box-shadow: 0 0 0 1px rgba(54,157,239,.18) inset; }
|
||||||
|
.tk-preview-seg::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: var(--tk-preview-seg-fill,0%); border-radius: inherit; background: linear-gradient(180deg,#52c8ff 0%,#2673dd 58%,#1554c5 100%); box-shadow: 0 0 9px rgba(42,163,255,.72), 0 1px 0 rgba(255,255,255,.45) inset; transition: width .6s ease; }
|
||||||
|
.tk-preview-badge { position: absolute; right: 18px; top: 50%; z-index: 2; display: inline-flex; align-items: center; gap: 3px; height: 18px; padding: 0 7px 0 5px; border-radius: 7px; transform: translateY(-50%); color: #fff; font-size: 11px; font-weight: 800; letter-spacing: 0; white-space: nowrap; background: linear-gradient(180deg,#63c8ff 0%,#2c77df 64%,#1f60c9 100%); box-shadow: 0 0 12px rgba(53,159,246,.75), 0 1px 0 rgba(255,255,255,.45) inset; }
|
||||||
|
.tk-preview-check { display: inline-flex; align-items: center; justify-content: center; width: 12px; height: 12px; border-radius: 50%; background: #fff; color: #266bd5; font-size: 10px; line-height: 1; }
|
||||||
|
.tk-preview-cap { position: absolute; right: 7px; bottom: 4px; width: 10px; height: 12px; border-radius: 3px 3px 4px 4px; background: linear-gradient(180deg,#dcffff 0%,#61ddfb 100%); box-shadow: 0 0 8px rgba(82,222,255,.75); opacity: .95; }
|
||||||
|
.tk-preview-cap::before { content: ""; position: absolute; left: 2px; right: 2px; top: -3px; height: 3px; border-radius: 2px 2px 0 0; background: #cfffff; }
|
||||||
|
.tk-preview-input { padding: 10px 12px; font-size: 12px; color: var(--muted); }
|
||||||
|
.tk-row { display: flex; gap: 10px; align-items: center; }
|
||||||
|
.tk-diag { margin-top: 8px; word-break: break-all; }
|
||||||
|
|||||||
@@ -23,9 +23,9 @@ const OWNER = "mhgmh", REPO = "codex-market-app";
|
|||||||
const API = `https://up.tokendun.cc/api/v1/repos/${OWNER}/${REPO}`;
|
const API = `https://up.tokendun.cc/api/v1/repos/${OWNER}/${REPO}`;
|
||||||
const DIST = path.join(ROOT, "dist");
|
const DIST = path.join(ROOT, "dist");
|
||||||
|
|
||||||
// 要上传的产物:dmg/zip/blockmap + latest-mac.yml
|
// 要上传的产物:dmg/zip/exe/blockmap + latest*.yml(mac 的 latest-mac.yml + win 的 latest.yml)
|
||||||
const files = fs.readdirSync(DIST).filter((f) =>
|
const files = fs.readdirSync(DIST).filter((f) =>
|
||||||
/\.(dmg|zip)(\.blockmap)?$/.test(f) || /^latest.*\.yml$/.test(f)
|
/\.(dmg|zip|exe)(\.blockmap)?$/.test(f) || /^latest.*\.yml$/.test(f)
|
||||||
);
|
);
|
||||||
if (!files.length) { console.error("dist/ 下没有可上传的产物"); process.exit(1); }
|
if (!files.length) { console.error("dist/ 下没有可上传的产物"); process.exit(1); }
|
||||||
console.log(`待上传 ${files.length} 个文件:\n ` + files.join("\n "));
|
console.log(`待上传 ${files.length} 个文件:\n ` + files.join("\n "));
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ function arg(name, def) {
|
|||||||
}
|
}
|
||||||
const has = (name) => argv.includes(name);
|
const has = (name) => argv.includes(name);
|
||||||
const version = arg("--version", pkg.hotContentVersion || pkg.version);
|
const version = arg("--version", pkg.hotContentVersion || pkg.version);
|
||||||
const minAppVersion = arg("--min", pkg.version);
|
// 这是原生 Electron 壳的最低版本,不是热更内容版本。
|
||||||
|
const minAppVersion = arg("--min", pkg.hotMinAppVersion || "1.5.0");
|
||||||
const notes = arg("--notes", "");
|
const notes = arg("--notes", "");
|
||||||
const mandatory = has("--mandatory");
|
const mandatory = has("--mandatory");
|
||||||
const requiresFull = has("--requires-full");
|
const requiresFull = has("--requires-full");
|
||||||
|
|||||||
Reference in New Issue
Block a user