diff --git a/AGENTS.md b/AGENTS.md index 872d70f..db1d494 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,34 +1,45 @@ ## 项目记忆(Codex 插件市场自动维护 · 回答前据此 · 勿手动编辑本段) -**当前状态**:当前仅确认助手位于项目目录 `/Users/mac/Documents/备份数据/ProjectDown/codex-market-app`,尚未开始具体代码任务。可继续处理代码、查历史记忆或恢复 Claude/Codex 会话。 +**关于用户 / 长期事实(务必遵守)**: +- 与助手沟通用中文优先 +**当前状态**:已确认失败原因是后端优先选到了需59aiapi私有token的content_url,导致401;正确应选同一结果中的pulseaify mp4。已修改URL提取逻辑优先选择真实视频文件URL,并验证15秒结果可下载播放。 **已定决策**: -- 指定历史会话用 claude --resume -- 最近会话用 claude --continue -- 优先在原项目目录恢复会话 -- 不在输出中暴露任何 API key -- Tavily 不可用时用网页搜索兜底 -- 不破坏 Homebrew 系统 Python -- 日期按 2026-07-16 -- 查询 topic 设为 news -- 使用 Tavily skill 搜索新闻 -- latest兼容下载保留但设为预发布 -- 同时发布热更和完整包 -- 非显式生图请求剥离gen-image声明 +- 59aiapi content不直接下载 +- 不再优先使用video_url/content_url +- 优先选.mp4等真实视频文件URL +- URL提取不能误选任务API +- 前端下载应优先使用 dataUrl +- 下载受保护域名需带Bearer鉴权 +- Seedance 失败点在成品下载阶段 +- 本地craft-website技能已安装校验通过 +- URL 提取只认明确视频字段或文件 +- Seedance 成品下载需带鉴权 +- skill 源 SKILL.md 移除 version **下一步**: -- 等待用户指定具体任务 -**关键文件**:/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-16 Claude Code 会话恢复 -- 2026-07-16 今日新闻搜索交接 -- 2026-07-16 发布1.5.14完整包 -- 2026-07-16 生图 skill 与聊天分组冲突 -- 2026-07-15 语音输入按钮位置与样式修复 -- 2026-07-15 Grok多参考图热更排查 -- 2026-07-15 会话同步修复与热更发布 -- 2026-07-15 工具箱新增Grok视频与全量会话同步 -- 2026-07-15 生成魔兽世界宣传海报 -- 2026-07-15 Codex skill软链改造 -- 2026-07-14 列出本地 skills +- 2026-07-19 Seedance视频下载失败修复 +- 2026-07-18 Grok视频比例选项补充 +- 2026-07-18 Grok传图与提示词约定 +- 2026-07-18 Seedance参数修复与热更状态 +- 2026-07-18 插件市场会话起始 +- 2026-07-18 gen-image 技能市场更新 +- 2026-07-18 修复 token 统计不准 +- 2026-07-17 调整语音连接入口位置 +- 2026-07-17 项目闲聊与广汉天气查询 +- 2026-07-17 项目任务待确认 +- 2026-07-17 修复 Codex token 计数 +- 2026-07-17 Respond to hello > 回答涉及本段未列出的历史细节前,先运行 `rg -n "关键词" /Users/mac/.codex/memory/31c67c70b625.md` 查证再答。 + +## 发布与热更规则 + +- 热更、完整安装包和插件资源的归属仓库统一为 `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。 diff --git "a/C:\\Users\\mac\\.claude\\projects\\C--Users-mac-Documents------ProjectDown-codex-market-app\\memory\\MEMORY.md" "b/C:\\Users\\mac\\.claude\\projects\\C--Users-mac-Documents------ProjectDown-codex-market-app\\memory\\MEMORY.md" new file mode 100644 index 0000000..746fc8c --- /dev/null +++ "b/C:\\Users\\mac\\.claude\\projects\\C--Users-mac-Documents------ProjectDown-codex-market-app\\memory\\MEMORY.md" @@ -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 与诊断/自愈修复,排查"切模型丢记录"必读 diff --git "a/C:\\Users\\mac\\.claude\\projects\\C--Users-mac-Documents------ProjectDown-codex-market-app\\memory\\codex-model-catalog-history-loss.md" "b/C:\\Users\\mac\\.claude\\projects\\C--Users-mac-Documents------ProjectDown-codex-market-app\\memory\\codex-model-catalog-history-loss.md" new file mode 100644 index 0000000..9aec490 --- /dev/null +++ "b/C:\\Users\\mac\\.claude\\projects\\C--Users-mac-Documents------ProjectDown-codex-market-app\\memory\\codex-model-catalog-history-loss.md" @@ -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]]。 diff --git a/CANVAS_SPEC.md b/CANVAS_SPEC.md index 21dd608..2fc9990 100644 --- a/CANVAS_SPEC.md +++ b/CANVAS_SPEC.md @@ -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:"请先填写图像模型"}`,不要回退聊天模型。** - `size`:`"WxH"`(前端按 比例×清晰度 算好传来)。 - `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`)。 -- **video** `{ mode, prompt, model, image?, lastFrameImage?, referenceImages?[], referenceVideo?, ratio?, resolution?, duration?, generateAudio?, watermark?, cameraFixed?, seed?, endpointPath? }` —— **Seedance 2.0**(完整契约,port 自 提示词提取/server.js `buildVideoBody`) - - `mode`:`"text"` 文生视频 | `"first"` 首帧 | `"firstlast"` 首尾帧 | `"reference"` 多参考图 | `"video"` 参考视频。旧值 `"image"` 归一为 `"first"`。 - - **素材**(主进程 `runVideo` 里经 `resolveMat`:`data:`→兰空图床外链,`http(s)`→原样):`image`(首帧)、`last_frame_image`、`reference_images[]`;**`reference_video` 仅接受公网URL**(图床不托管视频)。 - - `input_mode` 仅在 mode≠text 时下发;素材按模式互斥;缺必填素材直接 `{ok:false,error}`。 - - `metadata`:`ratio / resolution / generate_audio / watermark / camera_fixed / seed`(auto/-1/空不塞)。 - - `model`:用户填写的视频模型(为空返回明确错误)。 - - body 组装:`{ model, prompt, ...(mode==='image'?{image}:{}) , size, ratio, duration, ...extra }`(空字段不塞)。 - - `endpointPath` 默认 `/v1/videos`,允许覆盖。仍严禁伪造成功。 -- **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` 仅用于单图首帧场景,不用于多参考图。 +- **video** `{ mode, prompt, model?, image?, lastFrameImage?, referenceImages?[], ratio?, quality?, seconds?, referenceField?, endpointPath? }` —— **Seedance 2.0**,使用新中转契约。 + - 创建端点默认 `/v1/video/generations`;若分组 `baseUrl` 已以 `/v1` 结尾,拼接时不得产生重复的 `/v1/v1`。 + - 请求体是扁平结构:`prompt` 必填,固定 `async:true`;`duration` 仅发送整数 `5/10/15`;`ratio`、`quality` 非自动时才发送;`model` 可省略并使用上游默认。 + - `mode`:`"text"` 只发送文本 | `"first"` 发送 `image` | `"firstlast"` 将首尾两图作为 `images` | `"reference"` 按 `referenceField` 发送 `images` 或 `ref_assets`。旧值 `"image"` 归一为 `"first"`。 + - 图片经 `resolveMat` 处理:`data:` 先上传图床换为公网 URL,`http(s)` 原样发送;上游请求中禁止出现 base64。 + - 不发送旧版 `input_mode`、`metadata`、`resolution`、`generate_audio`、`watermark`、`camera_fixed`、`seed`、`reference_video` 字段。 + - 创建响应任务 ID 兼容 `request_id/requestId/task_id/taskId/id`,并递归检查 `data/result/task`;字符串和数字 ID 都支持。 +- **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`。 - **tts** 增加 `model`(默认 `tts-1` 可保留但允许覆盖)、`format`、可选 `speed`。 ### 新增 canvasModels(后端 + preload) @@ -130,26 +129,24 @@ canvasRun: (kind, params) => ipcRenderer.invoke("canvas-run", { kind, params }), - 最终 `canvasRun` resolve 的成品仍照常渲染覆盖;skeleton 在收到第一帧 partial 后即可替换为图片。 - 仅 image 节点用到渐进预览;video/tts 不变。 -## 【v5 增补 · 接入 TokenDun/Seedance 真实视频接口】——本轮必须实现 -参照 `/Users/mac/Documents/备份数据/ProjectDown/提示词提取/server.js`(Seedance 视频 API)。异步任务制:建任务→轮询→下载。gwBase = `{分组baseUrl}/v1`。 +## 【v5 增补 · 接入 TokenDun/Seedance 真实视频接口】 +Seedance 使用异步任务制:建任务→轮询→下载。以下内容按当前 `/v1/video/generations` 文档更新,覆盖早期火山 `metadata/input_mode` 版本。 ### 后端 runVideo 重写(canvas.js)——完全替换旧 /v1/videos 逻辑 -- 端点:create = `{base}{endpointPath||"/v1/video/generations"}`;poll = `create + "/" + taskId`(GET)。鉴权 `Bearer `(分组由 groupId 解析,同 v3)。 +- 端点:create = `joinUrl(base, endpointPath||"/v1/video/generations")`;poll = `create + "/" + taskId`(GET)。鉴权 `Bearer `(分组由 groupId 解析,同 v3)。 - **body 组装**(严格照 Seedance): - - `{ model, prompt }`(model 空→报错"请先填写视频模型") - - `seconds`:字符串,params.seconds 非空才加(值 "-1"=自动/"5"/"10")。 - - 文生视频(mode!=='image'):不加 input_mode、不加 image。 - - 图生视频(mode==='image'):`input_mode:"first"` + `image: params.imageUrl`。**imageUrl 必须是 http(s) 公网URL**;若为空或以 `data:` 开头 → 直接返回 `{ok:false,error:"图生视频需填公网图片URL(Seedance 不接受 base64/上游图)"}`,**不要把 base64 发出去**。 - - `metadata`:{ ratio(≠auto才加), resolution(≠auto才加), generate_audio(bool 才加) },非空才挂到 body.metadata。 -- **建任务**:POST create。2xx 才继续;非 2xx 返回 `视频建任务失败(状态):{detail前300}`。解析 taskId:`data.data?.task_id||data.data?.id||data.task_id||data.id||data.result?.task_id||data.result?.id`;无则报错。 -- **轮询**:GET poll,每 5s 一次,最多 ~5 分钟(60 次)。用 `normalizeVideoStatus`(port 自 server.js);succeeded → `findVideoUrl`(port,深挖 video_url/result_url/url) → 下载转 base64 → `{ok:true,mime:"video/mp4",dataUrl,meta}`;failed/超时 → 明确错误。仍严禁伪造成功。 + - 基础字段 `{ prompt, async:true }`;`model` 可选。 + - `params.seconds` 仅为 `5/10/15` 时转成整数 `duration`;`ratio`、`quality` 非 `auto` 时按顶层字段发送。 + - 文生视频不发送图片字段;首帧发送 `image:string`;多图发送 `images:string[]`;全能参考发送 `ref_assets:string[]`。 + - 图片必须是公网 URL;本地 data URL 先经 `uploadMedia()` 转成公网地址。 +- **建任务**:POST create。2xx 才继续;非 2xx 返回状态、实际 URL、model 和上游详情。响应先读取 text 再解析 JSON,确保非 JSON 错误不会丢失原文。任务 ID 由通用 `pickTaskId()` 提取。 +- **轮询**:GET poll,每 5s 一次,最多约 10 分钟(120 次)。用 `normalizeVideoStatus`;succeeded → `findVideoUrl`(深挖 `video_url/result_url/url`)→ 下载转 base64 → `{ok:true,mime:"video/mp4",dataUrl,meta}`;failed/超时 → 明确错误。仍严禁伪造成功。 - port 两个纯函数 `normalizeVideoStatus`、`findVideoUrl` 到 canvas.js。全程 try/catch。 ### 前端视频节点(renderer/*) -- 模式:文生视频 / 图生视频(保留)。 -- **图生视频时显示「公网图片URL」输入框** `.cv-field-imgurl`(hint:Seedance 需公网图片URL,上游 base64 图无法直接用)。文生视频时隐藏。 -- 比例(16:9/9:16/1:1/4:3) + 分辨率(480p/720p/1080p/自动) 保留。 -- **时长改为**:自动(-1) / 5s / 10s(对应 seconds 值 "-1"/"5"/"10","自动"传 "-1" 或空)。 -- **加「生成音频」勾选** `.cv-field-audio`(→ generateAudio bool)。 -- 高级:接口地址(默认 /v1/video/generations)。 -- 运行 params:`{ mode:'text'|'image', prompt, model, ratio, resolution, seconds, generateAudio, imageUrl(仅image), endpointPath, groupId }`。图生视频且 imageUrl 空/为 data: → 前端红字拦截。 +- 模式:首帧图、双图参考、多参考图/全能参考、文生视频。 +- `prompt` 在所有模式必填;模型可留空使用上游默认,也可通过分组拉取当前可用模型。 +- 比例:16:9 / 9:16 / 1:1 / 4:3;画质:自动 / `hd`;时长:自动 / 5s / 10s / 15s。 +- 高级:接口地址(默认 `/v1/video/generations`)和多参考图字段(`images/ref_assets`)。 +- 不展示旧接口已不支持的分辨率、音频、水印、固定镜头、seed、参考视频控件。 +- 运行 params:`{ mode, prompt, model?, ratio, quality, seconds, referenceField, image?, lastFrameImage?, referenceImages?, endpointPath?, groupId }`。 diff --git a/HANDOFF.md b/HANDOFF.md index 2fc19a4..912297b 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -3,58 +3,65 @@ > 核心记忆已写进本项目 `AGENTS.md`(Codex 每次会话自动读)。本文件是更详细的近期快照,供需要时参考;关键信息不依赖本文件。 -## 最近会话 · 2026-07-16 14:32 · 项目会话恢复 +## 最近会话 · 2026-07-19 08:05 · Seedance视频下载失败修复 -当前仅确认助手位于项目目录 `/Users/mac/Documents/备份数据/ProjectDown/codex-market-app`,尚未开始具体代码任务。可继续处理代码、查历史记忆或恢复 Claude/Codex 会话。 - -**下一步**: -- 等待用户指定具体任务 - -**涉及文件**:/Users/mac/Documents/备份数据/ProjectDown/codex-market-app - -## 更早会话 · 2026-07-16 10:37 · Claude Code 会话恢复 - -用户想重开 Claude Code 会话并延续之前记录。已说明可在原项目目录用 `claude --continue`/`-c` 接最近会话,用 `claude --resume`/`-r` 或 `/resume` 手动选择历史会话;会话按项目目录保存。 +已确认失败原因是后端优先选到了需59aiapi私有token的content_url,导致401;正确应选同一结果中的pulseaify mp4。已修改URL提取逻辑优先选择真实视频文件URL,并验证15秒结果可下载播放。 **已确定的决策**: -- 优先在原项目目录恢复会话 -- 最近会话用 claude --continue -- 指定历史会话用 claude --resume +- 优先选.mp4等真实视频文件URL +- 不再优先使用video_url/content_url +- 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 -- Tavily 不可用时用网页搜索兜底 -- 不在输出中暴露任何 API key +- Grok默认视频格式为16:9 +- 支持16:9、9:16、1:1、4:3、3:4 +- 后端用比例+分辨率生成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 -- 同时发布热更和完整包 -- latest兼容下载保留但设为预发布 +- 与助手沟通用中文优先 +- 外部生成提示词可中英混写 +- Grok 首帧图先传图床再提交 `image.url` + +**涉及文件**:/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验证 -- 若仍403检查所选生图分组权限 +- 确认上游是否支持 watermark:false +- 如需上线 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 diff --git a/build/icon.icns b/build/icon.icns index 722e78f..05e7eb9 100644 Binary files a/build/icon.icns and b/build/icon.icns differ diff --git a/build/icon.icns.bak b/build/icon.icns.bak new file mode 100644 index 0000000..722e78f Binary files /dev/null and b/build/icon.icns.bak differ diff --git a/build/icon.png b/build/icon.png index a6aae17..c5db8a2 100644 Binary files a/build/icon.png and b/build/icon.png differ diff --git a/build/icon.png.bak b/build/icon.png.bak new file mode 100644 index 0000000..a6aae17 Binary files /dev/null and b/build/icon.png.bak differ diff --git a/electron/lib/canvas.js b/electron/lib/canvas.js index 3c7d3d9..ddf6359 100644 --- a/electron/lib/canvas.js +++ b/electron/lib/canvas.js @@ -48,6 +48,17 @@ function grokDebugLog(entry) { } 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() { const envProxy = process.env.HTTPS_PROXY || process.env.https_proxy || process.env.HTTP_PROXY || process.env.http_proxy || ""; 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) => { let u; try { @@ -95,13 +106,13 @@ function requestBuffer(url, agent) { url, { agent, - headers: { "User-Agent": "codex-market" }, + headers: { "User-Agent": "codex-market", ...extraHeaders }, }, (res) => { const loc = res.headers.location; if (res.statusCode >= 300 && res.statusCode < 400 && loc) { res.resume(); - requestBuffer(new URL(loc, url).toString(), agent).then(resolve, reject); + requestBuffer(new URL(loc, url).toString(), agent, extraHeaders).then(resolve, reject); return; } if (res.statusCode < 200 || res.statusCode >= 300) { @@ -120,14 +131,14 @@ function requestBuffer(url, agent) { } // —— 下载任意远程资源 → base64 字符串 —— -async function downloadAsBase64(url) { +async function downloadAsBase64(url, extraHeaders = {}) { const proxyUrl = systemProxyUrl(); let proxyErr = ""; if (proxyUrl) { const agent = proxyAgentFor(url, proxyUrl); if (agent) { try { - return (await requestBuffer(url, agent)).toString("base64"); + return (await requestBuffer(url, agent, extraHeaders)).toString("base64"); } catch (e) { proxyErr = String((e && e.message) || e); // 代理下载失败时再直连兜底,保留原行为。 @@ -137,7 +148,7 @@ async function downloadAsBase64(url) { const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), 120000); 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); const buf = Buffer.from(await res.arrayBuffer()); 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 —— function toDataUrl(mime, 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 等嵌套)—— // 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 (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)) { for (const v of o) { const u = findVideoUrl(v); @@ -410,7 +463,7 @@ function findVideoUrl(o) { return ""; } 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]; if (typeof v === "string" && /^https?:\/\//.test(v)) return v; } @@ -432,13 +485,16 @@ function normalizeVideoStatus(raw) { } 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) { if (!o || typeof o !== "object") return ""; 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"]) { const v = o[k]; @@ -456,27 +512,52 @@ function pushRecentPoll(arr, value) { 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)—— -// mode: "text"(文生视频,只用 prompt) | "image"(图生视频)。model 须用户填写。 +// mode: "text"(文生视频,只用 prompt) | "image"(图生视频)。model 可省略,使用上游默认。 // 图生视频素材两条来路:params.imageUrl(手填公网URL,优先) 或 params.image(上游 base64)。 // base64 会先经 uploadMedia() 换成公网直链再发上游(Seedance 绝不接受 base64)。 async function runVideo(base, llm, params) { - const model = params.model; - if (!model) return { ok: false, error: "请先填写视频模型" }; - const createUrl = base + (params.endpointPath || "/v1/video/generations"); + const model = String(params.model || "").trim(); + const createUrl = joinUrl(base, params.endpointPath || "/v1/video/generations"); // 输入模式:text/first/firstlast/reference/video;兼容旧值 image→first let mode = String(params.mode || "text"); if (mode === "image") mode = "first"; // —— body 组装:严格照中转文档(pulseaify Seedance)——扁平字段,非火山 metadata 格式 —— // prompt 必填;duration 整数(5/10/15);ratio 顶层;quality 可选;async:true 走异步轮询 - const body = { model, prompt: (params.prompt || "").trim(), async: true }; + const body = { prompt: (params.prompt || "").trim(), async: true }; + if (model) body.model = model; // 文档允许省略,交给上游使用默认 video-v1 const dur = parseInt(params.seconds, 10); - if (!isNaN(dur) && dur > 0) body.duration = dur; // -1/空/auto → 省略,用中转默认(10) - if (params.ratio && params.ratio !== "auto") body.ratio = params.ratio; // 顶层 ratio - // 注:文档的 quality 字段示例值是 "hd",与 UI 的 720p/1080p 不同类,未确认合法值前不下发,避免 400 + if ([5, 10, 15].includes(dur)) body.duration = dur; // -1/空/auto → 省略,用中转默认(10) + if (params.ratio && params.ratio !== "auto") body.ratio = params.ratio; + if (params.quality && params.quality !== "auto") body.quality = params.quality; try { - // 参考图/素材:本中转支持 image(单图) / images(多图) / ref_assets;base64 先传兰空换公网URL + // 参考图/素材:本中转支持 image(单图) / images(多图) / ref_assets;base64 先传图床换公网 URL if (mode === "first") { const img = await resolveMat(params.imageUrl || params.image); 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 resolved = (await Promise.all(refs.map((r) => resolveMat(r)))).filter(Boolean); if (!resolved.length) return { ok: false, error: "多参考图模式需至少 1 张参考图" }; - body.images = resolved; + body[params.referenceField === "ref_assets" ? "ref_assets" : "images"] = resolved; } else if (mode === "video") { return { ok: false, error: "该中转不支持「参考视频」模式,请改用 文生 / 首帧 / 多参考图" }; } @@ -500,6 +581,22 @@ async function runVideo(base, llm, params) { if (!body.prompt) return { ok: false, error: "请填写提示词(本接口文生/图生视频都需要 prompt)" }; // —— 建任务 —— + const debugBase = { + base: hostOf(base), + create_url: createUrl, + mode, + request: { + model: body.model || "", + 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, { method: "POST", headers: headers(llm.key), @@ -507,21 +604,23 @@ async function runVideo(base, llm, params) { }); if (!cres.ok) { 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; + const rawCreate = await cres.text().catch(() => ""); try { - data = await cres.json(); + data = JSON.parse(rawCreate); } catch { - const detail = await cres.text().catch(() => ""); - return { ok: false, error: `视频建任务返回非 JSON:${detail.slice(0, 300)}` }; + seedanceDebugLog({ type: "seedance_video_create_non_json", ...debugBase, detail: rawCreate.slice(0, 500) }); + return { ok: false, error: `视频建任务返回非 JSON:${rawCreate.slice(0, 300)}` }; } - const taskId = - (data.data && (data.data.task_id || data.data.id)) || - data.task_id || - data.id || - (data.result && (data.result.task_id || data.result.id)); - if (!taskId) return { ok: false, error: `视频建任务未返回任务ID:${JSON.stringify(data).slice(0, 300)}` }; + const taskId = pickTaskId(data); + if (!taskId) { + seedanceDebugLog({ type: "seedance_video_missing_task_id", ...debugBase, response: JSON.stringify(data).slice(0, 500) }); + return { ok: false, error: `视频建任务未返回任务ID:${JSON.stringify(data).slice(0, 300)}` }; + } + seedanceDebugLog({ type: "seedance_video_accepted", ...debugBase, task_id: taskId }); // —— 轮询:每 5s,最多 120 次(~10min),对齐 提示词提取 —— // poll 是 GET:只带 Authorization(不要带 Content-Type,个别网关对「GET 携带 body 类头」会返回非 2xx 而被误跳过) @@ -548,16 +647,25 @@ async function runVideo(base, llm, params) { if (st === "succeeded") { const url = findVideoUrl(pd); 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 }; } if (st === "failed") { const err = (d && d.error && (d.error.message || d.error)) || (d && (d.fail_reason || d.message || d.reason)) || (pd && pd.message) || st; const raw = JSON.stringify(pd).slice(0, 500); + 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 }; } // queued/running → 继续等 } + seedanceDebugLog({ type: "seedance_video_timeout", ...debugBase, task_id: taskId, last_poll: lastPoll || "" }); return { ok: false, error: "视频轮询 10 分钟未完成(taskId=" + taskId + ",最近一次轮询:" + (lastPoll || "无") + ")" }; } catch (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} 调用 —— 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(); if (!prompt) return { ok: false, error: "请填写 Grok 视频提示词" }; - const body = { model, prompt }; - if (params.resolution && params.resolution !== "auto") body.resolution = params.resolution; - const dur = parseInt(params.seconds, 10); - if (!isNaN(dur) && dur > 0) body.duration = dur; + const seconds = String(params.seconds || "6").trim(); + const body = { model, prompt, seconds, size: grokSizeFromParams(params) }; let mode = String(params.mode || "text"); if (mode === "image") mode = "first"; try { if ( mode === "text" && - /grok-imagine-video-1\.5/i.test(model) && - body.resolution === "1080p" && - Number(body.duration) === 15 + /grok-(?:imagine-)?video-1\.5/i.test(model) && + body.size === "1920x1080" && + Number(body.seconds) === 15 ) { return { ok: false, error: "1.5 的 1080p/15s 需要首帧图;请改用首帧图生视频,或降低文生视频规格" }; } 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 / 连接上游文生图" }; - body.image = { image_url: img }; + body.image = { url: img }; } else if (mode === "reference") { const refs = Array.isArray(params.referenceImages) ? params.referenceImages : []; const resolved = (await Promise.all(refs.map((r) => resolveMat(r)))).filter(Boolean); if (!resolved.length) return { ok: false, error: "Grok 多参考图模式请至少提供 1 张参考图" }; model = "grok-imagine-video"; body.model = model; + delete body.size; 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"; body.reference_images = resolved.map((url) => ({ url })); } else if (mode !== "text") { @@ -611,10 +721,13 @@ async function runGrokVideo(base, llm, params) { request: { model: body.model, prompt: body.prompt, + seconds: body.seconds || "", + size: body.size || "", resolution: body.resolution || "", duration: body.duration || "", aspect_ratio: body.aspect_ratio || "", 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, }, }; @@ -669,7 +782,13 @@ async function runGrokVideo(base, llm, params) { lastPoll = "status=succeeded,但暂未返回视频地址"; 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 }); return { ok: true, mime: "video/mp4", dataUrl: toDataUrl("video/mp4", b64), url, _model: model }; } diff --git a/electron/lib/claudedesk.js b/electron/lib/claudedesk.js new file mode 100644 index 0000000..0cb9932 --- /dev/null +++ b/electron/lib/claudedesk.js @@ -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/.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, +}; diff --git a/electron/lib/prompt-analysis.js b/electron/lib/prompt-analysis.js new file mode 100644 index 0000000..cdcb397 --- /dev/null +++ b/electron/lib/prompt-analysis.js @@ -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"); + +// ============================================================================ +// 以下 自 提示词提取/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 }; \ No newline at end of file diff --git a/electron/lib/router.js b/electron/lib/router.js index a35f615..2d971a1 100644 --- a/electron/lib/router.js +++ b/electron/lib/router.js @@ -36,12 +36,18 @@ function dec(s) { 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; 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() { if (config) return config; try { @@ -60,8 +66,28 @@ function load() { config = defaultConfig(); } ensureDefault(); + migrateActiveSlots(); 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() { const out = { ...config, @@ -82,9 +108,17 @@ function save() { } catch {} } -function activeGroup() { +// 取激活分组。wantClaude=true 取 Anthropic 族(activeClaudeId),否则取 OpenAI 族(activeId)。 +function activeGroup(wantClaude) { 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 配置(自己账号,直连)—— @@ -243,8 +277,12 @@ function catalogPath() { return path.isAbsolute(name) ? name : path.join(CODEX_HOME, name); } // 把分组的模型 + 上下文窗口写进 Codex 模型目录,让 Codex 按真实窗口自动压缩 +// 铁律:只要分组指定了 model,就必须保证它在目录里存在(visibility=list)。 +// 否则 config.toml 的根级 model 指向一个「目录里没有的模型」,Codex 会以未知模型启动、 +// 无法解析出该模型 → 侧栏历史线程还原失败 = 用户看到「切模型后会话记录消失」。 +// contextWindow 只是「可选的窗口覆盖」,绝不能作为「是否登记进目录」的前置条件。 function applyModelCatalog(group) { - if (!group || group.isDefault || !group.model || !group.contextWindow) return; + if (!group || group.isDefault || !group.model) return; // 不再要求 contextWindow const p = catalogPath(); let cat; try { @@ -253,20 +291,23 @@ function applyModelCatalog(group) { return; // 无目录文件就不动 } if (!cat.models || !cat.models.length) return; - const win = parseInt(group.contextWindow, 10); - if (!win) return; + const win = parseInt(group.contextWindow, 10); // 未填 → NaN(假值),此时保留/沿用既有窗口 const existing = cat.models.find((m) => m.slug === group.model); if (existing) { - existing.context_window = win; - existing.max_context_window = win; - existing.visibility = "list"; + if (win) { + existing.context_window = win; + existing.max_context_window = win; + } + existing.visibility = "list"; // 已在目录:无论有无窗口,都确保可见 } else { 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.display_name = group.model; tpl.description = group.model; - tpl.context_window = win; - tpl.max_context_window = win; + tpl.context_window = w; + tpl.max_context_window = w; tpl.priority = 900; tpl.visibility = "list"; cat.models.push(tpl); @@ -276,9 +317,100 @@ function applyModelCatalog(group) { } catch {} 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) { if (!model) return; + ensureModelInCatalog(model); const cfgFile = path.join(CODEX_HOME, "config.toml"); let text = readConfigToml(); if (/^\s*model\s*=/m.test(text)) text = text.replace(/^\s*model\s*=.*$/m, `model = "${model}"`); @@ -468,10 +600,13 @@ function rewriteResponsesBody(body, group) { } 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) { res.writeHead(503, { "content-type": "text/plain; charset=utf-8" }); - res.end("本地路由:未选择中转分组"); + res.end(wantClaude ? "本地路由:未选择 Claude Code 分组" : "本地路由:未选择中转分组"); return; } // 校验路由 key(Codex 用 auth.json 里的 routerKey 作 Bearer 发来) @@ -612,6 +747,17 @@ function handle(req, res) { } else { 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"]; if (body.length) headers["content-length"] = Buffer.byteLength(body); if (doCapture) { @@ -681,6 +827,7 @@ function status() { routerKey: config.routerKey, address: `http://127.0.0.1:${config.port}`, activeId: config.activeId, + activeClaudeId: config.activeClaudeId, claudeApplied: claudeApplied(), groups: config.groups.map((g) => ({ id: g.id, @@ -694,6 +841,7 @@ function status() { codexImage: !!g.codexImage, stt: !!g.stt, sttModel: g.sttModel || "", + oneMillionModels: Array.isArray(g.oneMillionModels) ? g.oneMillionModels : [], })), }; } @@ -765,9 +913,10 @@ function syncGenImage() { function upsertGroup(g) { load(); let id; + let prevMode = null; // 编辑前的接口类型(用于判断是否从 claude 改成别的) if (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 }); id = g.id; } else { @@ -775,7 +924,10 @@ function upsertGroup(g) { config.groups.push(g); 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); if (cur && cur.codexImage) { @@ -783,28 +935,49 @@ function upsertGroup(g) { } save(); try { syncGenImage(); } catch {} - // 保存中转分组后:把它的直连中转配置写进 Codex config.toml(不经路由、不起进程), - // 并设为激活分组。用户重启 Codex 即用当前分组。只有显式「启用本地路由」才走路由进程。 + // 保存分组后按接口类型分流: + // - claude 模式:把分组直连配置写进 ~/.claude/settings.json(接入 Claude Code CLI),不碰 Codex + // - responses/chat 模式:把直连中转配置写进 Codex config.toml(不经路由、不起进程) const st = status(); if (cur && !cur.isDefault) { - // 路由进程正在跑(用户处于「启用路由」高级模式)→ 保持走本地路由,不改成直连 - if (isRunning()) { - st._directSkipped = true; - st._directReason = "本地路由运行中,保存的分组走路由生效(切分组无需重启)"; - } else if (!cur.baseUrl || !cur.key) { - st._directSkipped = true; - st._directReason = "该分组缺少 API 地址或密钥,未写入 Codex 配置"; - } else if (cur.mode === "chat") { - st._directSkipped = true; - st._directNeedRouter = true; - st._directReason = "该分组为 Chat 协议转换模式,需启用本地路由才能接入 Codex"; + if (cur.mode === "claude") { + // 接入 Claude Code + if (!cur.baseUrl || !cur.key) { + st._claudeSkipped = true; + st._claudeReason = "该分组缺少 API 地址或密钥,未写入 Claude Code 配置"; + } else { + try { + const rc = applyDirectToClaude(cur.id); + if (rc.ok) st._claudeApplied = true; + else { st._claudeSkipped = true; st._claudeReason = rc.error || "写入失败"; } + } catch (e) { + st._claudeError = String((e && e.message) || e); + } + } } 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); + // 接入 Codex(responses 直连 / chat 协议转换) + // 仅当【本分组】之前是 claude 模式、现改回 Codex 模式 → 还原它写过的 Claude Code 配置 + if (prevMode === "claude" && config.claudePrev && claudeDirectApplied()) { + try { restoreClaudeCode(); st._claudeRestored = true; } catch {} + } + 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(); const g = config.groups.find((x) => x.id === id); if (g && g.isDefault) return { ...status(), error: "默认分组不可删除" }; + const wasClaude = isClaudeGroup(g); 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(); try { syncGenImage(); } catch {} // 删掉的可能正是生图来源,同步清除 .env return status(); @@ -824,6 +1009,25 @@ function setActive(id) { load(); const target = config.groups.find((g) => g.id === id); 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 wasTakenOver = before === PROVIDER_ID || before === DIRECT_PROVIDER_ID; let needsRestart = false; @@ -873,10 +1077,10 @@ function codexSnippet() { function applyToCodex() { load(); - // 本地路由需要一个中转分组做上游;若当前是默认/空,自动选第一个中转分组 + // 本地路由需要一个中转分组做上游;若当前是默认/空,自动选第一个中转分组(排除 claude 模式) const cur = config.groups.find((g) => g.id === config.activeId); - if (!cur || cur.isDefault) { - const proxy = config.groups.find((g) => !g.isDefault); + if (!cur || cur.isDefault || cur.mode === "claude") { + const proxy = config.groups.find((g) => !g.isDefault && g.mode !== "claude"); if (proxy) config.activeId = proxy.id; } const cfgFile = path.join(CODEX_HOME, "config.toml"); @@ -924,6 +1128,9 @@ function applyToCodex() { applyModelCatalog(cur2); setConfigModel(cur2.model); } + // 先回填该中转站的全部文本模型进目录(异步、不阻塞、失败不动目录), + // 保证用户之后在 Codex 本体里切到任何模型都不会因"模型不在目录"而丢会话。 + if (cur2 && !cur2.isDefault) syncCatalogFromProvider(cur2).catch(() => {}); save(); return { ok: true }; } @@ -947,6 +1154,9 @@ function applyDirectToCodex(groupId) { if (g.mode === "chat") { 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); @@ -991,6 +1201,10 @@ function applyDirectToCodex(groupId) { applyModelCatalog(g); setConfigModel(g.model); } + // 先回填:把该中转站能用的全部文本模型登记进 Codex 模型目录(异步、不阻塞保存、失败不动目录)。 + // 这样即便用户之后在 Codex 本体的模型选择器里切到别的模型(那条路径不经过本 App), + // 目标模型也已在目录里 → Codex 能正常启动、侧栏历史不再"消失"。 + syncCatalogFromProvider(g).catch(() => {}); config.activeId = g.id; save(); return { ok: true, direct: true, group: g.name }; @@ -1001,15 +1215,8 @@ function claudeSettingsPath() { function applyToClaudeCode() { load(); - // 本地路由需要一个中转分组做上游;若当前是默认/空,自动选第一个中转分组 - const cur = config.groups.find((g) => g.id === config.activeId); - if (!cur || cur.isDefault) { - const proxy = config.groups.find((g) => !g.isDefault); - if (proxy) { - config.activeId = proxy.id; - save(); - } - } + // Anthropic 请求由路由按 activeClaudeId 派发(见 handle);这里只把 settings.json 指向本地路由。 + // 不再动 activeId(OpenAI/Codex 槽)—— 两族独立,接入 Claude Code 不该顶掉 Codex 的选择。 fs.mkdirSync(CLAUDE_HOME, { recursive: true }); const p = claudeSettingsPath(); let settings = {}; @@ -1055,8 +1262,8 @@ function restoreClaudeCode() { return { ok: true }; // 没有文件就无需还原 } settings.env = settings.env || {}; - const prev = config.claudePrev || { base: null, token: null }; - for (const [k, v] of [["ANTHROPIC_BASE_URL", prev.base], ["ANTHROPIC_AUTH_TOKEN", prev.token]]) { + 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], ["ANTHROPIC_MODEL", prev.model]]) { if (v == null) delete settings.env[k]; 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 = { load, start, @@ -1091,8 +1358,10 @@ module.exports = { applyDirectToCodex, codexSnippet, applyToClaudeCode, + applyDirectToClaude, restoreClaudeCode, claudeApplied, + claudeDirectApplied, getGroupKey, syncGenImage, groupById, @@ -1100,4 +1369,5 @@ module.exports = { llmGroup, sttGroup, fetchModels, + syncCatalogFromProvider, }; diff --git a/electron/lib/team-prompt.js b/electron/lib/team-prompt.js new file mode 100644 index 0000000..5678242 --- /dev/null +++ b/electron/lib/team-prompt.js @@ -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 = `# 任务契约 · + +> 分派任何任务前由总控产出。它是「防跑飞」的依据: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, +}; diff --git a/electron/lib/team.js b/electron/lib/team.js new file mode 100644 index 0000000..6671b7a --- /dev/null +++ b/electron/lib/team.js @@ -0,0 +1,1586 @@ +// team.js —— 多 Agent 员工团队生成(需求 → 带约束的 AI 员工团队 → 打包 zip) +// 移植自「提示词提取」项目的「生成员工」视图。纯逻辑(prompt / 归一化 / 序列化 / 脚手架) +// 原样搬运;LLM 通道改走本应用的 router 分组(主进程 fetch /v1/chat/completions), +// 打包改用零依赖 zip.createZip。 +"use strict"; +const router = require("./router"); +const { createZip } = require("./zip"); +const { + EMPLOYEE_SYSTEM_PROMPT, + buildEmployeeInstruction, + TEAM_CONVENTIONS, + ORCHESTRATOR_WORKFLOW, + TASK_CHARTER, + SECURITY_CHECKLIST, +} = require("./team-prompt"); + +// ============================================================================ +// 以下 区块自 提示词提取/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; + // 修复 LLM 常见错误:去掉对象/数组结尾的尾随逗号(如 {"a":1,} 或 [1,2,]) + 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; +} + +const SCOPE_RULE = '仅在当前项目范围内工作,不访问项目外的文件或系统'; +function ensureConstraints(agent) { + const list = Array.isArray(agent.constraints) ? agent.constraints.slice() : []; + if (!list.some((c) => /当前项目|本项目|current.?project|项目范围|项目内/.test(c))) list.unshift(SCOPE_RULE); + return list; +} +function cleanId(id, fallback) { + const s = String(id || fallback || 'agent').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''); + return s || 'agent'; +} +const asArr = (v) => (Array.isArray(v) ? v : []); +// 去重合并(保序) +const mergeUniq = (...arrs) => { + const out = [], seen = new Set(); + arrs.flat().forEach((x) => { const k = String(x).trim(); if (k && !seen.has(k)) { seen.add(k); out.push(k); } }); + return out; +}; +// 剥掉 glob 后缀,得到可比较的路径基(src/app/api/** → src/app/api) +const stripGlob = (p) => String(p).replace(/\/\*\*?$/, '').replace(/\/\*+$/, '').replace(/\*+$/, '').replace(/\/+$/, ''); + +// —— 包管理器一致性表:声明的包管理器 → 对应锁文件 + 安装命令 —— +const PM_TABLE = { + npm: { lock: 'package-lock.json', install: 'npm ci' }, + pnpm: { lock: 'pnpm-lock.yaml', install: 'pnpm install --frozen-lockfile' }, + yarn: { lock: 'yarn.lock', install: 'yarn install --immutable' }, +}; + +// 服务端强制口径一致(不能只靠 prompt): +// 1) 包管理器:ci_steps 命令 + owner_paths 锁文件名 统一成 team.package_manager 声明的那个 +// 2) 鉴权方案:auth_scheme 所需的 env_vars 缺了自动补全 +// 每次改写记入 team.consistency_warnings,最终渲染进 CONVENTIONS.md +function enforceConsistency(team) { + team.consistency_warnings = asArr(team.consistency_warnings); + const warn = (msg) => { if (!team.consistency_warnings.includes(msg)) team.consistency_warnings.push(msg); }; + + // —— 包管理器一致性 —— + team.ci_steps = asArr(team.ci_steps); + team.employees = asArr(team.employees); + const pm = String(team.package_manager || '').toLowerCase().trim(); + if (PM_TABLE[pm]) { + const target = PM_TABLE[pm]; + const others = Object.keys(PM_TABLE).filter((k) => k !== pm); + // ci_steps:其它 JS 包管理器的命令改写成声明的那个 + others.forEach((other) => { + let touched = false; + team.ci_steps = team.ci_steps.map((step) => { + let s = String(step); + const before = s; + if (other === 'npm') s = s.replace(/\bnpm ci\b/g, target.install).replace(/\bnpm install\b/g, target.install); + else if (other === 'pnpm') s = s.replace(/\bpnpm install(\s+--frozen-lockfile)?\b/g, target.install); + else s = s.replace(/\byarn install(\s+--immutable)?\b/g, target.install); + s = s.replace(new RegExp(`\\b${other} run\\b`, 'g'), `${pm} run`); + s = s.replace(new RegExp(`\\b${other} test\\b`, 'g'), `${pm} test`); + if (s !== before) touched = true; + return s; + }); + if (touched) warn(`ci_steps 中的 ${other} 命令已统一为 ${pm}`); + }); + // owner_paths:错误锁文件名替换成声明包管理器的锁文件 + team.employees.forEach((e) => { + e.owner_paths = asArr(e.owner_paths).map((p) => { + let s = String(p); + others.forEach((other) => { + if (s.includes(PM_TABLE[other].lock)) { + s = s.split(PM_TABLE[other].lock).join(target.lock); + warn(`${e.id} 的 owner_paths 中的锁文件 ${PM_TABLE[other].lock} 已替换为 ${target.lock}`); + } + }); + return s; + }); + }); + } else if (pm && pm !== 'none') { + // 非 JS 包管理器(pip/cargo/go 等):只检测混入 JS 包管理器命令,不改写 + const mixed = team.ci_steps.filter((s) => /\b(npm|pnpm|yarn)\b/.test(String(s))); + if (mixed.length) warn(`包管理器为 ${pm},但 ci_steps 混入了 JS 包管理器命令(${mixed.join(';')}),请人工确认`); + } + + // —— auth_scheme ↔ env_vars 自动补全 —— + const auth = String(team.auth_scheme || '').toLowerCase(); + if (auth && auth !== 'none') { + const need = []; + if (auth.includes('jwt')) need.push('JWT_SECRET', 'JWT_EXPIRES_IN'); + if (auth.includes('nextauth')) need.push('NEXTAUTH_SECRET', 'NEXTAUTH_URL'); + if ((auth.includes('session') || auth.includes('cookie')) && !auth.includes('nextauth')) + need.push('SESSION_SECRET', 'SESSION_COOKIE_NAME', 'SESSION_TTL'); + team.env_vars = asArr(team.env_vars); + const have = new Set(team.env_vars.map((v) => String((v && v.name) || '').toUpperCase())); + need.forEach((name) => { + if (!have.has(name)) { + team.env_vars.push({ name, desc: `(鉴权方案 ${team.auth_scheme} 所需,自动补全)` }); + warn(`env_vars 缺少鉴权方案「${team.auth_scheme}」所需的 ${name},已自动补全`); + } + }); + } + + // —— 职责完整性兜底(模型漏配也要闭环)—— + team.db_models = asArr(team.db_models); + const stackStr = asArr(team.stack).join(' '); + const findByIdName = (re) => team.employees.find((e) => re.test(`${e.id || ''} ${e.name || ''}`)); + const contractOwnerEmp = team.employees.find((e) => e.id === team.contract_owner); + + // 1) 数据库 schema/迁移归属:有数据存储但没人拥有迁移路径 → 按栈推断目录并指派 + const hasDatastore = + team.db_models.length > 0 || + /prisma|postgres|mysql|sqlite|mongo|typeorm|sequelize|drizzle|django|rails|flyway|liquibase/i.test(stackStr); + if (hasDatastore && team.employees.length) { + const dbOwned = team.employees.some((e) => + asArr(e.owner_paths).some((p) => /prisma|migrations?|db\/|schema/i.test(String(p))) + ); + if (!dbOwned) { + const s = stackStr.toLowerCase(); + const dbPath = s.includes('prisma') + ? 'prisma/**' + : s.includes('django') + ? '**/migrations/**' + : s.includes('rails') + ? 'db/**' + : 'migrations/**'; + const target = findByIdName(/db|database|data|数据/i) || contractOwnerEmp || team.employees[0]; + target.owner_paths = asArr(target.owner_paths); + if (!target.owner_paths.includes(dbPath)) target.owner_paths.push(dbPath); + warn(`数据库 schema/迁移路径(${dbPath})职责无人认领,已指派给 ${target.id}`); + } + } + + // 2) README/docs 归属:没人拥有 README.md 且没人拥有 docs 顶层(docs/coordination 是共享区,不算) + if (team.employees.length) { + const hasReadme = team.employees.some((e) => + asArr(e.owner_paths).some((p) => String(p).includes('README.md')) + ); + const hasDocsTop = team.employees.some((e) => + asArr(e.owner_paths).some((p) => { + const base = stripGlob(p); + return /^docs(\/|$)/.test(base) && !/^docs\/coordination(\/|$)/.test(base); + }) + ); + if (!hasReadme && !hasDocsTop) { + const target = findByIdName(/devops|ops|infra/i) || contractOwnerEmp || team.employees[0]; + target.owner_paths = asArr(target.owner_paths); + // 只归窄职责:README + docs 顶层文档。绝不给 docs/**(会吞掉 docs/security 等专职子目录和共享区) + ['README.md', 'docs/*.md'].forEach((p) => { + if (!target.owner_paths.includes(p)) target.owner_paths.push(p); + }); + warn(`README.md 与 docs/*.md(顶层文档)职责无人认领,已指派给 ${target.id}(协作文档维护者)`); + } + } + + // 2.5) owner 覆盖面补齐:常见工程文件无人认领时补给对应专业角色(带免冲突守卫,绝不制造新重叠) + { + const allBases = []; + team.employees.forEach((e) => asArr(e.owner_paths).forEach((p) => allBases.push(stripGlob(p)))); + const unowned = (path) => { + const b = stripGlob(path); + return !allBases.some((x) => x === b || x.startsWith(b + '/') || b.startsWith(x + '/')); + }; + const grant = (emp, paths, why) => { + if (!emp) return; + emp.owner_paths = asArr(emp.owner_paths); + const added = []; + paths.forEach((p) => { + if (unowned(p) && !emp.owner_paths.includes(p)) { + emp.owner_paths.push(p); + allBases.push(stripGlob(p)); + added.push(p); + } + }); + if (added.length) warn(`${why}无人认领,已补给 ${emp.id}:${added.join('、')}`); + }; + const stackStr = `${asArr(team.stack).join(' ')} ${team.package_manager || ''}`; + // 安全:安全文档产出区 + grant(findByIdName(/security|安全/i), ['docs/security/**'], '安全文档(威胁模型/审核报告)'); + // 运维:锁文件 / .gitignore / Docker / 部署 + const devops = findByIdName(/devops|ops|infra/i) || contractOwnerEmp; + const LOCKS = { npm: 'package-lock.json', pnpm: 'pnpm-lock.yaml', yarn: 'yarn.lock' }; + if (LOCKS[team.package_manager]) grant(devops, [LOCKS[team.package_manager]], '依赖锁文件'); + grant(devops, ['.gitignore'], '.gitignore'); + if (/docker/i.test(stackStr)) grant(devops, ['Dockerfile', 'docker-compose.*', 'docker/**', 'deploy/**'], 'Docker/部署文件'); + // 测试:测试配置与工具目录(按栈出现的工具补) + const tester = findByIdName(/test|qa|测试/i); + if (/playwright/i.test(stackStr)) grant(tester, ['playwright/**', 'playwright.config.*'], 'Playwright 配置'); + if (/vitest/i.test(stackStr)) grant(tester, ['vitest.config.*', 'vitest.setup.*'], 'Vitest 配置'); + if (/\bjest\b/i.test(stackStr)) grant(tester, ['jest.config.*', 'jest.setup.*'], 'Jest 配置'); + if (tester) grant(tester, ['__tests__/**', 'src/test-utils/**'], '测试目录/工具'); + } + + // 3) 安全协作:security 角色必须与鉴权/上传/权限相关角色(至少后端)双向 collaborators + const sec = findByIdName(/security|安全/i); + if (sec) { + sec.collaborators = asArr(sec.collaborators); + const SEC_DUTY_RE = /鉴权|登录|会话|auth|session|token|权限|rbac|上传|upload/i; + team.employees.forEach((e) => { + if (e.id === sec.id) return; + const text = `${e.duty || ''} ${asArr(e.responsibilities).join(' ')} ${e.prompt || ''}`; + if (!SEC_DUTY_RE.test(text) && !/back/.test(String(e.id))) return; + e.collaborators = asArr(e.collaborators); + let touched = false; + if (!e.collaborators.includes(sec.id)) { e.collaborators.push(sec.id); touched = true; } + if (!sec.collaborators.includes(e.id)) { sec.collaborators.push(e.id); touched = true; } + if (touched) warn(`已建立 ${e.id} ↔ ${sec.id} 安全协作关系`); + }); + } + + // 4)+5) git_rules:提交示例贴合本项目 + 共享协作区规则 + // ⚠️ 先深拷贝——team.git_rules 可能就是 TEAM_CONVENTIONS.git_rules 的引用,绝不能改常量本身 + team.git_rules = [...(asArr(team.git_rules).length ? team.git_rules : TEAM_CONVENTIONS.git_rules)]; + const writeEp = asArr(team.api_endpoints).find((ep) => /^(post|put)$/i.test(String((ep && ep.method) || ''))); + let scope = ''; + if (writeEp && writeEp.path) { + // 取 path 最后一个静态段(跳过 :id / {id} 等参数段)并单数化 + const segs = String(writeEp.path).split(/[/?#]/).filter((x) => x && !/[:{}\[\]<>]/.test(x) && !x.includes('*')); + scope = String(segs[segs.length - 1] || '').toLowerCase().replace(/[^a-z0-9-]/g, ''); + if (/ies$/.test(scope)) scope = scope.replace(/ies$/, 'y'); + else if (/s$/.test(scope) && !/ss$/.test(scope)) scope = scope.replace(/s$/, ''); + } + if (!scope) scope = String(team.db_models[0] || '').toLowerCase().replace(/[^a-z0-9-]/g, ''); + if (!scope) scope = 'core'; + const exDesc = (writeEp && writeEp.desc) || '完成核心功能'; + team.git_rules = team.git_rules.map((r) => + /提交信息格式/.test(String(r)) ? `提交信息格式:type(scope): 摘要(如 feat(${scope}): ${exDesc})` : r + ); + if (!team.git_rules.some((r) => /docs\/coordination/.test(String(r)))) { + team.git_rules.push( + 'docs/coordination/** 为共享协作区:所有角色均可写入交接单 / 验收 / 裁决记录,不属于任何角色的 owner_paths' + ); + } + return team; +} + +// 结构性归一(/api/employees 与 /api/pack 共用):id 清洗去重、职责归属(owner_paths)互斥、跨角色引用收敛、总控只读兜底。 +// 权限模型(开放写权限模式):allowed_paths 只有两档——执行角色 ['**']、orchestrator []; +// 职责/审核归属由 owner_paths 表达(不限制编辑,只决定谁需要复核)。 +// 注意:这里不动 e.model —— pack 时不能覆写模型。 +const OPEN_WRITE_RULE = '开放写权限:可修改项目内任意文件;跨职责改动必须在完成报告中说明并请对应 owner 复核'; +function reconcileTeam(team) { + team.employees = asArr(team.employees); + const seen = new Set(['orchestrator']); // 保留字:员工不得占用总控 id(否则文件名/清单/校验三处错位) + team.employees.forEach((e, i) => { + e.id = cleanId(e.id, `employee-${i + 1}`); + while (seen.has(e.id)) e.id += '-x'; + seen.add(e.id); + // 兼容旧字段:没给 owner_paths 时把 allowed_paths 当职责归属用 + e.owner_paths = asArr(e.owner_paths).length ? asArr(e.owner_paths) : asArr(e.allowed_paths); + e.review_paths = asArr(e.review_paths); + e.collaborators = asArr(e.collaborators); + }); + + // docs 结构性收窄(在互斥/嵌套检测之前做,避免产生脏冲突记录): + // - 宽泛 docs/** → docs/*.md(顶层文档):子目录归专职角色(如 docs/security),coordination 是共享区,谁都不许整锅端 + // - 误配的 docs/coordination/** 直接剔除(共享区不归任何单一角色) + team.consistency_warnings = asArr(team.consistency_warnings); + team.employees.forEach((e) => { + e.owner_paths = e.owner_paths + .map((p) => { + if (stripGlob(p) === 'docs') { + team.consistency_warnings.push(`${e.id} 的 docs/** 过宽,已收窄为 docs/*.md(子目录归专职角色,docs/coordination 为共享区)`); + return 'docs/*.md'; + } + return p; + }) + .filter((p) => { + if (/^docs\/coordination(\/|$)/.test(stripGlob(p) + '/')) { + team.consistency_warnings.push(`${e.id} 的 ${p} 已剔除:docs/coordination 是共享协作区,不归任何单一角色`); + return false; + } + return true; + }); + }); + + // 强制 owner_paths 跨角色互不重叠。相同路径按「专业匹配优先」裁定归属, + // 而非先占者赢(否则模型把 prisma/** 同时给 backend 与 database 时,排前面的 backend 会抢走它)。 + const SPECIALTY = [ + { path: /(^|\/)(prisma|migrations?|schema|seeds?)(\/|$)|(^|\/)db(\/|$)/i, role: /db|database|warehouse/i }, + { path: /^security(\/|$)|^docs\/security(\/|$)/i, role: /security|安全/i }, + { path: /^(tests?|e2e|playwright|__tests__|spec)(\/|$)/i, role: /test|qa/i }, + { path: /^(\.github|scripts|infra|docker|deploy)(\/|$)/i, role: /devops|ops|infra/i }, + ]; + const specialistFor = (key) => { + const rule = SPECIALTY.find((s) => s.path.test(key)); + if (!rule) return null; + const emp = team.employees.find((e) => rule.role.test(e.id + ' ' + (e.name || ''))); + return emp ? emp.id : null; + }; + const conflicts = []; + // 先汇总每个 key 的全部认领者,再统一裁定 + const claims = new Map(); + team.employees.forEach((e) => + e.owner_paths.forEach((p) => { + const key = stripGlob(p); + if (!key) return; + if (!claims.has(key)) claims.set(key, []); + if (!claims.get(key).includes(e.id)) claims.get(key).push(e.id); + }) + ); + const winnerOf = new Map(); + claims.forEach((ids, key) => { + const spec = specialistFor(key); + winnerOf.set(key, spec && ids.includes(spec) ? spec : ids[0]); // 专业角色在认领者中则它赢,否则先占者 + }); + team.employees.forEach((e) => { + const kept = []; + e.owner_paths.forEach((p) => { + const key = stripGlob(p); + if (!key) return; + const win = winnerOf.get(key); + if (win !== e.id) { + conflicts.push({ path: key, role: e.id, ownedBy: win, nested: false }); + team.consistency_warnings.push(`owner_paths 重叠:${key} 按专业归属裁定给 ${win},${e.id} 降级为只读审查`); + if (!e.review_paths.includes(p)) e.review_paths.push(p); + } else if (!kept.includes(p)) { + kept.push(p); + } + }); + e.owner_paths = kept; + }); + // 嵌套消解:父路径吞子路径(如前端 src/app 吞后端 src/app/api)——父路径自动降级为只读,子路径保留 + const bases = []; + team.employees.forEach((e) => e.owner_paths.forEach((p) => bases.push({ p, base: stripGlob(p), role: e.id }))); + const demoteParents = new Map(); + bases.forEach((a) => bases.forEach((b) => { + if (a.role !== b.role && a.base && b.base && b.base.startsWith(a.base + '/')) { + conflicts.push({ path: a.base, role: a.role, child: b.base, ownedBy: b.role, nested: true }); + if (!demoteParents.has(a.role)) demoteParents.set(a.role, new Set()); + demoteParents.get(a.role).add(a.p); + team.consistency_warnings.push(`owner_paths 父子重叠:${a.role} 的 ${a.p} 包含 ${b.role} 的 ${b.base},父路径已降级为只读审查`); + } + })); + demoteParents.forEach((paths, roleId) => { + const e = team.employees.find((x) => x.id === roleId); + if (!e) return; + e.owner_paths = e.owner_paths.filter((p) => !paths.has(p)); + paths.forEach((p) => { if (!e.review_paths.includes(p)) e.review_paths.push(p); }); + }); + team.path_conflicts = conflicts; + + // 开放写权限模式定稿:执行角色一律 allowed_paths=['**'],并注入开放写权限约束(去重) + team.employees.forEach((e) => { + e.allowed_paths = ['**']; + e.constraints = asArr(e.constraints); + if (!e.constraints.includes(OPEN_WRITE_RULE)) e.constraints.push(OPEN_WRITE_RULE); + }); + + // 总控强制只读(文件名/键/校验三处对齐) + if (team.orchestrator) { + team.orchestrator.id = 'orchestrator'; + team.orchestrator.allowed_paths = []; // 总控只读,不写任何文件 + team.orchestrator.owner_paths = []; // 总控无职责归属路径(只审不改) + team.orchestrator.review_paths = asArr(team.orchestrator.review_paths); + if (!team.orchestrator.review_paths.length) team.orchestrator.review_paths = ['**']; + } + + // 契约 owner:模型给的若无效,兜底选后端类角色 / 第一个员工 + const ids = new Set(team.employees.map((e) => e.id)); + if (!team.contract_owner || !ids.has(team.contract_owner)) { + const be = team.employees.find((e) => /back|api|server|后端/i.test(e.id + e.name)); + team.contract_owner = (be && be.id) || (team.employees[0] && team.employees[0].id) || ''; + } + // 🔴 收敛所有跨角色引用回真实 id 集合(消除 collaborators/owner 悬空引用,如 qa-engineer vs testing-engineer) + team.employees.forEach((e) => { + e.collaborators = e.collaborators.filter((c) => ids.has(c) && c !== e.id); + }); + team.api_endpoints = asArr(team.api_endpoints).map((ep) => ({ + ...ep, + owner: ep && ids.has(ep.owner) ? ep.owner : team.contract_owner, // 无效 owner 兜底成契约 owner + })); + return team; +} + +function normalizeTeam(team, model, requirement) { + team.stack = asArr(team.stack); + team.employees = asArr(team.employees); + + team.employees.forEach((e) => { + e.model = model; // 员工统一用运行模型,避免模型名不一致 + e.constraints = ensureConstraints(e); + e.responsibilities = asArr(e.responsibilities); + e.handoff_in = e.handoff_in || ''; + e.handoff_out = e.handoff_out || ''; + }); + + if (team.orchestrator) { + team.orchestrator.model = model; + team.orchestrator.constraints = ensureConstraints(team.orchestrator); + team.orchestrator.responsibilities = asArr(team.orchestrator.responsibilities); + team.orchestrator.workflow = ORCHESTRATOR_WORKFLOW; // 注入调度状态机 + } + + // 结构性归一(id / 路径互斥 / 引用收敛 / 总控只读)——与 /api/pack 共用同一套 + reconcileTeam(team); + + team.contract_spec = + team.contract_spec || + '(由 contract owner 按本项目定义对外契约——Web:响应体/分页/错误码/鉴权;CLI:命令与参数;库:公开 API 与版本策略;数据管道:输入输出 schema 与调度。)'; + team.ci_steps = asArr(team.ci_steps); + team.env_vars = asArr(team.env_vars); + team.db_models = asArr(team.db_models); + team.interface_kind = team.interface_kind || (team.api_endpoints.length ? 'rest' : 'none'); + team.auth_scheme = team.auth_scheme || ''; + team.package_manager = team.package_manager || ''; + + // 完成定义 = 基线 + 模型追加 + team.definition_of_done = mergeUniq(TEAM_CONVENTIONS.definition_of_done, asArr(team.dod_extra)); + team.output_format = TEAM_CONVENTIONS.output_format; + team.git_rules = TEAM_CONVENTIONS.git_rules; + team.handoff_protocol = TEAM_CONVENTIONS.handoff_protocol; + + // 安全清单:涉及鉴权/权限/敏感数据时启用基线 + 模型追加 + const needSecurity = /鉴权|权限|登录|账号|支付|敏感|隐私|rbac|auth|token|jwt|role/i.test( + (requirement || '') + JSON.stringify(team.stack) + JSON.stringify(team.employees.map((e) => e.duty)) + ); + // 基线里的「会话/令牌」项按本项目选定的 auth_scheme 定稿,不再写死「JWT 或 Cookie」二选一 + const baseline = needSecurity + ? SECURITY_CHECKLIST.map((x) => + /会话 \/ 令牌/.test(x) && team.auth_scheme && team.auth_scheme !== 'none' + ? `会话 / 令牌:本项目统一采用 ${team.auth_scheme}——设置过期、可注销失效、防重放` + : x + ) + : []; + team.security_checklist = mergeUniq(baseline, asArr(team.security_checklist)); + + // 服务端强制口径一致:包管理器命令/锁文件 + 鉴权 env(改写记录进 consistency_warnings) + enforceConsistency(team); + + delete team.dod_extra; + return team; +} + +// 生成注入进每个 agent prompt 的「协同规范」块 +function ownerName(team) { + const o = team.employees.find((e) => e.id === team.contract_owner); + return o ? `${o.name}(${o.id})` : team.contract_owner || '后端负责人'; +} +function sharedProtocol(team, isLead) { + const L = []; + L.push('\n\n---\n## 协同规范(所有角色必须严格遵守)'); + L.push('\n### 一、每次任务完成后固定输出以下报告'); + TEAM_CONVENTIONS.output_format.forEach((x) => L.push(`- ${x}`)); + L.push('\n### 二、完成定义(DoD)——全部满足才算完成'); + team.definition_of_done.forEach((x) => L.push(`- ${x}`)); + L.push('\n### 三、Git / 文件边界'); + (asArr(team.git_rules).length ? team.git_rules : TEAM_CONVENTIONS.git_rules).forEach((x) => L.push(`- ${x}`)); + L.push('\n### 四、任务交接协议'); + TEAM_CONVENTIONS.handoff_protocol.forEach((x) => L.push(`- ${x}`)); + L.push(`\n### 五、接口契约`); + L.push(`- 契约 owner:${ownerName(team)},只有 owner 能定稿 / 变更契约。`); + L.push(`- 契约约定:${team.contract_spec}`); + L.push(`- 其他角色需遵循契约,有异议先与 owner 或总控确认,不擅自改动。`); + if (team.security_checklist.length) { + L.push('\n### 六、安全 / 权限审核清单(相关改动必须自检)'); + team.security_checklist.forEach((x) => L.push(`- ${x}`)); + } + if (isLead) { + L.push('\n### 七、调度流程(总控按此状态机推进)'); + ((team.orchestrator && team.orchestrator.workflow) || ORCHESTRATOR_WORKFLOW).forEach((x, i) => L.push(`${i + 1}. ${x}`)); + L.push('\n遇到跨角色冲突、职责归属不清、契约争议,由你(总控)裁决并驱动返工闭环。你是只读角色,不写任何文件。'); + L.push('\n### 八、范围纪律(防跑飞——总控是第一道闸)'); + L.push('- 分派任何任务前,先产出该任务的《任务契约》(套 templates/task-charter.md),其中「明确不做 / out of scope」至少写 2 条:根据**本次客户需求**推导「这个任务明确不该碰的东西」。写不出 out of scope,说明任务还没想清楚,不许分派。'); + L.push('- 审核与打回一律对照该任务的《任务契约》:产出必须能在「目标」里找到依据、且未命中「out of scope」。打回时理由必须引用《任务契约》或 DoD 的**具体条款**,并写入 docs/coordination/task-ledger.md,不凭感觉打回。'); + L.push('- 同一任务打回 ≥ 2 次时,不再简单退回,改为重新澄清《任务契约》(判断是不是任务边界本身没定义清楚),避免无限返工。'); + L.push('- 任务状态在 docs/coordination/task-ledger.md 流转(assigned → in-progress → review → rework → accepted),仅当状态为 accepted 才算关闭。'); + } else { + L.push('\n> 遇到跨角色冲突或职责归属不清,提交总控 Agent 裁决;跨职责修改务必走报告 + 复核(在完成报告中说明并请对应 owner 复核)。'); + L.push('\n### 七、范围纪律(防跑飞——动工前必读)'); + L.push('- 动工前先读本任务的《任务契约》(templates/task-charter.md 的本任务实例)。只做「目标」授权的事。'); + L.push('- 判定跑飞的通用标准:你即将做的事若无法在《任务契约》的「目标」里找到依据,或命中了「明确不做 / out of scope」,即为跑飞——**立即停下**,把情况写进 docs/coordination/task-ledger.md 并交总控裁决,不要自行扩大范围。'); + L.push('- 「顺手」优化 / 重构 / 补功能若不在本任务目标内,一律不做,先报总控。连续两次都要越界才能完成,说明任务定义有问题,同样交总控。'); + } + return L.join('\n'); +} + +// ---- 序列化:TOML(Codex)/ Markdown(Claude Code)---- +const esc = (s) => String(s == null ? '' : s).replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\r?\n/g, '\\n'); +const arrToml = (a) => '[' + (a || []).map((x) => `"${esc(x)}"`).join(', ') + ']'; +// TOML 多行基本字符串会解释反斜杠转义:先转义 \ 再转义 """,避免 prompt 里的 C:\ 或 \d 破坏解析 +const multiline = (s) => '"""\n' + String(s || '').replace(/\\/g, '\\\\').replace(/"""/g, '\\"\\"\\"') + '\n"""'; + +function toToml(agent, role, protocol) { + const lines = []; + lines.push(`name = "${esc(agent.name)}"`); + lines.push(`model = "${esc(agent.model)}"`); + lines.push(`role = "${role}"`); + lines.push(`duty = "${esc(agent.duty)}"`); + lines.push(`scope = "current-project-only"`); + if (role === 'orchestrator') { + lines.push(`readonly = true`); + lines.push(`allowed_paths = []`); + lines.push(`review_paths = ${arrToml(agent.review_paths)}`); + if (agent.workflow) lines.push(`workflow = ${arrToml(agent.workflow)}`); + } else { + // 开放写权限模式:allowed_paths 恒为 ["**"](技术可编辑全部);owner_paths 表达职责/审核归属 + lines.push(`allowed_paths = ${arrToml(asArr(agent.allowed_paths).length ? agent.allowed_paths : ['**'])}`); + lines.push(`owner_paths = ${arrToml(agent.owner_paths)}`); + if ((agent.review_paths || []).length) lines.push(`review_paths = ${arrToml(agent.review_paths)}`); + lines.push(`collaborators = ${arrToml(agent.collaborators)}`); + if (agent.handoff_in) lines.push(`handoff_in = "${esc(agent.handoff_in)}"`); + if (agent.handoff_out) lines.push(`handoff_out = "${esc(agent.handoff_out)}"`); + } + lines.push(`responsibilities = ${arrToml(agent.responsibilities)}`); + lines.push(`constraints = ${arrToml(agent.constraints)}`); + lines.push(''); + lines.push(`prompt = ${multiline((agent.prompt || '') + protocol)}`); + return lines.join('\n') + '\n'; +} + +function toMarkdown(agent, role, protocol) { + const desc = `${(agent.duty || '').replace(/\n/g, ' ')}(仅在当前项目内使用)`; + // Claude Code 的 model 字段只认 claude 系(sonnet|opus|haiku|claude-*|inherit)。 + // 网关模型名(gpt-* 等)写进去 agent 会启动失败——不匹配时省略该行 = 继承会话模型。 + const claudeModel = /^(sonnet|opus|haiku|inherit|claude-)/i.test(String(agent.model || '')) ? agent.model : null; + const fm = [ + '---', + `name: ${agent.id}`, + `description: ${desc}`, + ...(claudeModel ? [`model: ${claudeModel}`] : []), + role === 'orchestrator' ? 'tools: Read, Grep, Glob, Task' : 'tools: Read, Edit, Write, Bash, Grep, Glob', + '---', + '', + ].join('\n'); + const pathList = (a) => (a || []).map((p) => `\`${p}\``).join('、'); + const paths = + role === 'orchestrator' + ? [ + '', + '## 文件边界', + '- 只读,不写任何文件', + ...((agent.review_paths || []).length ? [`- 只读审查:${pathList(agent.review_paths)}`] : []), + ] + : [ + '', + '## 文件边界', + '- 技术可编辑:全部(开放写权限模式)', + `- 职责归属(owner_paths):${(agent.owner_paths || []).length ? pathList(agent.owner_paths) : '—'}`, + ...((agent.review_paths || []).length ? [`- 只读审查:${pathList(agent.review_paths)}`] : []), + ]; + const collab = + role === 'orchestrator' + ? [] + : [ + '', + '## 协作', + ...(agent.collaborators || []).map((c) => `- 与 \`${c}\` 协作`), + ...(agent.handoff_in ? [`- 接收:${agent.handoff_in}`] : []), + ...(agent.handoff_out ? [`- 交付:${agent.handoff_out}`] : []), + ]; + const body = [ + agent.prompt || '', + '', + '## 职责', + ...(agent.responsibilities || []).map((r) => `- ${r}`), + ...paths, + ...collab, + '', + '## 约束', + ...(agent.constraints || []).map((c) => `- ${c}`), + protocol, + '', + ].join('\n'); + return fm + body; +} + +function buildConventions(team) { + const L = []; + L.push(`# ${team.project_name || '项目'} · 团队协同规范`); + L.push('\n> 本文件是所有 Agent 的共同契约。每个 agent 的 prompt 里也内置了同一套规范。\n'); + L.push('## 统一产出格式(每次任务完成后必须输出)'); + team.output_format.forEach((x) => L.push(`- ${x}`)); + L.push('\n## 完成定义(DoD)'); + team.definition_of_done.forEach((x) => L.push(`- ${x}`)); + L.push('\n## Git / 文件边界'); + team.git_rules.forEach((x) => L.push(`- ${x}`)); + L.push('\n## 任务交接协议'); + team.handoff_protocol.forEach((x) => L.push(`- ${x}`)); + if (team.auth_scheme && team.auth_scheme !== 'none') L.push(`\n## 鉴权方案(全局唯一)\n- ${team.auth_scheme}`); + if (team.package_manager && team.package_manager !== 'none') L.push(`\n## 包管理器(全局唯一)\n- ${team.package_manager}(锁文件、CI 命令、.gitignore 均以此为准)`); + L.push('\n## 接口契约'); + L.push(`- Owner:${ownerName(team)}`); + L.push(`- 约定:${team.contract_spec}`); + if ((team.api_endpoints || []).length) { + L.push('- 接口清单(完整版见 templates/api-contract.md):'); + team.api_endpoints.slice(0, 24).forEach((e) => L.push(` - ${e.method || ''} ${e.path || ''} — ${e.desc || ''}`)); + } + if ((team.db_models || []).length) L.push(`- 数据模型:${team.db_models.join('、')}(结构变更必须附 up/down 迁移)`); + if (team.security_checklist.length) { + L.push('\n## 安全 / 权限审核清单'); + team.security_checklist.forEach((x) => L.push(`- ${x}`)); + } + if (team.orchestrator && team.orchestrator.workflow) { + L.push('\n## 总控调度流程'); + team.orchestrator.workflow.forEach((x, i) => L.push(`${i + 1}. ${x}`)); + } + L.push('\n## 协作关系'); + team.employees.forEach((e) => { + const c = (e.collaborators || []).join(', '); + L.push(`- **${e.name}** \`${e.id}\`${c ? ` ↔ ${c}` : ''}`); + }); + + const pl = (a) => (a || []).map((p) => `\`${p}\``).join('、') || '—'; + L.push('\n## 职责归属(owner_paths,审核归属而非编辑限制)'); + L.push('> 本包为开放写权限模式:执行角色 allowed_paths=["**"](技术上可改任意文件),orchestrator 只读。owner_paths 只决定「谁对这片文件负责、跨职责改动需要谁复核」。'); + team.employees.forEach((e) => { + L.push(`- **${e.name}** \`${e.id}\`|职责归属:${pl(e.owner_paths)}${(e.review_paths || []).length ? `|只读审查:${pl(e.review_paths)}` : ''}`); + }); + if (team.orchestrator) L.push(`- **${team.orchestrator.name}**(总控)|只读,不写任何文件`); + const exactC = (team.path_conflicts || []).filter((c) => !c.nested); + const nestC = (team.path_conflicts || []).filter((c) => c.nested); + if (exactC.length) { + L.push('\n> ⚠️ 已自动消解的重复职责归属(冲突方降级为只读审查):'); + exactC.forEach((c) => L.push(`> - \`${c.path}\` 职责归 ${c.ownedBy},${c.role} 改为只读审查`)); + } + if (nestC.length) { + L.push('\n> 🛑 职责嵌套冲突(父路径吞子路径,请由总控收窄父路径后再开工):'); + nestC.forEach((c) => L.push(`> - \`${c.path}\`(${c.role})包含了 \`${c.child}\`(${c.ownedBy})——把 ${c.role} 收窄到不含该子路径`)); + } + + if (asArr(team.consistency_warnings).length) { + L.push('\n## ⚠️ 自动一致性修正'); + L.push('> 以下是生成器为保证全局口径一致(包管理器 / 鉴权方案等)自动做的修正,供团队知悉:'); + team.consistency_warnings.forEach((w) => L.push(`- ${w}`)); + } + + L.push('\n## 落地产物(本包已生成,配合执行层约束)'); + L.push('- `.github/CODEOWNERS` — 路径 → 审核归属角色(代表「谁需要审核」,不代表「谁能改」)'); + L.push('- `.github/workflows/ci.yml` — CI 跑 DoD(lint / 测试 / 构建),devops 按技术栈补全'); + L.push('- `agent-paths.json` + `scripts/check-allowed-paths.sh` — 提交前跨职责提醒(执行角色只警告不阻断;orchestrator 只读,写文件即阻断)'); + L.push('- `templates/` — 交接单 / 接口契约 / 验收报告 模板'); + return L.join('\n') + '\n'; +} + +// CLAUDE.md:Claude Code 每次会话自动加载的入口(对应 Codex 的 AGENTS.md)。保持精简,细则放 CONVENTIONS.md。 +function buildClaudeMd(team) { + const L = [ + `# ${team.project_name || '项目'} · 多 Agent 协作`, + '', + '本项目采用多角色协作开发,**完整协同规范见 `CONVENTIONS.md`(必读)**。', + '', + '## 你(主线程)就是总控', + '- 按 CONVENTIONS.md 的「总控调度流程」推进:需求分析 → 拆任务 → 契约收敛 → 用 Task 委派给对应 subagent → 交叉审核 → 验收。', + '- 总控不直接写业务代码,实现工作委派给 `.claude/agents/` 下的角色;`orchestrator` 子代理(只读工具集)可用于独立审核。', + '- 开放写权限模式:执行角色技术上可改任意文件;跨职责(他人 owner_paths)改动必须在完成报告中说明并请对应 owner 复核。', + '', + '## 角色速查(委派时选对人)', + ...team.employees.map( + (e) => `- \`${e.id}\`:${(e.duty || '').replace(/\n/g, ' ')}|职责归属:${asArr(e.owner_paths).slice(0, 4).join('、') || '—'}` + ), + '', + `接口契约 owner:\`${team.contract_owner || '—'}\`(契约冻结后改动走 templates/decision-record.md 裁决)。`, + '', + ]; + return L.join('\n'); +} + +function buildReadme(team, tool) { + const isClaude = tool === 'claude'; + const L = []; + L.push(`# ${team.project_name || 'AI 员工团队'}`); + L.push(`\n> ${team.summary || ''}\n`); + L.push(`**技术栈**:${(team.stack || []).join(' · ')}`); + L.push(`\n**目标工具**:${isClaude ? 'Claude Code' : 'Codex'}`); + L.push('\n## 员工清单\n'); + if (team.orchestrator) L.push(`- **${team.orchestrator.name}**(总控)\`${team.orchestrator.id}\` — ${team.orchestrator.duty}`); + team.employees.forEach((e) => L.push(`- **${e.name}** \`${e.id}\` — ${e.duty}`)); + L.push('\n## 目录放置\n'); + if (isClaude) { + L.push('把 `.claude/agents/*.md` 放到你项目根目录下的 `.claude/agents/` 里,Claude Code 会自动识别为可调用的 subagent。'); + L.push('把 `CLAUDE.md` 和 `CONVENTIONS.md` 放到项目根目录:Claude Code 每次会话自动加载 `CLAUDE.md`(总控入口),细则见 `CONVENTIONS.md`。'); + L.push('主线程即充当总控:先读需求,按 `orchestrator.md` 的调度流程拆分任务,再用 Task 分派给对应 subagent。'); + } else { + L.push('把 `agents/*.toml` 放到 Codex 的 agents 配置目录,把 `AGENTS.md` 放到项目根目录(Codex 会自动读取作为团队公共规范)。'); + L.push('`_orchestrator.toml` 是总控:只做拆分任务、契约收敛、分派、审核、验收,不直接写业务代码。'); + } + L.push('\n## 如何跑起来\n'); + if (isClaude) { + L.push('1. 在项目根目录启动 Claude Code(自动加载 `CLAUDE.md`),**主线程即总控**:读取需求,按调度流程(见 `CONVENTIONS.md`)拆任务、定契约。'); + L.push('2. 总控用 Task 把任务 + 契约 + DoD 分派给对应 subagent。'); + } else { + L.push('1. 先让**总控**读取需求,按调度流程(见 `CONVENTIONS.md`)拆任务、定契约。'); + L.push('2. 总控把任务 + 契约 + DoD 分派给对应员工。'); + } + L.push('3. 每个员工完成后按「统一产出格式」回报,总控交叉审核。'); + L.push('4. 未达 DoD 的返工,全部通过后由总控做集成验收。'); + // 示例任务贴合本项目:优先取第一个 POST/PUT 接口的描述,其次任意接口,再次数据模型,最后兜底 + const eps = asArr(team.api_endpoints); + const writeEp = eps.find((e) => /^(post|put)$/i.test(String((e && e.method) || ''))); + const topic = + (writeEp && writeEp.desc) || + (eps[0] && eps[0].desc) || + asArr(team.db_models)[0] || + `${team.project_name || '本项目'}的第一个核心功能`; + L.push('\n**示例任务**(交给总控):'); + L.push('```'); + L.push(`实现「${topic}」功能:`); + L.push(`- 契约 owner(\`${team.contract_owner || '契约负责人'}\`)先定稿相关契约(入参/出参/错误码/权限约定)`); + if (asArr(team.db_models).length) L.push('- 数据负责人准备数据结构与迁移脚本'); + L.push('- 实现角色开发功能并互相联调'); + L.push('- 测试补用例,达 DoD 后总控验收'); + L.push('```'); + L.push('\n## ⚠️ 模型名'); + if (isClaude) { + L.push('Claude Code 的 `model` 字段只认 claude 系(`sonnet` / `opus` / `haiku` / `claude-*`)。生成时若未指定 claude 系模型,agent 文件已**省略 model 字段 = 自动继承会话模型**(推荐)。需要固定某档时在 frontmatter 加一行如 `model: sonnet`。'); + } else { + L.push(`各文件里的 \`model = "${team.employees[0] ? team.employees[0].model : ''}"\` 请改成你本地 Codex 运行器实际支持的模型名,否则可能调用失败。`); + } + L.push('\n> 定位:这是一份**多 Agent 协作配置包**(规范 + 角色 + 模板 + 工具化约束),不是可直接运行的项目骨架。`package.json` 等由 **devops-engineer 作为第一个 bootstrap 任务**初始化后,CI 才会真正跑起来(未初始化时 CI 不会硬失败)。'); + L.push('\n## ⚠️ 解压须知(先看这个)'); + L.push('本包含 `.github/`、`.env.example`、`.gitignore` 等**隐藏文件**。访达/图形工具拖拽会把它们弄丢,导致审查时看到「旧版残留」假象。'); + L.push('```bash'); + L.push('unzip -o agents-xxx.zip -d 全新目录/ # 必须命令行完整解压到空目录'); + L.push('```'); + L.push('\n## 初始化(一条命令)'); + L.push('```bash'); + L.push('bash scripts/setup.sh'); + L.push('```'); + L.push('它会:`git init` + 给脚本加可执行权限 + `git config core.hooksPath scripts/hooks`(启用提交信息校验 & 跨职责提醒)+ 建好 `docs/coordination/` 子目录。'); + L.push('之后每个角色开工前 `export AGENT_ROLE=<你的角色>`,commit 时自动做角色检查:执行角色为**警告模式**(跨职责改动只提醒复核,不阻断);`orchestrator` 为只读角色,写文件会被阻断。'); + L.push('\n## 落地产物'); + L.push(isClaude + ? '- `CLAUDE.md`:Claude Code 每次会话自动加载的总控入口\n- `CONVENTIONS.md`:完整协同规范(CLAUDE.md 指向它;含职责归属 owner_paths、契约、安全清单、调度流程)' + : '- `AGENTS.md` / `CONVENTIONS.md`:团队公共规范(Codex 自动读根目录 AGENTS.md;含职责归属 owner_paths、契约、安全清单、调度流程)'); + L.push('- `.github/CODEOWNERS`:路径 → 审核归属角色(由 owner_paths 生成,代表「谁需要审核」而非「谁能改」;把 @role-id 换成真实 GitHub handle)'); + L.push('- `.github/workflows/ci.yml`:CI 跑 DoD(lint/test/build),devops-engineer 按技术栈补全命令'); + L.push('- `scripts/setup.sh`:一键 bootstrap(git init + 权限 + 钩子 + 目录)'); + L.push('- `agent-paths.json` + `scripts/check-allowed-paths.sh`:角色检查(开放写权限模式:执行角色跨职责改动只警告提醒复核、不阻断;orchestrator 只读,写文件阻断)'); + L.push('- `scripts/hooks/commit-msg` + `scripts/hooks/pre-commit`:提交信息校验 & 跨职责提醒钩子'); + L.push('- `.gitignore` / `.env.example`:忽略清单 / 环境变量'); + L.push('- `docs/coordination/`:任务状态(requirements / contract-freeze / handoffs / acceptance / decisions)'); + L.push('- `templates/`:交接单 / 接口契约(清单 + 每接口完整定义模板)/ 验收报告 / 裁决记录'); + L.push('- `scripts/verify-consistency.sh`:一致性自检(agent 文件 / CODEOWNERS / CI 与 `agent-paths.json` 口径一致,发现旧版残留)'); + L.push('- `agent-manifest.json`:本次生成的角色与文件清单(自检 / 清理旧残留的依据)'); + L.push('\n> 审查 / 复检前先跑 `bash scripts/verify-consistency.sh`:输出 ✗ 说明目录里是旧版本残留(而非本包问题),按提示清理后再审。'); + L.push('\n## 更多'); + L.push('- `CONVENTIONS.md`:完整协同规范 + 职责归属(owner_paths)+ 冲突消解。每个 agent 的 prompt 已内置同一套规范。'); + return L.join('\n') + '\n'; +} + +// ---- 落地产物(工具化约束)---- +function buildCodeowners(team) { + const L = [ + '# 路径 → 审核归属角色(由 owner_paths 生成,互不重叠)', + '# 开放写权限模式:本文件代表「谁需要审核」,不代表「谁能改」——任何执行角色都可修改任意文件,', + '# 但跨职责改动须在完成报告中说明并请对应 owner 复核。', + '# 把 @role-id 换成真实 GitHub handle 即可启用 GitHub 代码所有者审查', + '', + ]; + // 已知点目录(basename 以点开头但其实是目录) + const DOT_DIRS = /^(\.github|\.claude|\.vscode|\.idea)$/i; + // 无扩展名的已知文件(不匹配「含点」规则的常见文件) + const KNOWN_FILES = /^(Dockerfile|Makefile|LICENSE|Procfile|Rakefile|Gemfile|CMakeLists\.txt|\.gitignore|\.env\.example)$/i; + // 每条 allowed_path → CODEOWNERS 行的路径写法(文件/目录/通配 分情况,不能先 stripGlob) + const toOwnerPath = (p) => { + const raw = String(p).replace(/^\/+|\/+$/g, ''); + if (!raw) return ''; + // 1) 目录 glob(src/app/(public)/**、src/components/*)→ 归一成 /<目录>/** + if (/\/\*\*$/.test(raw)) return `/${raw.slice(0, -3)}/**`; + if (/\/\*$/.test(raw)) return `/${raw.slice(0, -2)}/**`; + // 2) 文件通配(next.config.*、**/*.test.ts)→ 原样保留(GitHub CODEOWNERS 支持 * 通配) + if (raw.includes('*')) return `/${raw}`; + const base = raw.split('/').pop(); + // 3) 已知点目录(.github 等)→ 目录 + if (DOT_DIRS.test(base)) return `/${raw}/**`; + // 4) basename 含扩展名点(globals.css)或已知无扩展名文件(Dockerfile)→ 文件,不加 /** + if (/\.[^./]+$/.test(base) || KNOWN_FILES.test(base)) return `/${raw}`; + // 5) 其余按目录处理 + return `/${raw}/**`; + }; + team.employees.forEach((e) => + (e.owner_paths || []).forEach((p) => { + // 共享协作区防御性过滤(reconcileTeam 已剔除,这里兜底) + if (/^docs\/coordination(\/|$)/.test(stripGlob(p) + '/')) return; + const line = toOwnerPath(p); + if (line) L.push(`${line} @${e.id}`); + }) + ); + // 共享协作区:所有角色可写,不设单一 owner。 + // CODEOWNERS 按「最后匹配生效」,下面这条无 owner 条目会清除上方任何模式对该区的归属。 + L.push(''); + L.push('# docs/coordination/** 为共享协作区(交接单/验收/裁决,所有角色可写)——显式声明无单一 owner'); + L.push('/docs/coordination/**'); + return L.join('\n') + '\n'; +} + +// agent-paths.json(开放写权限模式新结构): +// { "mode": "open-write", "orchestrator": [], "roles": { "": { "allowed": ["**"], "owner": [...] } } } +// allowed = 技术可编辑范围(执行角色恒 ["**"]);owner = 职责/审核归属(保留原始 glob,脚本按 glob 匹配)。 +// 消费方脚本向后兼容旧结构(roles. 是数组时视为 owner,且 allowed 同值)。 +function buildAgentPaths(team) { + const roles = {}; + team.employees.forEach((e) => { + roles[e.id] = { + allowed: asArr(e.allowed_paths).length ? e.allowed_paths : ['**'], + owner: asArr(e.owner_paths), + }; + }); + return JSON.stringify({ mode: 'open-write', orchestrator: [], roles }, null, 2) + '\n'; +} + +const CHECK_SCRIPT = `#!/usr/bin/env bash +# 角色检查(开放写权限模式): +# - orchestrator:只读角色,暂存区有任何文件 → 逐个报错并阻断(exit 1) +# - 执行角色(allowed 含 "**"):一律放行(exit 0);改动落在其他角色 owner_paths 内时 +# 打印跨职责警告,提醒在完成报告中说明并请对应 owner 复核(docs/coordination/** 共享区不警告) +# - 兼容旧包 / 自定义收窄:allowed 不含 "**" 时沿用旧的越界阻断逻辑 +# 用法: bash scripts/check-allowed-paths.sh +set -euo pipefail +ROLE="\${1:-}" +[ -z "$ROLE" ] && { echo "用法: $0 "; exit 2; } + +# 必须在 git 仓库里 +if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + echo "✗ 当前不是 git 仓库,先 git init(否则无法检查暂存区)"; exit 2 +fi +# 角色清单:可见文件名优先(agent-paths.json),兼容旧隐藏名 +PF=agent-paths.json; [ -f "$PF" ] || PF=.agent-paths.json +if [ ! -f "$PF" ]; then echo "✗ 缺少 agent-paths.json(解压可能丢了文件,重新 unzip -o 到空目录)"; exit 2; fi +# 解析:优先 node,回退 python3(不把整套约束锁死在 Node 环境)。 +# 兼容两种结构:新 roles.={allowed:[...],owner:[...]};旧 roles.=[...](视为 owner 且 allowed 同值)。 +# 用法:_read roles | _read allowed | _read owner | _read others (其他角色 owner,行格式 \\t) +_read() { + if command -v node >/dev/null 2>&1; then + node -e "const m=require('./'+process.argv[1]).roles||{};const norm=v=>Array.isArray(v)?{allowed:v,owner:v}:{allowed:(v&&v.allowed)||[],owner:(v&&v.owner)||[]};const mode=process.argv[2]||'roles';const r=process.argv[3]||'';if(mode==='roles')process.stdout.write(Object.keys(m).join(' '));else if(mode==='allowed')process.stdout.write(norm(m[r]).allowed.join('\\n'));else if(mode==='owner')process.stdout.write(norm(m[r]).owner.join('\\n'));else{const out=[];Object.keys(m).forEach(k=>{if(k===r)return;norm(m[k]).owner.forEach(p=>out.push(k+'\\t'+p));});process.stdout.write(out.join('\\n'));}" "$PF" "\${1:-roles}" "\${2:-}" + elif command -v python3 >/dev/null 2>&1; then + python3 -c "import json,sys;m=json.load(open(sys.argv[1])).get('roles',{});norm=lambda v:{'allowed':v,'owner':v} if isinstance(v,list) else {'allowed':(v or {}).get('allowed',[]),'owner':(v or {}).get('owner',[])};mode=sys.argv[2] if len(sys.argv)>2 else 'roles';r=sys.argv[3] if len(sys.argv)>3 else '';sys.stdout.write(' '.join(m.keys()) if mode=='roles' else chr(10).join(norm(m.get(r))['allowed']) if mode=='allowed' else chr(10).join(norm(m.get(r))['owner']) if mode=='owner' else chr(10).join(k+chr(9)+p for k in m if k!=r for p in norm(m.get(k))['owner']))" "$PF" "\${1:-roles}" "\${2:-}" + else + echo "✗ 需要 node 或 python3 来解析 agent-paths.json" >&2; exit 2 + fi +} + +FILES=$(git diff --cached --name-only || true) + +# —— 总控只读:写任何文件都阻断 —— +if [ "$ROLE" = "orchestrator" ]; then + [ -z "$FILES" ] && { echo "无暂存改动"; exit 0; } + while IFS= read -r f; do + [ -z "$f" ] && continue + echo "✗ 总控为只读角色,不得写文件: $f" + done <<< "$FILES" + exit 1 +fi + +KNOWN=$(_read roles) +case " \${KNOWN} " in + *" \${ROLE} "*) : ;; + *) echo "✗ 未知角色: \${ROLE} (可用角色: \${KNOWN} orchestrator)"; exit 2 ;; +esac + +[ -z "$FILES" ] && { echo "无暂存改动"; exit 0; } + +ALLOWED=$(_read allowed "$ROLE") +MY_OWNER=$(_read owner "$ROLE") + +# glob 匹配:** 允许跨目录;单个 * 不允许跨目录(docs/*.md 不得匹配 docs/security/x.md) +matches() { + local f="$1" p="$2" base fs ps + base="\${p%/}"; base="\${base%/\\*\\*}"; base="\${base%/\\*}" + if [[ "$base" != *'*'* ]]; then # base 不含通配才按目录前缀匹配 + [[ "$f" == "$base" ]] && return 0 # 精确文件/目录 + [[ "$f" == "$base"/* ]] && return 0 # 目录前缀(含深层) + fi + if [[ "$f" == $p ]]; then # 按 glob 匹配(next.config.*、src/routes/**/+server.ts 等) + [[ "$p" == *'**'* ]] && return 0 # ** 模式:允许跨目录 + fs="\${f//[^\\/]/}"; ps="\${p//[^\\/]/}" # 单星模式:要求路径深度一致,防 * 跨目录 + [ "\${#fs}" -eq "\${#ps}" ] && return 0 + return 1 + fi + return 1 +} + +# —— 开放写权限模式(allowed 含 "**"):全放行,仅对落在其他角色 owner_paths 的改动打跨职责警告 —— +if printf '%s\\n' "$ALLOWED" | grep -qx '\\*\\*'; then + OTHERS=$(_read others "$ROLE") + warned=0 + while IFS= read -r f; do + [ -z "$f" ] && continue + # 共享协作区:docs/coordination/** 所有角色均可写入(交接单/验收/裁决),不算跨职责 + case "$f" in docs/coordination/*) continue ;; esac + # 落在本角色 owner_paths 内 → 本职责改动,不警告 + mine=0 + while IFS= read -r p; do + [ -z "$p" ] && continue + if matches "$f" "$p"; then mine=1; break; fi + done <<< "$MY_OWNER" + [ "$mine" -eq 1 ] && continue + while IFS=$'\\t' read -r oid p; do + [ -z "\${oid:-}" ] && continue + [ -z "\${p:-}" ] && continue + if matches "$f" "$p"; then + echo "⚠ 跨职责修改: $f 属 @\${oid} 职责范围,请在完成报告中说明并请其复核" + warned=1 + break + fi + done <<< "$OTHERS" + done <<< "$FILES" + if [ "$warned" -eq 1 ]; then + echo "✓ 放行(开放写权限模式,⚠ 为跨职责提醒)" + else + echo "✓ 放行(开放写权限模式,无跨职责改动)" + fi + exit 0 +fi + +# —— 兼容旧包 / 自定义收窄:allowed 不含 "**" → 按旧逻辑阻断越界 —— +bad=0 +while IFS= read -r f; do + [ -z "$f" ] && continue + ok=0 + # 共享协作区:docs/coordination/** 所有角色均可写入(交接单/验收/裁决),全局放行 + case "$f" in docs/coordination/*) ok=1 ;; esac + while IFS= read -r p; do + [ -z "$p" ] && continue + if matches "$f" "$p"; then ok=1; break; fi + done <<< "$ALLOWED" + [ "$ok" -ne 1 ] && { echo "✗ 越界: $f 不在 [$ROLE] 的 allowed_paths"; bad=1; } +done <<< "$FILES" +[ "$bad" -eq 0 ] && echo "✓ 所有改动都在 [$ROLE] 边界内" +exit $bad +`; + +// 一致性自检脚本:agent 文件 / CODEOWNERS / CI / env 与 agent-paths.json + agent-manifest.json 交叉校验 +const VERIFY_SCRIPT = `#!/usr/bin/env bash +# 一致性自检(在包根目录运行):bash scripts/verify-consistency.sh +# 作用:判定目录是「自洽的新包」还是「混入旧版残留 / 解压丢了隐藏文件」 +set -uo pipefail +bad=0; lost=0 + +# —— 0) 关键文件在场检查(缺失多半是解压/拷贝丢了隐藏文件)—— +PF=agent-paths.json; [ -f "$PF" ] || PF=.agent-paths.json +MF=agent-manifest.json; [ -f "$MF" ] || MF=.agent-manifest.json +for pair in "\${PF}|agent-paths.json" ".github/CODEOWNERS|.github/CODEOWNERS" ".github/workflows/ci.yml|.github/workflows/ci.yml" ".gitignore|.gitignore" ".env.example|.env.example" "\${MF}|agent-manifest.json"; do + f="\${pair%%|*}"; label="\${pair#*|}" + [ -f "$f" ] || { echo "✗ 缺少 \${label}"; lost=1; bad=1; } +done +if [ "$lost" -eq 1 ]; then + echo " ► 这些缺失多半是解压/拷贝时丢了隐藏文件(访达拖拽最常见)" + echo " ► 正确做法:命令行完整解压到全新目录 → unzip -o 包.zip -d 新目录/" +fi +[ -f "$PF" ] || exit 1 + +# —— 解析器:node 优先,python3 回退 —— +# 兼容两种结构:新 roles.={allowed:[...],owner:[...]}(取 owner=职责/审核归属);旧 roles.=[...](即 owner) +_pf() { # 参数:角色 id → 输出该角色 owner 路径;留空则输出全部角色名 + if command -v node >/dev/null 2>&1; then + node -e "const m=require('./'+process.argv[1]).roles||{};const own=v=>Array.isArray(v)?v:((v&&v.owner)||[]);const r=process.argv[2]||'';process.stdout.write(r?own(m[r]).join('\\n'):Object.keys(m).join(' '))" "$PF" "\${1:-}" + elif command -v python3 >/dev/null 2>&1; then + python3 -c "import json,sys;m=json.load(open(sys.argv[1])).get('roles',{});own=lambda v:v if isinstance(v,list) else (v or {}).get('owner',[]);r=sys.argv[2] if len(sys.argv)>2 and sys.argv[2] else '';sys.stdout.write(chr(10).join(own(m.get(r))) if r else ' '.join(m.keys()))" "$PF" "\${1:-}" + else echo "✗ 需要 node 或 python3" >&2; exit 2; fi +} +_mf() { # 参数:manifest 字段名;数组输出为空格分隔 + [ -f "$MF" ] || return 0 + if command -v node >/dev/null 2>&1; then + node -e "const m=require('./'+process.argv[1]);const v=m[process.argv[2]];process.stdout.write(Array.isArray(v)?v.join(' '):String(v==null?'':v))" "$MF" "$1" + elif command -v python3 >/dev/null 2>&1; then + python3 -c "import json,sys;m=json.load(open(sys.argv[1]));v=m.get(sys.argv[2],'');sys.stdout.write(' '.join(v) if isinstance(v,list) else str(v))" "$MF" "$1" + fi +} +_own_all() { # 输出全部「角色owner路径」行(互斥校验用) + if command -v node >/dev/null 2>&1; then + node -e "const m=require('./'+process.argv[1]).roles||{};const own=v=>Array.isArray(v)?v:((v&&v.owner)||[]);const out=[];for(const[r,v]of Object.entries(m))for(const p of own(v))out.push(r+'\\t'+p);process.stdout.write(out.join('\\n'))" "$PF" + elif command -v python3 >/dev/null 2>&1; then + python3 -c "import json,sys;m=json.load(open(sys.argv[1])).get('roles',{});own=lambda v:v if isinstance(v,list) else (v or {}).get('owner',[]);sys.stdout.write(chr(10).join(r+chr(9)+p for r,v in m.items() for p in own(v)))" "$PF" + fi +} +ROLES=$(_pf) + +# —— a) 角色 ↔ agent 文件(orchestrator 固定对应 _orchestrator.toml / orchestrator.md,单独查)—— +for r in $ROLES; do + case "$r" in orchestrator) continue ;; esac + [ -f "agents/\${r}.toml" ] || [ -f ".claude/agents/\${r}.md" ] || { echo "✗ 角色 \${r} 缺少 agent 文件(agents/\${r}.toml 或 .claude/agents/\${r}.md)—— agent 文件或 agent-paths.json 有一方是旧版"; bad=1; } +done +[ -f "agents/_orchestrator.toml" ] || [ -f ".claude/agents/orchestrator.md" ] || { echo "✗ 缺少总控 agent 文件(agents/_orchestrator.toml 或 .claude/agents/orchestrator.md)"; bad=1; } +for d in agents .claude/agents; do + [ -d "$d" ] || continue + for f in "$d"/*.toml "$d"/*.md; do + [ -e "$f" ] || continue + b=$(basename "$f"); b="\${b%.toml}"; b="\${b%.md}" + case "$b" in _orchestrator|orchestrator) continue ;; esac + case " \${ROLES} " in + *" \${b} "*) : ;; + *) echo "✗ 旧版本残留文件: \${f}(不在当前角色清单里)"; bad=1 ;; + esac + done +done + +# —— b) CODEOWNERS ↔ 角色集合(双向)—— +if [ -f .github/CODEOWNERS ]; then + CO_ROLES=$(grep -v '^#' .github/CODEOWNERS | grep -oE '@[a-z0-9][a-z0-9-]*' | sed 's/^@//' | sort -u | tr '\\n' ' ') + for r in $CO_ROLES; do + case " \${ROLES} orchestrator " in + *" \${r} "*) : ;; + *) echo "✗ CODEOWNERS 含未知角色 @\${r} —— CODEOWNERS 是旧版残留"; bad=1 ;; + esac + done + for r in $ROLES; do + case "$r" in orchestrator) continue ;; esac + [ -n "$(_pf "$r")" ] || continue # 无 owner_paths(审核归属)的角色不出现在 CODEOWNERS + case " \${CO_ROLES} " in + *" \${r} "*) : ;; + *) echo "✗ CODEOWNERS 缺少角色 @\${r} —— CODEOWNERS 是旧版残留"; bad=1 ;; + esac + done +fi + +# —— c) manifest 交叉校验:包管理器 / 必需环境变量 / 角色集 —— +if [ -f "$MF" ]; then + PM=$(_mf package_manager) + if [ -n "$PM" ] && [ "$PM" != "none" ] && [ -f .github/workflows/ci.yml ]; then + for other in npm pnpm yarn; do + [ "$other" = "$PM" ] && continue + grep -qE "(^|[^a-zA-Z@/-])\${other} (ci|install|run|test|add)" .github/workflows/ci.yml && { echo "✗ ci.yml 用了 \${other},但本包声明包管理器为 \${PM} —— ci.yml 是旧版残留"; bad=1; } + done + fi + REQ=$(_mf required_env) + if [ -n "$REQ" ] && [ -f .env.example ]; then + for v in $REQ; do + grep -q "^\${v}=" .env.example || { echo "✗ .env.example 缺少 \${v} —— .env.example 是旧版残留"; bad=1; } + done + fi + MROLES=$(_mf roles) + if [ -n "$MROLES" ]; then + for r in $MROLES; do + case "$r" in orchestrator) continue ;; esac + case " \${ROLES} " in *" \${r} "*) : ;; *) echo "✗ agent-paths.json 缺少角色 \${r} —— agent-paths.json 是旧版残留"; bad=1 ;; esac + done + for r in $ROLES; do + case "$r" in orchestrator) continue ;; esac + case " \${MROLES} " in *" \${r} "*) : ;; *) echo "✗ manifest 不含角色 \${r} —— agent-paths.json 与 manifest 不同代"; bad=1 ;; esac + done + fi +fi + +# —— d) CI 命令 ↔ 锁文件(无 manifest 时的兜底交叉检)—— +if [ -f .github/workflows/ci.yml ]; then + PMS="" + grep -qE '(^|[^a-zA-Z@/-])npm (ci|install|run|test)' .github/workflows/ci.yml && PMS="$PMS npm" + grep -qE '(^|[^a-zA-Z@/-])pnpm ' .github/workflows/ci.yml && PMS="$PMS pnpm" + grep -qE '(^|[^a-zA-Z@/-])yarn ' .github/workflows/ci.yml && PMS="$PMS yarn" + ALL_PATHS=$(for r in $ROLES; do _pf "$r"; echo; done) # 各角色 owner 路径(锁文件扫描按职责归属) + echo "$ALL_PATHS" | grep -q 'package-lock\\.json' && PMS="$PMS npm" + echo "$ALL_PATHS" | grep -q 'pnpm-lock\\.yaml' && PMS="$PMS pnpm" + echo "$ALL_PATHS" | grep -q 'yarn\\.lock' && PMS="$PMS yarn" + N=$(printf '%s\\n' $PMS | sort -u | grep -c . || true) + [ "\${N:-0}" -gt 1 ] && { echo "✗ CI 命令与锁文件指向不同包管理器(\${PMS# })—— 有一方是旧版残留"; bad=1; } +fi + +# —— e) owner_paths 跨角色互斥:相同路径或父子重叠必须失败(职责归属必须唯一)—— +OROLES=(); OPATHS=(); i=0 +while IFS=$(printf '\\t') read -r r p; do + [ -z "$r" ] && continue + OROLES[i]="$r"; OPATHS[i]="$p"; i=$((i+1)) +done < ` ${String(s).replace(/[\r\n]+/g, ' ')}`).join('\n') + : ' echo "devops-engineer:把本项目的 lint/测试/构建命令填进来(对应 ci_steps)"'; + return `name: CI +on: [push, pull_request] +jobs: + dod: + runs-on: ubuntu-latest + # runner 预装了 node/python/go/rust/java 等;如需特定版本,devops 自行加对应 setup step + steps: + - uses: actions/checkout@v4 + - name: DoD 门禁 + run: | + # 项目骨架未就绪时不硬失败(这是协作配置包,待 devops bootstrap 后生效) + if git ls-files 2>/dev/null | grep -qE '(^|/)(package\\.json|go\\.mod|requirements\\.txt|pyproject\\.toml|setup\\.(py|cfg)|Cargo\\.toml|Gemfile|pom\\.xml|build\\.gradle|composer\\.json|pubspec\\.yaml|[^/]+\\.(csproj|sln)|Package\\.swift|mix\\.exs|CMakeLists\\.txt|Makefile|deno\\.jsonc?|build\\.zig)$'; then +${run} + else + echo "ℹ️ 尚无项目骨架 —— 协作配置包,待 devops-engineer 初始化后 CI 生效。" + fi +`; +} + +const TPL_HANDOFF = `# 交接单 + +- 来源角色: +- 目标角色: +- 交接物: +- 约定格式: +- 验收点: +- 关联契约 / 迁移: +- 备注 / 已知风险: +`; + +// 契约(按 interface_kind 自适应:API 项目出接口表,CLI/库/其他出对应契约) +function buildContract(team) { + const kind = team.interface_kind || 'none'; + const isApi = /rest|graphql|grpc/i.test(kind); + const L = ['# 契约(由 contract owner 定稿,冻结后改动走裁决记录)', '', `> interface_kind = ${kind}`, '']; + L.push('## 约定'); + L.push(team.contract_spec || '(按本项目定义对外契约)'); + if (isApi) { + L.push('\n## 接口列表'); + if ((team.api_endpoints || []).length) { + L.push('| 方法 | 路径 | 说明 | 负责角色 |'); + L.push('|------|------|------|----------|'); + team.api_endpoints.forEach((e) => L.push(`| ${e.method || ''} | ${e.path || ''} | ${(e.desc || '').replace(/\|/g, '\\|')} | ${e.owner || ''} |`)); + } else { + L.push('(⚠️ contract owner 补全全部接口,冻结后不得擅改)'); + } + L.push('\n## 每个接口的完整定义(按此模板逐个补全)'); + L.push('```'); + L.push('### '); + L.push('- 权限码 / 请求 DTO / 响应 DTO / 错误码 / 分页排序过滤 / 请求示例 / 响应示例'); + L.push('```'); + } else { + L.push('\n## 对外契约(非 HTTP API 项目,按下面补全)'); + if (kind === 'cli') L.push('- 命令列表、参数与选项、退出码、stdin/stdout/stderr 约定、使用示例'); + else if (kind === 'library') L.push('- 公开 API(函数 / 类 / 类型)、语义化版本策略、错误 / 异常约定、调用示例'); + else L.push('- 本项目对外暴露的接口 / 模块 / 数据 schema、约定与示例'); + if ((team.api_endpoints || []).length) { + L.push('\n### 已识别的对外项'); + team.api_endpoints.forEach((e) => L.push(`- ${e.method || ''} ${e.path || ''} — ${e.desc || ''}`)); + } + } + if ((team.db_models || []).length) { + L.push('\n## 数据模型 / 实体'); + team.db_models.forEach((m) => L.push(`- ${m}`)); + L.push('(结构变更需附迁移 / 版本管理)'); + } + return L.join('\n') + '\n'; +} + +// 验收报告(内联本项目的 DoD) +function buildAcceptance(team) { + const L = ['# 验收报告', '', '- 任务:', '- 负责角色:', '', '## DoD 自检(全部满足才算完成)']; + // DoD 行直接跟随 TEAM_CONVENTIONS / team.definition_of_done(开放写权限模式:含「跨职责修改已说明并获 owner 复核」) + mergeUniq(team.definition_of_done || []).forEach((d) => L.push(`- [ ] ${d}`)); + L.push('', '## 结构化产出'); + [ + '变更摘要', + '修改文件', + '测试结果', + '风险与影响', + '需要配合(哪个角色接手 / 确认)', + '跨职责说明(为什么改非本职责文件 / 影响哪个角色 / 需要谁复核 / 是否改动契约、数据库结构、安全策略、部署配置;无则写"无")', + ].forEach((x) => L.push(`- ${x}:`)); + L.push('- 结论(通过 / 返工):'); + return L.join('\n') + '\n'; +} + +// .env.example(从 env_vars 生成) +function buildEnvExample(team) { + const L = ['# 复制为 .env 并填写实际值', '']; + const vars = team.env_vars || []; + if (vars.length) { + vars.forEach((v) => { if (v && v.desc) L.push(`# ${v.desc}`); L.push(`${(v && v.name) || 'KEY'}=`); L.push(''); }); + } else { + L.push('# 由 devops-engineer 按本项目实际需要补全(纯库 / 无状态工具可能一个都不需要)。'); + L.push('# 常见类别(有才填):数据源连接、密钥 / 令牌、外部服务凭证、存储、运行时参数、日志等级。'); + } + return L.join('\n') + '\n'; +} + +// 协作流程 + 任务状态目录 +function buildCoordination(team) { + const L = [ + '# 协作流程与任务状态', + '', + '多 Agent 协作的任务状态在这里流转,别只靠聊天记录(会丢上下文)。', + '', + '## 建议目录', + '- `requirements.md` — 需求拆分', + '- `contract-freeze.md` — 契约冻结版(contract owner 定稿后其他人不得擅改,改动走裁决)', + '- `task-ledger.md` — 任务状态账本(防跑飞:assigned→…→accepted,打回必须引用条款)', + '- `handoffs/` — 交接单(套 templates/handoff.md)', + '- `acceptance/` — 验收报告(套 templates/acceptance-report.md)', + '- `decisions/` — 裁决记录(套 templates/decision-record.md)', + '', + '## 调度流转(总控按此推进)', + ]; + (team.orchestrator && team.orchestrator.workflow ? team.orchestrator.workflow : []).forEach((w, i) => L.push(`${i + 1}. ${w}`)); + return L.join('\n') + '\n'; +} + +const TPL_DECISION = `# 裁决记录(冲突交总控裁决时填写) + +- 编号 / 日期: +- 冲突描述: +- 涉及角色: +- 各方主张: +- 裁决结论: +- 影响范围 / 需同步的契约或文件: +- 版本 / 生效说明: +`; + +// 任务状态账本:防跑飞的第二道闸。每个任务一行状态机,打回必须留可追溯的理由。 +const TASK_LEDGER = `# 任务状态账本(task-ledger) + +> 任务的分派、返工、验收状态在这里流转,别只靠聊天记录(会丢上下文、无法追溯打回理由)。 +> 每个任务先由总控产出《任务契约》(templates/task-charter.md),再在此登记。 + +## 状态机 +assigned(已分派)→ in-progress(进行中)→ review(待验收)→ rework(打回返工)→ accepted(验收通过,任务关闭) + +## 返工规则(防止无限拉锯) +- 每次打回必须在「上次打回原因」里引用《任务契约》或 DoD 的**具体条款**,不凭感觉打回。 +- 同一任务打回 ≥ 2 次:不再简单退回,改由总控重新澄清《任务契约》(多半是任务边界本身没定义清楚)。 +- 只有状态为 accepted 的任务才算关闭。 + +## 账本 +| task-id | 负责角色 | 契约链接 | 状态 | 打回次数 | 上次打回原因(引用条款) | 更新时间 | +|---------|----------|----------|------|----------|--------------------------|----------| +| | | | | 0 | | | +`; + +const COMMIT_MSG_HOOK = `#!/usr/bin/env bash +# 提交信息格式校验:type(scope): 摘要 +msg=$(head -1 "$1") +# 放行 git 自动生成的 merge / revert 提交 +if echo "$msg" | grep -qE '^(Merge |Revert )'; then exit 0; fi +if ! echo "$msg" | grep -qE '^(feat|fix|docs|refactor|test|chore|perf|ci|build)(\\([a-z0-9-]+\\))?: .+'; then + echo "✗ 提交信息不符合规范:type(scope): 摘要(如 feat(模块): 一句话摘要)" + exit 1 +fi +`; + +const PRECOMMIT_HOOK = `#!/usr/bin/env bash +# 角色检查(开放写权限模式):提交前对执行角色只做「跨职责提醒」——改动落在其他角色 +# owner_paths 内时打印警告提示报告 + 复核,但不阻断提交;orchestrator 例外:只读角色, +# 写任何文件都会被阻断(exit 1)。 +# 把 AGENT_ROLE 设成当前角色,或改下面的默认值。 +ROLE="\${AGENT_ROLE:-}" +[ -z "$ROLE" ] && { echo "⚠ 未设置 AGENT_ROLE,跳过角色检查(export AGENT_ROLE=backend-engineer 后生效)"; exit 0; } +bash scripts/check-allowed-paths.sh "$ROLE" +`; + +const SETUP_SH = `#!/usr/bin/env bash +# 一键 bootstrap:git 仓库 + 权限 + 钩子 + 目录 + 清理旧生成残留 +set -e +[ -d .git ] || git init +chmod +x scripts/*.sh scripts/hooks/* 2>/dev/null || true +git config core.hooksPath scripts/hooks +mkdir -p docs/coordination/handoffs docs/coordination/acceptance docs/coordination/decisions + +# 清理孤儿 agent 文件(把新版 zip 解压覆盖到旧目录时,上一代角色文件会残留) +PF=agent-paths.json; [ -f "$PF" ] || PF=.agent-paths.json +_roles() { + if command -v node >/dev/null 2>&1; then node -e "process.stdout.write(Object.keys(require('./'+process.argv[1]).roles).join(' '))" "$PF" + elif command -v python3 >/dev/null 2>&1; then python3 -c "import json,sys;print(' '.join(json.load(open(sys.argv[1]))['roles'].keys()),end='')" "$PF"; fi +} +if [ -f "$PF" ]; then + KNOWN=" $(_roles) orchestrator " + for d in agents .claude/agents; do + [ -d "$d" ] || continue + for f in "$d"/*.toml "$d"/*.md; do + [ -e "$f" ] || continue + b=$(basename "$f"); b="\${b%.toml}"; b="\${b%.md}"; b="\${b#_}" + case "$KNOWN" in *" $b "*) ;; *) echo " 清理孤儿: $f"; rm -f "$f";; esac + done + done + # 清理旧隐藏版元数据(已由可见版取代,留着会误导审查) + [ "$PF" = "agent-paths.json" ] && rm -f .agent-paths.json .agent-manifest.json 2>/dev/null || true +fi + +# 关键隐藏文件在场检查(缺失=解压/拷贝丢了隐藏文件) +for need in .github/CODEOWNERS .github/workflows/ci.yml .gitignore .env.example; do + [ -f "$need" ] || echo "⚠ 缺少 \${need} —— 解压时丢了隐藏文件!请重新 unzip -o 包.zip -d 全新目录" +done + +echo "✓ 完成:git 仓库就绪、钩子启用、旧残留已清理。" +echo " 角色检查:export AGENT_ROLE=<你的角色> 后,commit 时自动运行(执行角色跨职责只提醒;orchestrator 只读阻断)。" +echo " 下一步:由 devops-engineer 初始化项目骨架,CI 才会真正跑。" + +# 一致性自检(发现旧版本残留会提示;只提示不阻断 bootstrap) +bash scripts/verify-consistency.sh || true +`; + +// .gitignore 按技术栈派生(避免只覆盖 Node) +function buildGitignore(team) { + const s = ((team.stack || []).join(' ') + ' ' + (team.package_manager || '')).toLowerCase(); + const has = (re) => re.test(s); + const L = ['# 通用', '.env', '.env.*', '!.env.example', '*.log', '.DS_Store', 'Thumbs.db', '.idea/', '.vscode/', '*.tmp', '']; + if (has(/node|npm|pnpm|yarn|next|vite|react|vue|nuxt|svelte|remix|astro|typescript|javascript/)) + L.push('# Node', 'node_modules/', 'dist/', 'build/', '.next/', '.nuxt/', '.svelte-kit/', 'coverage/', ''); + if (has(/python|pip|poetry|django|flask|fastapi|airflow|pandas/)) + L.push('# Python', '__pycache__/', '*.py[cod]', '.venv/', 'venv/', '.pytest_cache/', '.mypy_cache/', ''); + if (has(/rust|cargo/)) L.push('# Rust', 'target/', ''); + if (has(/\bgo\b|golang/)) L.push('# Go', 'bin/', ''); + if (has(/java|kotlin|gradle|maven|spring/)) L.push('# JVM', '*.class', 'target/', '.gradle/', ''); + if (has(/flutter|dart/)) L.push('# Flutter', '.dart_tool/', 'build/', ''); + if (has(/swift|ios|xcode/)) L.push('# Swift', '.build/', 'DerivedData/', ''); + return L.join('\n') + '# 其余构建产物 / 依赖目录按本项目补充\n'; +} + +// 兜底:确保协同规范字段齐全(正常来自 /api/employees 已规范化) +function packDefaults(result) { + const fill = (v, d) => (asArr(v).length ? v : d); + result.output_format = fill(result.output_format, TEAM_CONVENTIONS.output_format); + result.definition_of_done = fill(result.definition_of_done, TEAM_CONVENTIONS.definition_of_done); + result.git_rules = fill(result.git_rules, TEAM_CONVENTIONS.git_rules); + result.handoff_protocol = fill(result.handoff_protocol, TEAM_CONVENTIONS.handoff_protocol); + result.security_checklist = asArr(result.security_checklist); + result.employees = asArr(result.employees); + result.api_endpoints = asArr(result.api_endpoints); + result.ci_steps = asArr(result.ci_steps); + result.env_vars = asArr(result.env_vars); + result.db_models = asArr(result.db_models); + result.interface_kind = result.interface_kind || (result.api_endpoints.length ? 'rest' : 'none'); + result.auth_scheme = result.auth_scheme || ''; + result.package_manager = result.package_manager || ''; + if (result.orchestrator) result.orchestrator.workflow = fill(result.orchestrator.workflow, ORCHESTRATOR_WORKFLOW); + // pack 复用完整结构性归一(id 清洗/路径互斥/引用收敛/总控只读,含 orchestrator.id 对齐;不覆写模型)+ 口径强制 + reconcileTeam(result); + enforceConsistency(result); + return result; +} + +// ============================================================================ +// 以下为本应用适配层:LLM 走 router 分组,打包走零依赖 zip +// ============================================================================ + +// 把分组 baseUrl 归一成「+ /chat/completions 即正确」的基址(对齐 memory.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 分组。返回 {baseUrl,key,model} +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; +} + +// 生成员工团队:需求 → 结构化团队 +async function generate({ requirement, tool, groupId, model, agentModel } = {}) { + if (!requirement || !String(requirement).trim()) throw new Error("请先输入项目需求"); + const g = resolveGroup(groupId); + const useModel = (model && String(model).trim()) || g.model; + if (!useModel) throw new Error("未选择模型"); + const gwBase = chatBase(g.baseUrl); + + const content = await callChat(gwBase, g.key, useModel, [ + { role: "system", content: EMPLOYEE_SYSTEM_PROMPT }, + { role: "user", content: buildEmployeeInstruction(requirement, tool) }, + ], 5000); + + // 员工运行模型:用户可指定,否则用分析模型 + const runModel = (agentModel && String(agentModel).trim()) || useModel; + const result = normalizeTeam(extractJson(content), runModel, requirement); + return result; +} + +// 预览单个 agent 文件(与打包用同一套序列化) +function preview({ result, tool, id } = {}) { + if (!result || !Array.isArray(result.employees)) throw new Error("缺少员工数据"); + packDefaults(result); + const isClaude = tool === "claude"; + const ext = isClaude ? "md" : "toml"; + const isLead = id === "orchestrator"; + const agent = isLead ? result.orchestrator : result.employees.find((e) => e.id === id); + if (!agent) throw new Error("未找到该员工"); + const proto = sharedProtocol(result, isLead); + const content = isClaude + ? toMarkdown(agent, isLead ? "orchestrator" : "worker", proto) + : toToml(agent, isLead ? "orchestrator" : "worker", proto); + const filename = isClaude + ? `.claude/agents/${agent.id}.md` + : `agents/${isLead ? "_orchestrator" : agent.id}.toml`; + return { filename, content, lang: ext }; +} + +// 组装完整文件树(返回 [{path, content, mode?}]),供打包 zip +function packFiles(result, tool) { + if (!result || !Array.isArray(result.employees)) throw new Error("缺少员工数据"); + packDefaults(result); + const isClaude = tool === "claude"; + const dir = isClaude ? ".claude/agents" : "agents"; + const protoWorker = sharedProtocol(result, false); + const protoLead = sharedProtocol(result, true); + const files = []; + const add = (path, content, mode) => files.push(mode ? { path, content, mode } : { path, content }); + + if (result.orchestrator) { + const o = result.orchestrator; + if (isClaude) add(`${dir}/${o.id}.md`, toMarkdown(o, "orchestrator", protoLead)); + else add(`${dir}/_orchestrator.toml`, toToml(o, "orchestrator", protoLead)); + } + result.employees.forEach((e) => { + if (isClaude) add(`${dir}/${e.id}.md`, toMarkdown(e, "worker", protoWorker)); + else add(`${dir}/${e.id}.toml`, toToml(e, "worker", protoWorker)); + }); + add("CONVENTIONS.md", buildConventions(result)); + if (!isClaude) add("AGENTS.md", buildConventions(result)); + if (isClaude) add("CLAUDE.md", buildClaudeMd(result)); + add("README.md", buildReadme(result, tool)); + + add(".github/CODEOWNERS", buildCodeowners(result)); + add(".github/workflows/ci.yml", buildCI(result)); + add("agent-paths.json", buildAgentPaths(result)); + add(".env.example", buildEnvExample(result)); + add(".gitignore", buildGitignore(result)); + add("scripts/setup.sh", SETUP_SH, 0o755); + add("scripts/check-allowed-paths.sh", CHECK_SCRIPT, 0o755); + add("scripts/verify-consistency.sh", VERIFY_SCRIPT, 0o755); + add("scripts/hooks/commit-msg", COMMIT_MSG_HOOK, 0o755); + add("scripts/hooks/pre-commit", PRECOMMIT_HOOK, 0o755); + add("templates/handoff.md", TPL_HANDOFF); + add("templates/api-contract.md", buildContract(result)); + add("templates/acceptance-report.md", buildAcceptance(result)); + add("templates/decision-record.md", TPL_DECISION); + add("templates/task-charter.md", TASK_CHARTER); + add("docs/coordination/README.md", buildCoordination(result)); + add("docs/coordination/task-ledger.md", TASK_LEDGER); + add("docs/coordination/requirements.md", "# 需求拆分\n\n(总控拆解后填写:功能列表、优先级、负责角色、依赖关系)\n"); + add("docs/coordination/contract-freeze.md", "# 契约冻结版\n\n(contract owner 定稿后填写;冻结后任何改动走 templates/decision-record.md)\n"); + add("docs/coordination/handoffs/.gitkeep", ""); + add("docs/coordination/acceptance/.gitkeep", ""); + add("docs/coordination/decisions/.gitkeep", ""); + + const manifest = { + generated_at: new Date().toISOString(), + mode: "open-write", + tool: isClaude ? "claude" : "codex", + project_name: result.project_name || "", + package_manager: result.package_manager || "", + auth_scheme: result.auth_scheme || "", + interface_kind: result.interface_kind || "", + required_env: asArr(result.env_vars).map((v) => v && v.name).filter(Boolean), + roles: result.employees.map((e) => e.id).concat(["orchestrator"]), + files: [], + }; + manifest.files = files.map((f) => f.path).concat(["agent-manifest.json"]).sort(); + add("agent-manifest.json", JSON.stringify(manifest, null, 2) + "\n"); + return files; +} + +// 打包成 zip buffer + 建议文件名 +function pack({ result, tool } = {}) { + const files = packFiles(result, tool); + const safe = cleanId(result.project_name, "project"); + const buf = createZip(files); + return { buffer: buf, filename: `agents-${safe}.zip` }; +} + +module.exports = { generate, preview, packFiles, pack }; diff --git a/electron/lib/token_inject.js b/electron/lib/token_inject.js new file mode 100644 index 0000000..d9f3a1d --- /dev/null +++ b/electron/lib/token_inject.js @@ -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 = + '
' + + '' + + '' + + '
✓0 token
' + + ''; + 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 }; diff --git a/electron/lib/token_usage.js b/electron/lib/token_usage.js new file mode 100644 index 0000000..d31be33 --- /dev/null +++ b/electron/lib/token_usage.js @@ -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, +}; diff --git a/electron/lib/token_watch.js b/electron/lib/token_watch.js new file mode 100644 index 0000000..d67283c --- /dev/null +++ b/electron/lib/token_watch.js @@ -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 }; diff --git a/electron/lib/voice_cdp.js b/electron/lib/voice_cdp.js index ac3c199..dc83040 100644 --- a/electron/lib/voice_cdp.js +++ b/electron/lib/voice_cdp.js @@ -16,10 +16,14 @@ function dlog(...a) { try { console.log("[voice]", ...a); } catch {} } const router = require("./router"); const providerSync = require("./provider_sync"); 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 BINDING_NAME = "__voiceBridge"; // 页面 → 主进程 的 CDP binding 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_MAX_MS = 20000; // 轮询就绪最长等待 const RECORD_MAX_MS = 60000; // 单次录音上限(注入脚本侧也限) @@ -35,7 +39,10 @@ let state = { target: null, // { id, url, title } }; let session = null; // 当前 CdpSession +let networkSessions = []; // worker 等附加 target 的 Network 监听 session +let networkAttachGeneration = 0; let onStatusCb = null; +let tokenSyncTimer = null; function snapshot() { 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 可执行路径 —— function codexExecutable() { const home = os.homedir(); @@ -139,6 +169,10 @@ function wsConnect(wsUrl) { const socket = net.connect({ host, port: Number(port) }); const events = { open: [], message: [], close: [], error: [] }; 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) { @@ -252,6 +286,7 @@ function wsConnect(wsUrl) { return; } handshakeDone = true; + try { socket.setTimeout(0); } catch {} emit("open"); parseFrames(); } else { @@ -287,7 +322,20 @@ function openSession(wsUrl, onBindingCalled) { return new Promise((resolve, reject) => { if (closed) return reject(new Error("CDP 会话已关闭")); 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 })); }); } @@ -406,7 +454,7 @@ function connectedPhase() { return session ? "connected" : "disconnected"; } -// —— 注入:addBinding + addScriptToEvaluateOnNewDocument + 立即 evaluate —— +// —— 注入:addBinding + addScriptToEvaluateOnNewDocument + 立即 evaluate + Network 监听 —— async function inject(sessionObj) { await sessionObj.send("Runtime.enable"); try { @@ -414,8 +462,266 @@ async function inject(sessionObj) { } catch {} await sessionObj.send("Runtime.addBinding", { name: BINDING_NAME }); await sessionObj.send("Page.enable"); + // 语音注入脚本 await sessionObj.send("Page.addScriptToEvaluateOnNewDocument", { source: INJECT_SCRIPT }); 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 —— @@ -423,17 +729,26 @@ async function connectToTarget(port) { const targets = await listTargets(port); const tgt = pickTarget(targets); if (!tgt) return false; + closeNetworkSessions(); state.target = { id: tgt.id, url: tgt.url, title: tgt.title }; setPhase("connecting"); session = await openSession(tgt.webSocketDebuggerUrl, handleBinding); session.on("Runtime.bindingCalled", handleBinding); + // Token 用量:Network 层拦截 LLM 响应 + bindNetworkHandlers(session); session.ws.on("close", () => { + stopTokenSync(); + closeNetworkSessions(); session = null; setPhase("disconnected"); }); setPhase("injecting"); await inject(session); + startTokenSync(); 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; } @@ -545,6 +860,8 @@ async function connect(cb, opts = {}) { } function disconnect() { + stopTokenSync(); + closeNetworkSessions(); if (session) { try { session.close(); @@ -601,6 +918,56 @@ async function probeStt() { 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 = { status, connect, @@ -608,6 +975,9 @@ module.exports = { onStatus, sttInfo, probeStt, + tokenStatus, + tokenSet, + tokenReset, // 供临时实测脚本复用 _listTargets: listTargets, _pickTarget: pickTarget, diff --git a/electron/lib/zip.js b/electron/lib/zip.js index f37383a..750ebef 100644 --- a/electron/lib/zip.js +++ b/electron/lib/zip.js @@ -47,4 +47,101 @@ function extractZip(buf, destDir) { 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 }; diff --git a/electron/main.js b/electron/main.js index 4315710..997b125 100644 --- a/electron/main.js +++ b/electron/main.js @@ -1,6 +1,6 @@ // main.js —— 主进程:窗口、IPC、GitHub(isomorphic-git)、市场 API。 // 市场地址/密钥【只在主进程】,绝不下发前端。契约见 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 path = require("path"); const os = require("os"); @@ -15,7 +15,11 @@ const sessions = require("./lib/sessions"); const providerSync = require("./lib/provider_sync"); const router = require("./lib/router"); 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 tokenWatch = require("./lib/token_watch"); const proxy = require("./lib/proxy"); const memory = require("./lib/memory"); const { extractZip } = require("./lib/zip"); @@ -235,6 +239,20 @@ app.whenReady().then(async () => { } catch (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 + 还原网络) → 停本地路由 → 销毁托盘 → 退出。 @@ -1323,6 +1341,28 @@ ipcMain.handle("router-fetch-models", async (_e, { baseUrl, key, groupId }) => { 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-run", (e, { kind, params }) => @@ -1335,6 +1375,63 @@ ipcMain.handle("canvas-run", (e, { kind, params }) => ipcMain.handle("canvas-groups", () => canvas.groups()); 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)—— ipcMain.handle("voice-status", () => voice.status()); 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-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-get-config", async () => proxy.getConfig()); diff --git a/electron/preload.js b/electron/preload.js index 43489e9..e1aec52 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -76,6 +76,12 @@ contextBridge.exposeInMainWorld("api", { routerRestoreClaude: () => ipcRenderer.invoke("router-restore-claude"), routerSnippet: () => ipcRenderer.invoke("router-snippet"), 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) => { const handler = (_e, s) => cb(s); ipcRenderer.on("router-changed", handler); @@ -86,6 +92,14 @@ contextBridge.exposeInMainWorld("api", { canvasRun: (kind, params) => ipcRenderer.invoke("canvas-run", { kind, params }), canvasGroups: () => ipcRenderer.invoke("canvas-groups"), 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) => { const h = (_e, p) => cb(p); ipcRenderer.on("canvas-progress", h); @@ -102,6 +116,9 @@ contextBridge.exposeInMainWorld("api", { ipcRenderer.on("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"), proxyGetConfig: () => ipcRenderer.invoke("proxy-get-config"), diff --git a/package.json b/package.json index 2a1bfdd..f79b32d 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,12 @@ { "name": "codex-market", - "version": "1.5.16", + "version": "1.5.21", "description": "TD工具箱 / 技能商店 / 备份工具 —— 跨平台桌面客户端", "author": "Codex Market", "license": "MIT", "main": "electron/bootstrap.js", - "hotContentVersion": "1.5.16", + "hotContentVersion": "1.5.23", + "hotMinAppVersion": "1.5.0", "scripts": { "start": "electron .", "dist": "electron-builder", diff --git a/renderer/app.js b/renderer/app.js index a892130..117081c 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -34,10 +34,12 @@ function busy(btn, on, textWhenBusy) { if (!btn) return; if (on) { btn.dataset.label = btn.textContent; + btn.dataset.busyActive = "1"; btn.textContent = textWhenBusy || "处理中…"; btn.disabled = true; } else { btn.textContent = btn.dataset.label || btn.textContent; + delete btn.dataset.busyActive; btn.disabled = false; } } @@ -54,6 +56,10 @@ if (promoEl) { // —— 标签切换 —— // 激活某个面板(可能有顶部标签,也可能没有——如会话记录/记忆现从插件市场进入) function activatePanel(name) { + if (name !== "token" && tkPollTimer) { + clearInterval(tkPollTimer); + tkPollTimer = null; + } $$(".tab").forEach((t) => t.classList.remove("active")); $$(".panel").forEach((p) => p.classList.remove("active")); const panel = $(`#tab-${name}`); @@ -72,6 +78,9 @@ $$("[data-open-panel]").forEach((btn) => { if (name === "sessions" && !sessLoaded) sessLoad(); if (name === "memory" && !memLoaded) memLoad(); if (name === "voice" && !voiceLoaded) voiceLoad(); + if (name === "team") tmEnter(); + if (name === "prompt" && !pmLoaded) pmEnter(); + if (name === "token") tkEnter(); }); }); // 内置工具面板「← 返回插件市场」 @@ -103,6 +112,8 @@ apic.onLog((msg) => { // 语音输入(CDP 注入 Codex 麦克风按钮) // =========================================================================== let voiceLoaded = false; +let voiceControlsWired = false; +let latestVoiceStatus = null; const PHASE_TEXT = { unknown: "未连接", checking: "检测中…", @@ -117,37 +128,110 @@ const PHASE_TEXT = { recording: "录音中", error: "出错", }; +function isVoiceConnected(phase) { + return phase === "connected" || phase === "transcribing" || phase === "recording"; +} function renderVoiceStatus(s) { - const dot = $("#voice-dot"); - const phaseText = $("#voice-phase-text"); + latestVoiceStatus = s || null; + const dots = ["#voice-dot", "#codex-link-dot", "#tk-dot"].map((sel) => $(sel)).filter(Boolean); + const phaseTexts = ["#voice-phase-text", "#codex-link-phase-text"].map((sel) => $(sel)).filter(Boolean); const detail = $("#voice-detail"); - const connectBtn = $("#voice-connect-btn"); - const discBtn = $("#voice-disconnect-btn"); const phase = s && s.phase ? s.phase : "unknown"; - if (dot) dot.dataset.state = phase; + dots.forEach((dot) => { dot.dataset.state = phase; }); const txt = PHASE_TEXT[phase] || phase; - if (phaseText) phaseText.textContent = txt; - const connected = phase === "connected" || phase === "transcribing" || phase === "recording"; - if (connectBtn) connectBtn.classList.toggle("hidden", connected); - if (discBtn) discBtn.classList.toggle("hidden", !connected); + phaseTexts.forEach((el) => { el.textContent = txt; }); + const tokenPhaseText = $("#tk-phase-text"); + if (tokenPhaseText) tokenPhaseText.textContent = isVoiceConnected(phase) ? "已连接" : txt; + const connected = isVoiceConnected(phase); + $$("[data-voice-link-connect]").forEach((btn) => btn.classList.toggle("hidden", connected)); + $$("[data-voice-link-disconnect]").forEach((btn) => btn.classList.toggle("hidden", !connected)); + $$("[data-voice-enable]").forEach((btn) => { + btn.disabled = connected; + if (!btn.dataset.busyActive) { + btn.textContent = connected + ? (btn.dataset.enabledLabel || "已启用") + : (btn.dataset.enableLabel || "启用"); + } + }); let detailText = txt; if (s && s.lastError) detailText += " " + s.lastError; if (s && s.codexPath) detailText += " 路径:" + s.codexPath; if (s && s.target && s.target.title) detailText += " 目标:" + s.target.title; if (detail) detail.textContent = detailText; } +async function handleVoiceConnect(triggerBtn) { + const isEnable = triggerBtn && triggerBtn.hasAttribute("data-voice-enable"); + const cur = await apic.voiceStatus().catch(() => latestVoiceStatus || {}); + // 若 Codex 在跑但没端口,需用户确认重启 + if (cur.phase === "running_no_port") { + const ok = confirm( + "需要重启 Codex 以启用语音输入调试通道。\n\n当前 Codex 窗口会关闭一次后重新打开,登录态与历史会话保留。\n是否继续?" + ); + if (!ok) return; + busy(triggerBtn, true, "重启中…"); + try { + const s = await apic.voiceConnect({ forceRestart: true }); + renderVoiceStatus(s); + if (s.phase === "error") toast(s.lastError || "连接失败", "err"); + } catch (e) { + toast("连接失败:" + (e && e.message ? e.message : e), "err"); + } finally { + busy(triggerBtn, false); + renderVoiceStatus(latestVoiceStatus || {}); + } + return; + } + busy(triggerBtn, true, isEnable ? "启用中…" : "连接中…"); + try { + const s = await apic.voiceConnect({}); + renderVoiceStatus(s); + if (s.phase === "running_no_port") { + toast(isEnable ? "Codex 在运行,需重启以启用调试端口,请再次点击启用" : "Codex 在运行,需重启以启用调试端口,请再次点击连接", ""); + } else if (s.phase === "error") { + toast(s.lastError || "连接失败", "err"); + } else if (s.phase === "connected") { + toast(isEnable ? "语音输入已启用,Codex 输入框旁应出现 🎤 按钮" : "已连接 Codex"); + } + } catch (e) { + toast("连接失败:" + (e && e.message ? e.message : e), "err"); + } finally { + busy(triggerBtn, false); + renderVoiceStatus(latestVoiceStatus || {}); + } +} +async function handleVoiceDisconnect(triggerBtn) { + busy(triggerBtn, true, "断开中…"); + try { + const s = await apic.voiceDisconnect(); + renderVoiceStatus(s); + } finally { + busy(triggerBtn, false); + renderVoiceStatus(latestVoiceStatus || {}); + } +} +function wireVoiceControls() { + if (voiceControlsWired) return; + voiceControlsWired = true; + document.addEventListener("click", async (e) => { + const connectBtn = e.target.closest("[data-voice-link-connect], [data-voice-enable]"); + if (connectBtn) { + await handleVoiceConnect(connectBtn); + return; + } + const disconnectBtn = e.target.closest("[data-voice-link-disconnect]"); + if (disconnectBtn) await handleVoiceDisconnect(disconnectBtn); + }); + apic.voiceStatus().then(renderVoiceStatus).catch(() => {}); + apic.onVoiceStatus((s) => renderVoiceStatus(s)); +} async function voiceLoad() { if (voiceLoaded) return; voiceLoaded = true; - let unsub = null; try { const s0 = await apic.voiceStatus(); renderVoiceStatus(s0); } catch {} - unsub = apic.onVoiceStatus((s) => renderVoiceStatus(s)); - const connectBtn = $("#voice-connect-btn"); - const discBtn = $("#voice-disconnect-btn"); const refreshSttBtn = $("#voice-refresh-stt"); async function loadSttInfo() { @@ -166,52 +250,6 @@ async function voiceLoad() { } loadSttInfo(); - connectBtn.addEventListener("click", async () => { - const cur = await apic.voiceStatus().catch(() => ({})); - // 若 Codex 在跑但没端口,需用户确认重启 - if (cur.phase === "running_no_port") { - const ok = confirm( - "需要重启 Codex 以启用语音输入调试通道。\n\n当前 Codex 窗口会关闭一次后重新打开,登录态与历史会话保留。\n是否继续?" - ); - if (!ok) return; - busy(connectBtn, true, "重启中…"); - try { - const s = await apic.voiceConnect({ forceRestart: true }); - renderVoiceStatus(s); - if (s.phase === "error") toast(s.lastError || "连接失败", "err"); - } catch (e) { - toast("连接失败:" + (e && e.message ? e.message : e), "err"); - } finally { - busy(connectBtn, false); - } - return; - } - busy(connectBtn, true, "连接中…"); - try { - const s = await apic.voiceConnect({}); - renderVoiceStatus(s); - if (s.phase === "running_no_port") { - toast("Codex 在运行,需重启以启用调试端口,请再次点击连接", ""); - } else if (s.phase === "error") { - toast(s.lastError || "连接失败", "err"); - } else if (s.phase === "connected") { - toast("已连接,Codex 输入框旁应出现 🎤 按钮"); - } - } catch (e) { - toast("连接失败:" + (e && e.message ? e.message : e), "err"); - } finally { - busy(connectBtn, false); - } - }); - discBtn.addEventListener("click", async () => { - busy(discBtn, true, "断开中…"); - try { - const s = await apic.voiceDisconnect(); - renderVoiceStatus(s); - } finally { - busy(discBtn, false); - } - }); if (refreshSttBtn) { refreshSttBtn.addEventListener("click", () => loadSttInfo()); } @@ -1290,6 +1328,26 @@ document.addEventListener("keydown", (e) => { // =========================================================================== let routerState = null; let grpEditing = null; +let grp1mMarks = new Set(); // 当前编辑分组里被标记为「1M 长上下文」的模型名 + +// 渲染 1M 勾选清单:把拉取到的模型(并入已保存标记)逐个列出,勾选态来自 grp1mMarks +function grpRender1mList(models) { + const all = Array.from(new Set([...(models || []), ...grp1mMarks])); + const box = $("#grp-1m-list"); + if (!all.length) { $("#grp-1m-field").style.display = "none"; box.innerHTML = ""; return; } + $("#grp-1m-field").style.display = ""; + box.innerHTML = all + .map((m) => ``) + .join(""); + // 勾选变化实时同步进 grp1mMarks + box.querySelectorAll('input[type="checkbox"]').forEach((cb) => { + cb.addEventListener("change", () => { + if (cb.checked) grp1mMarks.add(cb.value); + else grp1mMarks.delete(cb.value); + }); + }); +} + function renderRouter(st) { routerState = st; @@ -1311,30 +1369,54 @@ function renderRouter(st) { box.innerHTML = `
还没有分组,点右上「+ 新增分组」添加一个中转 API
`; return; } - box.innerHTML = st.groups - .map((g) => { - const badges = - (g.id === st.activeId ? '当前' : "") + - (g.isDefault ? ' 默认·不可删' : ""); - const actions = g.isDefault - ? '切回这里 = 直连你的账号' - : ``; - return ` + // 按协议族分两栏:Codex/GPT(OpenAI 协议)与 Claude Code(Anthropic 协议)各一个单选, + // 两栏互不影响 —— 可同时激活一个 GPT 分组 + 一个 Claude 分组一起用。 + const isClaude = (g) => g.mode === "claude"; + const oai = st.groups.filter((g) => !isClaude(g)); + const claude = st.groups.filter((g) => isClaude(g)); + const itemHtml = (g, radioName, activeId) => { + // chat 模式(国产:DeepSeek/GLM/Kimi/通义)需协议转换,无法直连 Codex,必须开本地路由才能用 + const needsRouter = !g.isDefault && g.mode === "chat"; + const badges = + (g.id === activeId ? '当前' : "") + + (g.isDefault ? ' 默认·不可删' : "") + + (needsRouter ? ' 需路由' : ""); + const actions = g.isDefault + ? '切回这里 = 直连你的账号' + : ``; + // chat 分组被选中但路由没在跑 → 醒目提示去启动 + const routerHint = needsRouter && g.id === activeId && !st.running + ? '
⚠️ 该分组需协议转换,请先在上方「启动」本地路由,否则 Codex 无法使用
' + : ""; + return `
- +
${esc(g.name)} ${badges}${g.codexImage ? ' 🎨 生图' : ""}${g.stt ? ' 🎤 转文字' + (g.sttModel ? " " + esc(g.sttModel) : "") + '' : ""}
-
${esc(g.baseUrl)}${g.model ? " · 模型 " + esc(g.model) : ""}${!g.isDefault && g.mode === "chat" ? " · 🔄转换" : ""}${g.hasKey ? "" : " · ⚠️无密钥"}
+
${esc(g.baseUrl)}${g.model ? " · 模型 " + esc(g.model) : ""}${needsRouter ? " · 🔄转换" : ""}${g.hasKey ? "" : " · ⚠️无密钥"}
+ ${routerHint}
${actions}
`; - }) - .join(""); + }; + const colHtml = (title, hint, list, radioName, activeId) => ` +
+
${title}${hint}
+ ${list.length ? list.map((g) => itemHtml(g, radioName, activeId)).join("") : '
该类型下暂无分组
'} +
`; + box.innerHTML = + colHtml("Codex / GPT", "OpenAI 协议 · Responses / Chat", oai, "active-grp-oai", st.activeId) + + colHtml("Claude Code", "Anthropic 协议 · /v1/messages", claude, "active-grp-claude", st.activeClaudeId); box.querySelectorAll("[data-active]").forEach((el) => el.addEventListener("change", async () => { const r = await apic.routerSetActive(el.dataset.active); + const g = (r.groups || []).find((x) => x.id === el.dataset.active); renderRouter(r); - if (r._needsRestart) toast("已切换,重启 Codex 后生效(配置有变)", "ok"); + if (g && g.mode === "claude") { + if (r._claudeSkipped) toast("已选 Claude 分组,但未写入:" + (r._claudeReason || "缺地址或密钥"), "warn"); + else if (r._mode === "direct") toast("已切 Claude Code 分组 ✅ 新开 claude 会话生效", "ok"); + else toast("已秒切 Claude Code 分组 ✅ 无需重启", "ok"); + } else if (r._needsRestart) toast("已切换,重启 Codex 后生效(配置有变)", "ok"); else if (r._mode === "direct") toast("已切回默认(直连你的账号)✅", "ok"); else toast("已秒切分组 ✅ 无需重启 Codex", "ok"); }) @@ -1374,12 +1456,49 @@ function openGrpModal(g) { sel.classList.add("hidden"); sel.innerHTML = ''; $("#grp-model-hint").textContent = ""; + // 1M 标记:载入该分组已保存的名单;有则先渲染出来(未拉取模型也能看到已勾选的),否则隐藏 + grp1mMarks = new Set((g && Array.isArray(g.oneMillionModels)) ? g.oneMillionModels : []); + if (grp1mMarks.size) grpRender1mList([...grp1mMarks]); + else { $("#grp-1m-field").style.display = "none"; $("#grp-1m-list").innerHTML = ""; } + grpUpdateClaudeDesk(); $("#grp-modal").classList.remove("hidden"); } function closeGrpModal() { $("#grp-modal").classList.add("hidden"); } +// 刷新「接入 Claude 桌面 App」区:按分组协议启用/禁用 + 显示接入/备份状态 +async function grpUpdateClaudeDesk() { + const block = $("#grp-claudedesk"); + if (!block) return; + const isClaude = $("#grp-mode").value === "claude"; + const hint = $("#grp-cd-hint"); + const applyBtn = $("#grp-cd-apply"); + const exportBtn = $("#grp-cd-export"); + const copyBtn = $("#grp-cd-copy"); + // 未保存的新分组不能接入(需要 groupId) + const canApply = isClaude && !!grpEditing; + [applyBtn, exportBtn, copyBtn].forEach((b) => { if (b) b.disabled = !canApply; }); + if (!isClaude) { + hint.textContent = "⚠ Claude 桌面 App 只接受 Anthropic 协议分组——请把上面「接口类型」改成 Claude Code 再接入。"; + } else if (!grpEditing) { + hint.textContent = "先保存这个分组,再回来一键接入 Claude 桌面 App。"; + } else { + hint.textContent = "把该分组一键写入 Claude 桌面 App,重启 App 即用你的中转。接入前会自动备份官方配置。"; + } + // 状态行 + const statusEl = $("#grp-cd-status"); + try { + if (!apic.claudedeskStatus) { statusEl.textContent = ""; return; } + const s = await apic.claudedeskStatus(); + if (!s || !s.supported) { statusEl.textContent = "当前系统暂不支持(仅 macOS / Windows)。"; return; } + const parts = []; + parts.push(s.currentApplied ? "🟢 Claude 桌面当前:已接入第三方中转" : "⚪ Claude 桌面当前:官方登录"); + if (s.hasBackup) parts.push("已备份官方配置,可一键还原"); + statusEl.textContent = parts.join(" · "); + } catch { statusEl.textContent = ""; } +} + document.querySelectorAll("[data-ctx]").forEach((b) => b.addEventListener("click", () => { $("#grp-ctx").value = b.dataset.ctx; @@ -1387,6 +1506,54 @@ document.querySelectorAll("[data-ctx]").forEach((b) => ); $("#grp-add").addEventListener("click", () => openGrpModal(null)); $("#grp-modal-close").addEventListener("click", closeGrpModal); + +// 接口类型改变时,实时刷新「接入 Claude 桌面」区的可用性 +$("#grp-mode").addEventListener("change", grpUpdateClaudeDesk); + +// —— 接入 Claude 桌面 App 按钮 —— +$("#grp-cd-apply").addEventListener("click", async (ev) => { + if (!grpEditing) { toast("请先保存分组", "warn"); return; } + const btn = ev.currentTarget; + busy(btn, true, "接入中…"); + try { + const r = await apic.claudedeskApply(grpEditing.id); + if (!r || !r.ok) throw new Error((r && r.error) || "接入失败"); + toast("已接入 Claude 桌面 App ✅ 重启 App 生效" + (r.has1m ? "(已带 1M 头)" : "") + "。官方配置已备份,可一键还原。", "ok"); + grpUpdateClaudeDesk(); + } catch (e) { + toast("接入失败:" + (e.message || e), "err"); + } finally { busy(btn, false); } +}); +$("#grp-cd-export").addEventListener("click", async () => { + if (!grpEditing) { toast("请先保存分组", "warn"); return; } + try { + const r = await apic.claudedeskExport(grpEditing.id, "json"); + if (r && r.ok) toast("已导出到 " + r.path, "ok"); + else if (r && r.canceled) { /* 取消,静默 */ } + else throw new Error((r && r.error) || "导出失败"); + } catch (e) { toast("导出失败:" + (e.message || e), "err"); } +}); +$("#grp-cd-copy").addEventListener("click", async () => { + if (!grpEditing) { toast("请先保存分组", "warn"); return; } + try { + const r = await apic.claudedeskSnippet(grpEditing.id); + if (r && r.ok) toast("配置已复制到剪贴板", "ok"); + else throw new Error((r && r.error) || "复制失败"); + } catch (e) { toast("复制失败:" + (e.message || e), "err"); } +}); +$("#grp-cd-restore").addEventListener("click", async (ev) => { + const btn = ev.currentTarget; + busy(btn, true, "还原中…"); + try { + const r = await apic.claudedeskRestore(); + if (!r || !r.ok) throw new Error((r && r.error) || "还原失败"); + toast(r.message || "已还原官方配置,请重启 Claude 桌面 App", "ok"); + grpUpdateClaudeDesk(); + } catch (e) { + toast("还原失败:" + (e.message || e), "err"); + } finally { busy(btn, false); } +}); + $("#grp-modal").addEventListener("click", (e) => { if (e.target === $("#grp-modal")) closeGrpModal(); }); @@ -1406,6 +1573,7 @@ $("#grp-save").addEventListener("click", async () => { codexImage: $("#grp-codex-image").checked, stt: $("#grp-stt").checked, sttModel: $("#grp-stt").checked ? ($("#grp-stt-model").value.trim() || "whisper-1") : "", + oneMillionModels: [...grp1mMarks], }; if (grpEditing) group.id = grpEditing.id; const key = $("#grp-key").value; @@ -1417,13 +1585,21 @@ $("#grp-save").addEventListener("click", async () => { const st = await apic.routerUpsertGroup(group); renderRouter(st); closeGrpModal(); - // 保存后根据是否已把直连中转配置写进 Codex 给出对应提示 - if (st._directApplied) { - toast("已保存并写入 Codex 配置 ✅ 重启 Codex 即用当前分组", "ok"); - } else if (st._directError) { - toast("已保存,但写入 Codex 配置失败:" + st._directError, "err"); - } else if (st._directSkipped) { - toast("已保存分组。" + (st._directReason || "该分组需启用本地路由或补全密钥后才能接入 Codex"), "warn"); + // 组合提示:Codex 直连结果 + Claude Code 接入结果 + let codexMsg = ""; + if (st._directApplied) codexMsg = "已写入 Codex 配置(重启 Codex 即用)"; + else if (st._directError) codexMsg = "写入 Codex 失败:" + st._directError; + else if (st._directSkipped) codexMsg = st._directReason || "未写入 Codex(需启用路由/补全密钥)"; + let claudeMsg = ""; + if (st._claudeApplied) claudeMsg = "已接入 Claude Code"; + else if (st._claudeError) claudeMsg = "接入 Claude Code 失败:" + st._claudeError; + else if (st._claudeSkipped) claudeMsg = "Claude Code 未接入:" + (st._claudeReason || "缺地址或密钥"); + else if (st._claudeRestored) claudeMsg = "已取消 Claude Code 接入"; + const parts = [codexMsg, claudeMsg].filter(Boolean); + const hasErr = st._directError || st._claudeError; + const hasWarn = st._directSkipped || st._claudeSkipped; + if (parts.length) { + toast("已保存分组 ✅ " + parts.join(";"), hasErr ? "err" : hasWarn ? "warn" : "ok"); } else { toast("已保存分组 ✅", "ok"); } @@ -1452,6 +1628,7 @@ $("#grp-fetch-models").addEventListener("click", async (ev) => { // 若当前已有模型值,选中它 if ($("#grp-model").value) sel.value = $("#grp-model").value; sel.classList.remove("hidden"); + grpRender1mList(models); // 渲染 1M 勾选清单(合并已保存标记) hint.textContent = `共 ${models.length} 个模型,选一个或保持手填`; } catch (e) { hint.textContent = "获取失败:" + (e.message || e) + "(可手动填模型)"; @@ -1845,12 +2022,11 @@ function cvNodeBody(kind) { return ` - +
-
- +
@@ -1892,11 +2064,9 @@ function cvNodeBody(kind) { - + +
@@ -1906,15 +2076,14 @@ function cvNodeBody(kind) { - + 接口时长仅支持 5 / 10 / 15 秒
⚙️ 高级 -
- - -
- +
@@ -1946,24 +2115,36 @@ function cvNodeBody(kind) {
- +
+ +
+
-
多参考图使用 grok-imagine-video + 720p;1.5 的 1080p/15s 需要首帧图
+
首帧图生视频使用 grok-video-1.5;多参考图使用 grok-imagine-video + 720p
⚙️ 高级
@@ -2451,7 +2632,7 @@ function cvAddNode(kind) { cvSetupNode(id); // 唯一化 datalist + 回填分组下拉 } -// 切换视频模式:按 mode 显隐 首帧/尾帧/多参考图/参考视频 四组素材控件 +// 切换视频模式:按 mode 显隐首帧、尾帧和多参考图素材控件 function cvSyncVideoMode(nodeEl) { const sel = nodeEl.querySelector(".cv-field-vmode"); if (!sel) return; @@ -2460,9 +2641,8 @@ function cvSyncVideoMode(nodeEl) { first: mode === "first" || mode === "firstlast", last: mode === "firstlast", refs: mode === "reference", - refvideo: mode === "video", }; - ["first", "last", "refs", "refvideo"].forEach((slot) => { + ["first", "last", "refs"].forEach((slot) => { const box = nodeEl.querySelector(".cv-vmat-" + slot); if (box) box.classList.toggle("hidden", !show[slot]); }); @@ -2563,7 +2743,7 @@ function cvOpenPreview(previewEl) { const media = previewEl.querySelector("img.cv-media, video.cv-media"); if (!media || !media.src) return; const isVideo = media.tagName === "VIDEO"; - const src = media.dataset.sourceUrl || media.src; + const src = media.src || media.dataset.sourceUrl; const modal = modalOverlay.querySelector(".modal"); const body = $("#modal-body"); $("#modal-title").textContent = isVideo ? "视频预览" : "图片预览"; @@ -2577,15 +2757,39 @@ function cvOpenPreview(previewEl) { modalOverlay.classList.remove("hidden"); } +function cvDataUrlToBlob(dataUrl) { + const m = /^data:([^;,]+)?(;base64)?,(.*)$/is.exec(dataUrl); + if (!m) throw new Error("不是合法的 data URL"); + const mime = m[1] || "application/octet-stream"; + if (m[2]) { + // base64 编码 → 直接 atob 解码(+ 是合法 base64 字符,不要 decodeURIComponent) + const bin = atob(m[3]); + const u8 = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) u8[i] = bin.charCodeAt(i); + return new Blob([u8], { type: mime }); + } + // 非 base64 的 data URL(百分号编码) + const raw = decodeURIComponent(m[3].replace(/\+/g, " ")); + return new Blob([raw], { type: mime }); +} + async function cvDownloadPreview(previewEl) { if (!previewEl) return; const media = previewEl.querySelector("img.cv-media, video.cv-media"); if (!media || !media.src) return; const isVideo = media.tagName === "VIDEO"; - const src = media.dataset.sourceUrl || media.src; + const src = media.src || media.dataset.sourceUrl; try { - const res = await fetch(src); - const blob = await res.blob(); + let blob; + if (/^data:/i.test(src)) { + // data URL 直接解析,不走 fetch(避免 CSP 拦截 + 节省内存) + blob = cvDataUrlToBlob(src); + } else { + // 远程 URL 仍需 fetch + const res = await fetch(src); + if (!res.ok) throw new Error("HTTP " + res.status); + blob = await res.blob(); + } const ext = cvMediaExt(blob.type, isVideo ? "video" : "image"); const url = URL.createObjectURL(blob); const a = document.createElement("a"); @@ -2713,13 +2917,8 @@ async function cvRunNode(nodeId, btn) { let mode = val(".cv-field-vmode") || "first"; if (mode === "image") mode = "first"; // 兼容旧值 const model = val(".cv-field-model"); - if (!model) return cvSetErr(nodeEl, "请先填写视频模型"); const endpointPath = val(".cv-field-endpoint"); const duration = val(".cv-field-duration"); - const audioEl = nodeEl.querySelector(".cv-field-audio"); - const wmEl = nodeEl.querySelector(".cv-field-watermark"); - const cfEl = nodeEl.querySelector(".cv-field-camerafixed"); - const seed = val(".cv-field-seed"); const mats = CANVAS.videoMaterials[nodeId] || {}; const matUrl = (slot) => { const el = nodeEl.querySelector(`.cv-vmat-url[data-slot="${slot}"]`); @@ -2729,14 +2928,14 @@ async function cvRunNode(nodeId, btn) { params = { mode, prompt: ownText || up.text, - model, ratio: val(".cv-field-ratio"), - resolution: val(".cv-field-vsize"), - generateAudio: !!(audioEl && audioEl.checked), - watermark: !!(wmEl && wmEl.checked), - cameraFixed: !!(cfEl && cfEl.checked), + quality: val(".cv-field-vquality"), + referenceField: val(".cv-field-refsource") || "images", }; - if (seed) params.seed = seed; + if (model) params.model = model; + if (!(params.prompt && params.prompt.trim())) { + return cvSetErr(nodeEl, "Seedance 文生/图生视频都必须填写提示词"); + } // 按模式收集素材:优先手填URL,其次上传缓存(dataURL),首帧再兜底上游图 if (mode === "first" || mode === "firstlast") { const first = matUrl("first") || mats.first || up.image || ""; @@ -2753,19 +2952,11 @@ async function cvRunNode(nodeId, btn) { if (!refs.length) return cvSetErr(nodeEl, "多参考图模式请至少上传 1 张参考图"); params.referenceImages = refs; } - if (mode === "video") { - const v = matUrl("video"); - if (!/^https?:\/\//i.test(v)) return cvSetErr(nodeEl, "参考视频请手填公网视频URL(http(s))"); - params.referenceVideo = v; - } - if (mode === "text" && !(params.prompt && params.prompt.trim())) { - return cvSetErr(nodeEl, "文生视频请填写提示词"); - } if (duration) params.seconds = duration; if (endpointPath) params.endpointPath = endpointPath; } else if (kind === "grokVideo") { const mode = val(".cv-field-vmode") || "text"; - const model = val(".cv-field-model") || "grok-imagine-video"; + const model = val(".cv-field-model") || "grok-video-1.5"; const endpointPath = val(".cv-field-endpoint"); const duration = val(".cv-field-duration"); const mats = CANVAS.videoMaterials[nodeId] || {}; @@ -2783,11 +2974,12 @@ async function cvRunNode(nodeId, btn) { prompt: ownText || up.text, model, resolution: val(".cv-field-vsize") || "720p", + aspectRatio: val(".cv-field-ratio") || "16:9", }; if (!params.prompt) return cvSetErr(nodeEl, "请填写 Grok 视频提示词,或连接一个文本节点作为输入"); if ( mode === "text" && - /grok-imagine-video-1\.5/i.test(model) && + /grok-(?:imagine-)?video-1\.5/i.test(model) && params.resolution === "1080p" && String(duration) === "15" ) { @@ -2804,7 +2996,7 @@ async function cvRunNode(nodeId, btn) { params.referenceImages = refs; params.model = "grok-imagine-video"; params.resolution = "720p"; - params.aspectRatio = "16:9"; + params.aspectRatio = params.aspectRatio || "16:9"; } if (duration) params.seconds = duration; if (endpointPath) params.endpointPath = endpointPath; @@ -2969,7 +3161,693 @@ document.addEventListener("keydown", (e) => { } }); +// ============================================================================ +// 员工团队:需求 → AI 员工团队 → 打包 zip +// ============================================================================ +const TEAM = { tool: "codex", team: null, groups: [], activeGroupId: "", loaded: false }; + +async function tmEnter() { + if (!TEAM.loaded) { + TEAM.loaded = true; + tmSetView(TEAM.team ? "content" : "empty"); // 归一初始层,避免 empty/loading/content 叠在一起 + await tmLoadGroups(); + } +} + +// 拉取中转分组,填充分组下拉 +async function tmLoadGroups() { + try { + if (!apic.canvasGroups) throw new Error("no api"); + const r = await apic.canvasGroups(); + if (r && r.ok) { + TEAM.groups = Array.isArray(r.groups) ? r.groups : []; + TEAM.activeGroupId = r.activeId || (TEAM.groups[0] && TEAM.groups[0].id) || ""; + } else { + TEAM.groups = []; + TEAM.activeGroupId = ""; + } + } catch (e) { + TEAM.groups = []; + TEAM.activeGroupId = ""; + } + const sel = document.getElementById("tm-group"); + if (sel) { + sel.innerHTML = TEAM.groups.length + ? TEAM.groups.map((g) => ``).join("") + : ``; + sel.value = TEAM.activeGroupId || ""; + sel.onchange = () => { TEAM.activeGroupId = sel.value; }; + } + tmUpdateGenBtn(); +} + +// 拉取所选分组的模型列表,填充模型下拉 +async function tmFetchModels() { + const btn = document.getElementById("tm-fetch"); + const sel = document.getElementById("tm-model"); + const groupId = TEAM.activeGroupId || undefined; + busy(btn, true, "…"); + try { + if (!apic.canvasModels) throw new Error("拉取模型能力尚未就绪(后端未接入)"); + const r = await apic.canvasModels(groupId); + if (!r || !r.ok) throw new Error((r && r.error) || "拉取失败"); + const models = Array.isArray(r.models) ? r.models : []; + if (sel) { + sel.innerHTML = models.length + ? models.map((m) => ``).join("") + : ``; + sel.onchange = tmUpdateGenBtn; + } + document.getElementById("tm-model-hint").textContent = `已拉取 ${models.length} 个模型,选一个用于分析需求`; + tmUpdateGenBtn(); + toast(`已拉取 ${models.length} 个模型`, "ok"); + } catch (e) { + toast("拉取模型失败:" + (e.message || e), "err"); + } finally { + busy(btn, false); + } +} + +function tmUpdateGenBtn() { + const btn = document.getElementById("tm-gen"); + if (btn.dataset.busy === "1") return; // 生成中不改动禁用态(由 tmGenerate 收尾复位) + const req = document.getElementById("tm-req").value.trim(); + const model = document.getElementById("tm-model").value; + btn.disabled = !(req && model); +} + +function tmSetView(w) { + // 用内联 display 直接控制,避免 [hidden] 被 .tm-empty/.tm-loading 的 display:flex 覆盖 + const views = { empty: "tm-empty", loading: "tm-loading", content: "tm-content", error: "tm-error" }; + Object.entries(views).forEach(([k, id]) => { + const el = document.getElementById(id); + if (!el) return; + const on = k === w; + el.hidden = !on; + el.style.display = on ? (k === "content" ? "block" : "flex") : "none"; + }); +} + +async function tmGenerate() { + const requirement = document.getElementById("tm-req").value.trim(); + const model = document.getElementById("tm-model").value; + if (!requirement || !model) return; + const genBtn = document.getElementById("tm-gen"); + document.getElementById("tm-download").hidden = true; + // 生成期间禁用按钮并改文案,防止误触重复提交 + genBtn.disabled = true; + genBtn.dataset.busy = "1"; + genBtn.textContent = "正在生成员工团队…"; + tmSetView("loading"); + try { + const r = await apic.teamGenerate({ + requirement, + tool: TEAM.tool, + groupId: TEAM.activeGroupId || undefined, + model, + agentModel: document.getElementById("tm-agent-model").value.trim(), + }); + if (!r || !r.ok) throw new Error((r && r.error) || "生成失败"); + TEAM.team = r.result; + tmRenderTeam(r.result); + tmSetView("content"); + document.getElementById("tm-download").hidden = false; + } catch (err) { + document.getElementById("tm-error").innerHTML = "⚠ " + esc(err.message || String(err)); + tmSetView("error"); + } finally { + genBtn.textContent = "生成员工团队"; + genBtn.dataset.busy = ""; + tmUpdateGenBtn(); + } +} + +function tmRenderTeam(team) { + const head = document.getElementById("tm-team-head"); + const stack = (team.stack || []).map((s) => `${esc(s)}`).join(""); + head.innerHTML = + `
${esc(team.project_name || "AI 员工团队")}
` + + (team.summary ? `
${esc(team.summary)}
` : "") + + (stack ? `
${stack}
` : ""); + const list = document.getElementById("tm-team-list"); + list.innerHTML = ""; + list.appendChild(tmConvCard(team)); + if (team.orchestrator) list.appendChild(tmAgentCard(team.orchestrator, true)); + (team.employees || []).forEach((e) => list.appendChild(tmAgentCard(e, false))); +} + +function tmConvCard(team) { + const owner = (team.employees || []).find((e) => e.id === team.contract_owner); + const li = (arr) => (arr || []).map((x) => `
  • ${esc(x)}
  • `).join(""); + const div = document.createElement("div"); + div.className = "tm-conv-card"; + div.innerHTML = + `
    团队协同规范
    +
    接口契约 owner:${esc(owner ? owner.name : team.contract_owner || "—")} · 全部规范已写进每个 agent 的 prompt,并随包附 CONVENTIONS.md
    +
    +
    统一产出格式
      ${li(team.output_format)}
    +
    完成定义 (DoD)
      ${li(team.definition_of_done)}
    +
    Git / 文件边界
      ${li(team.git_rules)}
    + ${(team.security_checklist || []).length + ? `
    安全 / 权限清单
      ${li(team.security_checklist)}
    ` + : `
    任务交接协议
      ${li(team.handoff_protocol)}
    `} +
    `; + return div; +} + +function tmAgentCard(a, isLead) { + const div = document.createElement("div"); + div.className = "tm-emp-card" + (isLead ? " lead" : ""); + const resp = (a.responsibilities || []).map((r) => `
  • ${esc(r)}
  • `).join(""); + const cons = (a.constraints || []).map((c) => `${esc(c)}`).join(""); + let extra = ""; + if (isLead && (a.workflow || []).length) { + extra = `
    调度流程
      ${a.workflow.map((w) => `
    1. ${esc(w)}
    2. `).join("")}
    `; + } else if (!isLead) { + const collab = (a.collaborators || []).map((c) => `${esc(c)}`).join(""); + const rows = []; + if (collab) rows.push(`
    协作${collab}
    `); + if (a.handoff_in) rows.push(`
    接收${esc(a.handoff_in)}
    `); + if (a.handoff_out) rows.push(`
    交付${esc(a.handoff_out)}
    `); + if (rows.length) extra = `
    ${rows.join("")}
    `; + } + div.innerHTML = + `
    + ${esc(a.name || "")} + ${isLead ? '总控' : ""} + ${esc(a.id || "")} +
    +
    ${esc(a.duty || "")}
    + ${resp ? `
    职责
      ${resp}
    ` : ""} + ${extra} + ${cons ? `
    约束
    ${cons}
    ` : ""} +
    + + +
    `; + tmWirePreview(div, a); + return div; +} + +function tmWirePreview(div, a) { + const ext = TEAM.tool === "claude" ? ".md" : ".toml"; + const toggle = div.querySelector(".tm-file-toggle"); + const view = div.querySelector(".tm-file-view"); + const pre = div.querySelector(".tm-file-pre"); + const nameEl = div.querySelector(".tm-file-name"); + let loaded = false; + toggle.textContent = `预览 ${ext} 全文`; + toggle.addEventListener("click", async () => { + const show = view.hidden; + view.hidden = !show; + toggle.textContent = `${show ? "收起" : "预览"} ${ext} 全文`; + if (show && !loaded) { + pre.textContent = "加载中…"; + try { + const r = await apic.teamPreview({ result: TEAM.team, tool: TEAM.tool, id: a.id }); + if (!r || !r.ok) throw new Error((r && r.error) || "预览失败"); + nameEl.textContent = r.filename; + pre.textContent = r.content; + loaded = true; + } catch (err) { + pre.textContent = "⚠ " + (err.message || err); + } + } + }); + div.querySelector(".tm-file-copy").addEventListener("click", () => { + if (pre.textContent && loaded) navigator.clipboard.writeText(pre.textContent).then(() => toast("已复制", "ok")); + }); +} + +async function tmDownload() { + if (!TEAM.team) return; + const btn = document.getElementById("tm-download"); + busy(btn, true, "打包中…"); + try { + const r = await apic.teamPack({ result: TEAM.team, tool: TEAM.tool }); + if (r && r.ok) toast("已保存到 " + r.path, "ok"); + else if (r && r.canceled) { /* 用户取消,静默 */ } + else throw new Error((r && r.error) || "打包失败"); + } catch (err) { + toast("打包失败:" + (err.message || err), "err"); + } finally { + busy(btn, false); + } +} + +// —— 事件绑定 —— +(function tmWireEvents() { + // 入口在「插件市场」的工具卡片(data-open-panel="team"),懒加载由该处理器触发 tmEnter + const req = document.getElementById("tm-req"); + if (req) req.addEventListener("input", tmUpdateGenBtn); + const toolChips = document.getElementById("tm-tool"); + if (toolChips) { + toolChips.querySelectorAll(".tm-chip").forEach((chip) => { + chip.addEventListener("click", () => { + toolChips.querySelectorAll(".tm-chip").forEach((c) => c.classList.remove("active")); + chip.classList.add("active"); + TEAM.tool = chip.dataset.tool; + const isClaude = TEAM.tool === "claude"; + document.getElementById("tm-tool-hint").textContent = isClaude + ? "生成纯 Claude 包:.claude/agents/*.md + CLAUDE.md 总控入口" + : "生成 .toml 员工 + 总控(调度 / 审核)+ AGENTS.md"; + const hint = document.getElementById("tm-agent-hint"); + const inp = document.getElementById("tm-agent-model"); + inp.placeholder = isClaude ? "留空=继承会话模型(推荐),或填 sonnet / opus / haiku" : "留空=用上面的分析模型,如 gpt-4o"; + hint.textContent = isClaude + ? "Claude 只认 sonnet / opus / haiku / claude-*;留空则省略 model 字段=自动继承" + : "写进每个员工的 model 字段,改成你运行器支持的模型名"; + // 已生成的团队:切工具后重新渲染(预览扩展名随之变) + if (TEAM.team) tmRenderTeam(TEAM.team); + }); + }); + } + const fetchBtn = document.getElementById("tm-fetch"); + if (fetchBtn) fetchBtn.addEventListener("click", tmFetchModels); + const genBtn = document.getElementById("tm-gen"); + if (genBtn) genBtn.addEventListener("click", tmGenerate); + const dlBtn = document.getElementById("tm-download"); + if (dlBtn) dlBtn.addEventListener("click", tmDownload); +})(); + +// =========================================================================== +// ②.⑥ 提示词提取(图片 → 反推生图提示词) +// =========================================================================== +let pmLoaded = false; +const PM = { + groups: [], + activeGroupId: "", + models: [], + imageDataUrl: null, +}; + +function pmUpdateAnalyzeBtn() { + const btn = document.getElementById("pm-analyze"); + if (!btn) return; + const model = document.getElementById("pm-model").value; + btn.disabled = !(PM.imageDataUrl && model); +} + +async function pmEnter() { + if (pmLoaded) return; + pmLoaded = true; + // 加载分组 + try { + const r = await apic.promptGroups(); + if (r && r.ok) PM.groups = r.groups || []; + } catch { PM.groups = []; } + const sel = document.getElementById("pm-group"); + if (sel) { + sel.innerHTML = PM.groups.length + ? PM.groups.map((g) => ``).join("") + : ``; + sel.value = PM.activeGroupId || ""; + sel.onchange = () => { PM.activeGroupId = sel.value; }; + } + pmUpdateAnalyzeBtn(); +} + +// 拉取所选分组的模型 +async function pmFetchModels() { + const btn = document.getElementById("pm-fetch"); + const sel = document.getElementById("pm-model"); + const groupId = PM.activeGroupId || undefined; + busy(btn, true, "…"); + try { + const r = await apic.promptModels(groupId); + if (!r || !r.ok) throw new Error((r && r.error) || "拉取失败"); + PM.models = Array.isArray(r.models) ? r.models : []; + if (sel) { + sel.innerHTML = PM.models.length + ? PM.models.map((m) => ``).join("") + : ``; + sel.onchange = pmUpdateAnalyzeBtn; + } + document.getElementById("pm-model-hint").textContent = `已拉取 ${PM.models.length} 个模型(需要视觉/多模态)`; + pmUpdateAnalyzeBtn(); + toast(`已拉取 ${PM.models.length} 个模型`, "ok"); + } catch (e) { + toast("拉取模型失败:" + (e.message || e), "err"); + } finally { + busy(btn, false); + } +} + +// 拖拽 / 选择图片 +function pmHandleFile(file) { + if (!file) return; + if (file.type.startsWith("video/")) { + pmGrabVideoFrame(file); + return; + } + if (!file.type.startsWith("image/")) { alert("请上传图片或视频文件"); return; } + const reader = new FileReader(); + reader.onload = () => { + PM.imageDataUrl = reader.result; + document.getElementById("pm-previewImg").src = PM.imageDataUrl; + document.getElementById("pm-dzEmpty").hidden = true; + document.getElementById("pm-dzPreview").hidden = false; + pmUpdateAnalyzeBtn(); + }; + reader.onerror = () => toast("读取文件失败", "err"); + reader.readAsDataURL(file); +} + +// 视频取帧 +function pmGrabVideoFrame(file) { + const video = document.createElement("video"); + video.preload = "metadata"; video.muted = true; video.src = URL.createObjectURL(file); + video.onloadedmetadata = () => { video.currentTime = Math.min(1, video.duration / 2 || 0.5); }; + video.onseeked = () => { + const canvas = document.createElement("canvas"); + canvas.width = video.videoWidth; canvas.height = video.videoHeight; + canvas.getContext("2d").drawImage(video, 0, 0); + URL.revokeObjectURL(video.src); + PM.imageDataUrl = canvas.toDataURL("image/jpeg", 0.92); + document.getElementById("pm-previewImg").src = PM.imageDataUrl; + document.getElementById("pm-dzEmpty").hidden = true; + document.getElementById("pm-dzPreview").hidden = false; + pmUpdateAnalyzeBtn(); + }; + video.onerror = () => { URL.revokeObjectURL(video.src); toast("无法读取视频文件", "err"); }; +} + +async function pmAnalyze() { + if (!PM.imageDataUrl) return; + const model = document.getElementById("pm-model").value; + if (!model) return; + const btn = document.getElementById("pm-analyze"); + btn.disabled = true; + btn.textContent = "分析中…"; + pmRenderLoading(); + + try { + const r = await apic.promptAnalyze({ + image: PM.imageDataUrl, + model, + groupId: PM.activeGroupId || undefined, + }); + if (!r || !r.ok) throw new Error((r && r.error) || "分析失败"); + pmRenderResult(r.result); + } catch (err) { + pmRenderError(err.message || String(err)); + } finally { + btn.textContent = "开始分析"; + pmUpdateAnalyzeBtn(); + } +} + +// 清空右侧 body 渲染空状态 +function pmRenderEmpty() { + const body = document.getElementById("pm-result-body"); + if (!body) return; + body.innerHTML = ` +
    +
    🖼️→📝
    +

    上传素材,反推它的生图提示词

    +

    精准分析九个视觉维度,输出可直接复现的双语提示词

    +
    七维拆解中英双语负面提示词关键词标签
    +
    `; +} +function pmRenderLoading() { + const body = document.getElementById("pm-result-body"); + if (!body) return; + body.innerHTML = ` +
    +
    +
    正在分析图片…
    +
    `; +} +function pmRenderError(msg) { + const body = document.getElementById("pm-result-body"); + if (!body) return; + body.innerHTML = `
    ⚠ ${esc(msg)}
    `; +} + +function pmRenderResult(data) { + const body = document.getElementById("pm-result-body"); + if (!body) return; + + // 置信度徽标 + const conf = data.confidence || "low"; + const confLabel = { high: "高", medium: "中", low: "低" }[conf] || conf; + const confHTML = `${confLabel}`; + + // 关键词标签 + const tagsHTML = (data.tags_en || []).map((t) => `${esc(t)}`).join(""); + + // 维度拆解 + const breakdownHTML = (data.breakdown || []).map((d) => ` +
    +
    + ${esc(d.label_zh || "")} + ${esc(d.label_en || "")} +
    +
    + ${esc(d.value_zh || "")} + ${esc(d.value_en || "")} +
    +
    `).join(""); + + // 备注 + const notesHTML = data.notes_zh ? `

    ${esc(data.notes_zh)}

    ` : ""; + + body.innerHTML = ` +
    + 分析结果 + ${confHTML} +
    +
    +
    +
    + 英文提示词 推荐使用 + +
    +

    ${esc(data.prompt_en || "")}

    +
    +
    +
    + 中文对照 + +
    +

    ${esc(data.prompt_zh || "")}

    +
    +
    +
    + 负面提示词 Negative + +
    +

    ${esc(data.negative_prompt_en || "")}

    +

    ${esc(data.negative_prompt_zh || "")}

    +
    + ${tagsHTML ? `
    ${tagsHTML}
    ` : ""} + ${breakdownHTML ? `
    ${breakdownHTML}
    ` : ""} + ${notesHTML} +
    `; +} + +// ---- 事件绑定 ---- +(function pmWireEvents() { + const dropzone = document.getElementById("pm-dropzone"); + const fileInput = document.getElementById("pm-fileInput"); + if (dropzone) { + dropzone.addEventListener("click", () => fileInput && fileInput.click()); + ["dragover", "dragenter"].forEach((ev) => + dropzone.addEventListener(ev, (e) => { e.preventDefault(); dropzone.classList.add("drag"); }) + ); + ["dragleave", "drop"].forEach((ev) => + dropzone.addEventListener(ev, (e) => { e.preventDefault(); dropzone.classList.remove("drag"); }) + ); + dropzone.addEventListener("drop", (e) => { + const f = e.dataTransfer.files[0]; + if (f) pmHandleFile(f); + }); + } + if (fileInput) fileInput.addEventListener("change", (e) => pmHandleFile(e.target.files[0])); + const clearBtn = document.getElementById("pm-clearBtn"); + if (clearBtn) { + clearBtn.addEventListener("click", (e) => { + e.stopPropagation(); + PM.imageDataUrl = null; + if (fileInput) fileInput.value = ""; + document.getElementById("pm-dzPreview").hidden = true; + document.getElementById("pm-dzEmpty").hidden = false; + pmRenderEmpty(); // 重置右侧为空状态 + pmUpdateAnalyzeBtn(); + }); + } + const fetchBtn = document.getElementById("pm-fetch"); + if (fetchBtn) fetchBtn.addEventListener("click", pmFetchModels); + const analyzeBtn = document.getElementById("pm-analyze"); + if (analyzeBtn) analyzeBtn.addEventListener("click", pmAnalyze); + + // 复制按钮 —— 事件委托(CSP 禁止内联 onclick) + const resultBody = document.getElementById("pm-result-body"); + if (resultBody) { + resultBody.addEventListener("click", (e) => { + const btn = e.target.closest(".pm-copy-btn"); + if (!btn) return; + const card = btn.closest(".pm-card"); + if (!card) return; + const textEl = card.querySelector(".pm-prompt-text"); + if (!textEl?.textContent) return; + navigator.clipboard.writeText(textEl.textContent).then(() => toast("已复制", "ok")); + }); + } +})(); + +// =========================================================================== +// ②.⑦ Token 用量监控面板 +// =========================================================================== +let tkLoaded = false; +let tkPollTimer = null; + +function fmtTokens(n) { + if (!n || n < 0) return "0"; + 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 = Math.max(0, Math.round(Number(n) || 0)); + 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"; +} + +const TOKEN_PROGRESS_CAP = 5000000000; + +function tokenVisualProgressPct(total) { + total = Math.max(0, Math.round(Number(total) || 0)); + if (!total) return 0; + const scale = 100000; + const pct = Math.log1p(total / scale) / Math.log1p(TOKEN_PROGRESS_CAP / scale) * 100; + return Math.max(2.4, Math.min(100, pct)); +} + +function paintTokenPreviewSegments(total) { + const pct = tokenVisualProgressPct(total); + const segs = Array.from(document.querySelectorAll(".tk-preview-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("--tk-preview-seg-fill", fill + "%"); + }); + const badge = document.getElementById("tk-preview-badge-text"); + if (badge) badge.textContent = fmtBadgeTokens(total); +} + +function tkRenderStatus(s) { + const dot = document.getElementById("tk-dot"); + const phaseText = document.getElementById("tk-phase-text"); + if (dot) dot.dataset.state = (s && s.phase) || "unknown"; + if (phaseText) { + const map = { connected: "已连接", disconnected: "未连接", transcribing: "已连接", recording: "已连接" }; + phaseText.textContent = map[(s && s.phase)] || (s && s.phase) || "—"; + } +} + +async function tkRefresh() { + const totalEl = document.getElementById("tk-total"); + const inputEl = document.getElementById("tk-input"); + const outputEl = document.getElementById("tk-output"); + const cachedEl = document.getElementById("tk-cached"); + const estimateEl = document.getElementById("tk-estimate"); + const unknownEl = document.getElementById("tk-unknown"); + const dateEl = document.getElementById("tk-date"); + const diagEl = document.getElementById("tk-diag"); + try { + const r = await apic.tokenStatus(); + tkRenderStatus({ phase: r && r.phase }); + if (r && r.ok) { + const total = Number(r.total) || 0; + const input = Number(r.input) || 0; + const output = Number(r.output) || 0; + const cached = Number(r.cached) || 0; + const estimate = Number(r.estimate || r.estimated) || 0; + const unknown = Number(r.unknown) || 0; + if (totalEl) totalEl.textContent = fmtTokens(total); + if (inputEl) inputEl.textContent = fmtTokens(input); + if (outputEl) outputEl.textContent = fmtTokens(output); + if (cachedEl) cachedEl.textContent = fmtTokens(cached); + if (estimateEl) estimateEl.textContent = fmtTokens(estimate); + if (unknownEl) unknownEl.textContent = fmtTokens(unknown); + if (dateEl) dateEl.textContent = [r.date || "", r.lastSource ? "来源:" + r.lastSource : ""].filter(Boolean).join(" · "); + if (diagEl) { + const net = r.network || {}; + const parts = [ + r.lastSource === "session" ? "本地 session" : "页面 usage", + "worker " + (Number(net.attachedWorkers) || 0), + "Network 命中 " + (Number(net.matchedCount) || 0), + "usage " + (Number(net.usageCount) || 0), + "无 usage " + (Number(net.noUsageCount) || 0), + ]; + if (net.lastUsageTotal) parts.push("最近 +" + fmtTokens(Number(net.lastUsageTotal) || 0)); + if (net.lastError) parts.push("错误:" + net.lastError); + diagEl.textContent = parts.join(" · "); + diagEl.title = [net.lastUsageUrl || "", net.lastNoUsageUrl || "", net.lastMatchedUrl || ""].filter(Boolean).join("\n"); + } + paintTokenPreviewSegments(total); + } else { + if (totalEl) totalEl.textContent = "—"; + if (inputEl) inputEl.textContent = "0"; + if (outputEl) outputEl.textContent = "0"; + if (cachedEl) cachedEl.textContent = "0"; + if (estimateEl) estimateEl.textContent = "0"; + if (unknownEl) unknownEl.textContent = "0"; + if (dateEl) dateEl.textContent = r && r.error ? r.error : ""; + if (diagEl) diagEl.textContent = ""; + paintTokenPreviewSegments(0); + } + } catch (e) { + if (totalEl) totalEl.textContent = "—"; + if (inputEl) inputEl.textContent = "0"; + if (outputEl) outputEl.textContent = "0"; + if (cachedEl) cachedEl.textContent = "0"; + if (estimateEl) estimateEl.textContent = "0"; + if (unknownEl) unknownEl.textContent = "0"; + if (dateEl) dateEl.textContent = "读取失败"; + if (diagEl) diagEl.textContent = ""; + paintTokenPreviewSegments(0); + } +} + +async function tkReset() { + if (!confirm("确定手动清零今日 Token 用量吗?此操作不可恢复。")) return; + try { + const r = await apic.tokenReset(); + if (r && r.ok) { toast("已清零", "ok"); tkRefresh(); } + else throw new Error((r && r.error) || "重置失败"); + } catch (e) { + toast("重置失败:" + (e.message || e), "err"); + } +} + +async function tkEnter() { + tkLoaded = true; + await tkRefresh(); + // 每 5 秒轮询一次(Codex 已连接且用量在变化时) + if (!tkPollTimer) tkPollTimer = setInterval(tkRefresh, 5000); +} + +(function tkWireEvents() { + const refreshBtn = document.getElementById("tk-refresh"); + if (refreshBtn) refreshBtn.addEventListener("click", tkRefresh); + const resetBtn = document.getElementById("tk-reset"); + if (resetBtn) resetBtn.addEventListener("click", tkReset); +})(); + + // —— 启动 —— +wireVoiceControls(); loadMarket(); loadSecrets(); loadVersion(); diff --git a/renderer/index.html b/renderer/index.html index 310c56b..59b1574 100644 --- a/renderer/index.html +++ b/renderer/index.html @@ -84,6 +84,13 @@

    tokendun 官方自研插件与内置工具

    +
    @@ -105,7 +112,22 @@
    🎤
    语音输入
    给 Codex 输入框注入麦克风按钮,说话即转文字填入。需由工具箱重启 Codex 一次以启用调试通道(登录态保留)。
    -
    +
    +
    +
    +
    🧑‍💼
    员工团队
    +
    用一段话描述项目需求,自动拆解出一支带约束、能协作的 AI 员工团队(含总控 + 契约 + 文件边界 + 协同规范),打包成 Codex / Claude Code 可直接用的配置 zip。
    +
    +
    +
    +
    🎨
    提示词提取
    +
    上传图片或视频,AI 自动反推出能复现它的生图提示词。中英双语、七维拆解、负面提示词、关键词标签,直接用你的中转分组运行。
    +
    +
    +
    +
    ⚡
    Token 用量
    +
    在 Codex 输入框下方、模型选择左侧显示每日 Token 累计消耗量。复用语音 CDP 连接自动注入,无需额外配置。每天 24 点自动清零。
    +
    @@ -130,14 +152,13 @@
    未连接 - - +
    状态
    -
    点击「连接」开始。若 Codex 正在运行,会提示你确认重启 Codex 以启用调试端口。
    +
    点击「启用语音输入」开始。若 Codex 正在运行,会提示你确认重启 Codex 以启用调试端口。
    转写分组 / 模型
    @@ -155,7 +176,7 @@
    使用说明
      -
    1. 点击「连接」。若 Codex 在运行且未带调试端口,会弹窗提示重启(当前窗口会关闭一次,登录态保留)。
    2. +
    3. 点击「启用语音输入」。若 Codex 在运行且未带调试端口,会弹窗提示重启(当前窗口会关闭一次,登录态保留)。
    4. 连接成功后,Codex 输入框旁出现 🎤 按钮。点击 → 授权麦克风权限 → 说话 → 再次点击停止。
    5. 转写完成后文字自动填入输入框。首次需在「系统设置 → 隐私 → 麦克风」允许 ChatGPT。
    6. macOS 误杀进程风险已规避:仅在确认后重启。Codex 升级后若按钮消失,可能需等待工具箱适配。
    7. @@ -201,6 +222,192 @@
    + +
    +
    + +
    +

    生成 AI 员工团队

    +

    用一段话描述项目需求,自动拆解出一支带约束、能协作的 AI 员工团队(含总控),打包成 Codex / Claude Code 可直接用的配置。

    + + +
    + +
    + + +
    +
    生成 .toml 员工 + 总控(调度 / 审核)+ AGENTS.md
    +
    + +
    + +
    + + +
    + +
    先在『路由中转』配置好分组,再选具体模型
    +
    + +
    + + +
    写进每个员工的 model 字段,改成你运行器支持的模型名
    +
    + + +
    + + +
    +
    + 员工团队 + +
    +
    +
    +
    🧑‍💼→👥
    +

    描述需求,自动组建 AI 员工团队

    +

    拆解职责 · 定义契约 · 划分文件边界 · 生成协同规范

    +
    + + + +
    +
    +
    +
    + + +
    +
    +
    +

    提示词提取

    +

    上传图片或视频,AI 自动分析九个视觉维度,反推出能复现它的中英双语生图提示词。直接用你的中转分组运行。

    +
    +
    + +
    + +
    +
    + +
    +
    📤
    +
    拖拽图片 / 视频到这里
    +
    或点击选择 · JPG / PNG / WebP / MP4
    +
    + +
    + +
    + +
    + + +
    + +
    先在『路由中转』配置好分组,再选具体模型(需要视觉/多模态模型)
    +
    + + +
    + + +
    +
    +
    +
    🖼️→📝
    +

    上传素材,反推它的生图提示词

    +

    精准分析九个视觉维度,输出可直接复现的双语提示词

    +
    七维拆解中英双语负面提示词关键词标签
    +
    +
    +
    +
    +
    + + +
    +
    +
    +

    Token 用量

    +

    在 Codex 输入框下方、模型选择左侧显示 Token 累计消耗条。复用语音 CDP 连接自动注入,每天 24 点自动清零。

    +
    +
    + + 未连接 +
    +
    + +
    + +
    +
    —
    +
    今日累计消耗
    +
    + 输入 0 + 输出 0 + 缓存 0 + 估算 0 + 未知 0 +
    +
    +
    + + +
    +
    Codex 输入框下方效果
    +
    +
    +
    +
    +
    + + +
    + ✓0 token + +
    +
    +
    +
    输入消息…
    +
    +
    + + +
    +
    操作
    +
    + + +
    +
    + 用量数据保存在 Codex 页面 localStorage 中,跨天自动清零。
    需要先在「插件市场」主页连接 Codex(Token 脚本随连接一起注入)。 +
    +
    +
    +
    +
    工作原理
    +
      +
    1. 语音连接 Codex 时,Token 监控脚本自动注入同一个 CDP 连接。
    2. +
    3. 主进程优先读取 Codex 本地 session 的 token_count 累计快照,网络 usage 仅作为兼容兜底。
    4. +
    5. 用量条挂在输入框下方工具栏,优先放在模型选择左侧。
    6. +
    +
    +
    +
    +
    @@ -321,7 +528,7 @@
    -

    中转分组

    每个分组:名称 + API 地址 + 密钥 +(可选)模型。选中谁就转发到谁。

    +

    中转分组

    每个分组:名称 + API 地址 + 密钥 +(可选)模型。按协议分两栏:Codex/GPT 与 Claude Code 各选一个,两者可同时激活一起用。

    @@ -337,6 +544,7 @@ @@ -362,6 +570,11 @@
    +
    上下文窗口 tokens(国产模型建议填,Codex 据此自动压缩)
    @@ -372,6 +585,19 @@
    DeepSeek≈64K/128K · GLM≈128K · Kimi≈128K/200K · 通义≈128K。留空则用 Codex 默认(可能过大导致超限)
    + +
    + 接入 Claude 桌面 App(第三方推理) +
    把该分组一键写入 Claude 桌面 App,重启 App 即用你的中转。仅支持 Anthropic 协议分组。
    +
    + + + + +
    +
    +
    +
    diff --git a/renderer/styles.css b/renderer/styles.css index 991893a..cf15c55 100644 --- a/renderer/styles.css +++ b/renderer/styles.css @@ -137,6 +137,9 @@ h3 { margin: 0; font-size: 16px; } .pc-badge.plugin { background: rgba(124,133,255,.16); color: var(--accent); } .pc-badge.skill { background: rgba(74,222,128,.15); color: var(--ok); } .pc-badge.plat { background: rgba(245,158,11,.16); color: #f59e0b; } +.pc-badge.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); } /* 全局代理:两种模式说明块 */ .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-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; } .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; padding: 9px 12px; outline: none; font-size: 13px; } .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); @@ -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="error"] { background: #ef4444; box-shadow: 0 0 8px rgba(239,68,68,.7); } .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; } diff --git a/scripts/publish-full.js b/scripts/publish-full.js index e261d54..c0f22c6 100644 --- a/scripts/publish-full.js +++ b/scripts/publish-full.js @@ -23,9 +23,9 @@ const OWNER = "mhgmh", REPO = "codex-market-app"; const API = `https://up.tokendun.cc/api/v1/repos/${OWNER}/${REPO}`; 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) => - /\.(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); } console.log(`待上传 ${files.length} 个文件:\n ` + files.join("\n ")); diff --git a/scripts/publish-hot.js b/scripts/publish-hot.js index 0bcfcac..c0bb5c4 100644 --- a/scripts/publish-hot.js +++ b/scripts/publish-hot.js @@ -26,7 +26,8 @@ function arg(name, def) { } const has = (name) => argv.includes(name); 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 mandatory = has("--mandatory"); const requiresFull = has("--requires-full");