fix: Codex 自动压缩不触发 —— 接管时链接模型目录(绝对路径)进 config.toml (hot 1.5.83)
This commit is contained in:
@@ -0,0 +1,180 @@
|
|||||||
|
---
|
||||||
|
description: Review UI code for Vercel Web Interface Guidelines compliance
|
||||||
|
argument-hint: <file-or-pattern>
|
||||||
|
---
|
||||||
|
|
||||||
|
# Web Interface Guidelines
|
||||||
|
|
||||||
|
Review these files for compliance: $ARGUMENTS
|
||||||
|
|
||||||
|
Read files, check against rules below. Output concise but comprehensive—sacrifice grammar for brevity. High signal-to-noise.
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
### Accessibility
|
||||||
|
|
||||||
|
- Icon-only buttons need `aria-label`
|
||||||
|
- Form controls need `<label>` or `aria-label`
|
||||||
|
- Interactive elements need keyboard handlers (`onKeyDown`/`onKeyUp`)
|
||||||
|
- `<button>` for actions, `<a>`/`<Link>` for navigation (not `<div onClick>`)
|
||||||
|
- Images need `alt` (or `alt=""` if decorative)
|
||||||
|
- Decorative icons need `aria-hidden="true"`
|
||||||
|
- Async updates (toasts, validation) need `aria-live="polite"`
|
||||||
|
- Use semantic HTML (`<button>`, `<a>`, `<label>`, `<table>`) before ARIA
|
||||||
|
- Headings hierarchical `<h1>`–`<h6>`; include skip link for main content
|
||||||
|
- `scroll-margin-top` on heading anchors
|
||||||
|
|
||||||
|
### Focus States
|
||||||
|
|
||||||
|
- Interactive elements need visible focus: `focus-visible:ring-*` or equivalent
|
||||||
|
- Never `outline-none` / `outline: none` without focus replacement
|
||||||
|
- Use `:focus-visible` over `:focus` (avoid focus ring on click)
|
||||||
|
- Group focus with `:focus-within` for compound controls
|
||||||
|
|
||||||
|
### Forms
|
||||||
|
|
||||||
|
- Inputs need `autocomplete` and meaningful `name`
|
||||||
|
- Use correct `type` (`email`, `tel`, `url`, `number`) and `inputmode`
|
||||||
|
- Never block paste (`onPaste` + `preventDefault`)
|
||||||
|
- Labels clickable (`htmlFor` or wrapping control)
|
||||||
|
- Disable spellcheck on emails, codes, usernames (`spellCheck={false}`)
|
||||||
|
- Checkboxes/radios: label + control share single hit target (no dead zones)
|
||||||
|
- Submit button stays enabled until request starts; spinner during request
|
||||||
|
- Errors inline next to fields; focus first error on submit
|
||||||
|
- Placeholders end with `…` and show example pattern
|
||||||
|
- `autocomplete="off"` on non-auth fields to avoid password manager triggers
|
||||||
|
- Warn before navigation with unsaved changes (`beforeunload` or router guard)
|
||||||
|
|
||||||
|
### Animation
|
||||||
|
|
||||||
|
- Honor `prefers-reduced-motion` (provide reduced variant or disable)
|
||||||
|
- Animate `transform`/`opacity` only (compositor-friendly)
|
||||||
|
- Never `transition: all`—list properties explicitly
|
||||||
|
- Set correct `transform-origin`
|
||||||
|
- SVG: transforms on `<g>` wrapper with `transform-box: fill-box; transform-origin: center`
|
||||||
|
- Animations interruptible—respond to user input mid-animation
|
||||||
|
|
||||||
|
### Typography
|
||||||
|
|
||||||
|
- `…` not `...`
|
||||||
|
- Curly quotes `"` `"` not straight `"`
|
||||||
|
- Non-breaking spaces: `10 MB`, `⌘ K`, brand names
|
||||||
|
- Loading states end with `…`: `"Loading…"`, `"Saving…"`
|
||||||
|
- `font-variant-numeric: tabular-nums` for number columns/comparisons
|
||||||
|
- Use `text-wrap: balance` or `text-pretty` on headings (prevents widows)
|
||||||
|
|
||||||
|
### Content Handling
|
||||||
|
|
||||||
|
- Text containers handle long content: `truncate`, `line-clamp-*`, or `break-words`
|
||||||
|
- Flex children need `min-w-0` to allow text truncation
|
||||||
|
- Handle empty states—don't render broken UI for empty strings/arrays
|
||||||
|
- User-generated content: anticipate short, average, and very long inputs
|
||||||
|
|
||||||
|
### Images
|
||||||
|
|
||||||
|
- `<img>` needs explicit `width` and `height` (prevents CLS)
|
||||||
|
- Below-fold images: `loading="lazy"`
|
||||||
|
- Above-fold critical images: `priority` or `fetchpriority="high"`
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
|
||||||
|
- Large lists (>50 items): virtualize (`virtua`, `content-visibility: auto`)
|
||||||
|
- No layout reads in render (`getBoundingClientRect`, `offsetHeight`, `offsetWidth`, `scrollTop`)
|
||||||
|
- Batch DOM reads/writes; avoid interleaving
|
||||||
|
- Prefer uncontrolled inputs; controlled inputs must be cheap per keystroke
|
||||||
|
- Add `<link rel="preconnect">` for CDN/asset domains
|
||||||
|
- Critical fonts: `<link rel="preload" as="font">` with `font-display: swap`
|
||||||
|
|
||||||
|
### Navigation & State
|
||||||
|
|
||||||
|
- URL reflects state—filters, tabs, pagination, expanded panels in query params
|
||||||
|
- Links use `<a>`/`<Link>` (Cmd/Ctrl+click, middle-click support)
|
||||||
|
- Deep-link all stateful UI (if uses `useState`, consider URL sync via nuqs or similar)
|
||||||
|
- Destructive actions need confirmation modal or undo window—never immediate
|
||||||
|
|
||||||
|
### Touch & Interaction
|
||||||
|
|
||||||
|
- `touch-action: manipulation` (prevents double-tap zoom delay)
|
||||||
|
- `-webkit-tap-highlight-color` set intentionally
|
||||||
|
- `overscroll-behavior: contain` in modals/drawers/sheets
|
||||||
|
- During drag: disable text selection, `inert` on dragged elements
|
||||||
|
- `autoFocus` sparingly—desktop only, single primary input; avoid on mobile
|
||||||
|
|
||||||
|
### Safe Areas & Layout
|
||||||
|
|
||||||
|
- Full-bleed layouts need `env(safe-area-inset-*)` for notches
|
||||||
|
- Avoid unwanted scrollbars: `overflow-x-hidden` on containers, fix content overflow
|
||||||
|
- Flex/grid over JS measurement for layout
|
||||||
|
|
||||||
|
### Dark Mode & Theming
|
||||||
|
|
||||||
|
- `color-scheme: dark` on `<html>` for dark themes (fixes scrollbar, inputs)
|
||||||
|
- `<meta name="theme-color">` matches page background
|
||||||
|
- Native `<select>`: explicit `background-color` and `color` (Windows dark mode)
|
||||||
|
|
||||||
|
### Locale & i18n
|
||||||
|
|
||||||
|
- Dates/times: use `Intl.DateTimeFormat` not hardcoded formats
|
||||||
|
- Numbers/currency: use `Intl.NumberFormat` not hardcoded formats
|
||||||
|
- Detect language via `Accept-Language` / `navigator.languages`, not IP
|
||||||
|
- Brand names, code tokens, identifiers: wrap with `translate="no"` to prevent garbled auto-translation
|
||||||
|
|
||||||
|
### Hydration Safety
|
||||||
|
|
||||||
|
- Inputs with `value` need `onChange` (or use `defaultValue` for uncontrolled)
|
||||||
|
- Date/time rendering: guard against hydration mismatch (server vs client)
|
||||||
|
- `suppressHydrationWarning` only where truly needed
|
||||||
|
|
||||||
|
### Hover & Interactive States
|
||||||
|
|
||||||
|
- Buttons/links need `hover:` state (visual feedback)
|
||||||
|
- Interactive states increase contrast: hover/active/focus more prominent than rest
|
||||||
|
|
||||||
|
### Content & Copy
|
||||||
|
|
||||||
|
- Active voice: "Install the CLI" not "The CLI will be installed"
|
||||||
|
- Title Case for headings/buttons (Chicago style)
|
||||||
|
- Numerals for counts: "8 deployments" not "eight"
|
||||||
|
- Specific button labels: "Save API Key" not "Continue"
|
||||||
|
- Error messages include fix/next step, not just problem
|
||||||
|
- Second person; avoid first person
|
||||||
|
- `&` over "and" where space-constrained
|
||||||
|
|
||||||
|
### Anti-patterns (flag these)
|
||||||
|
|
||||||
|
- `user-scalable=no` or `maximum-scale=1` disabling zoom
|
||||||
|
- `onPaste` with `preventDefault`
|
||||||
|
- `transition: all`
|
||||||
|
- `outline-none` without focus-visible replacement
|
||||||
|
- Inline `onClick` navigation without `<a>`
|
||||||
|
- `<div>` or `<span>` with click handlers (should be `<button>`)
|
||||||
|
- Images without dimensions
|
||||||
|
- Large arrays `.map()` without virtualization
|
||||||
|
- Form inputs without labels
|
||||||
|
- Icon buttons without `aria-label`
|
||||||
|
- Hardcoded date/number formats (use `Intl.*`)
|
||||||
|
- `autoFocus` without clear justification
|
||||||
|
|
||||||
|
## Output Format
|
||||||
|
|
||||||
|
Group by file. Use `file:line` format (VS Code clickable). Terse findings.
|
||||||
|
|
||||||
|
```text
|
||||||
|
## src/Button.tsx
|
||||||
|
|
||||||
|
src/Button.tsx:42 - icon button missing aria-label
|
||||||
|
src/Button.tsx:18 - input lacks label
|
||||||
|
src/Button.tsx:55 - animation missing prefers-reduced-motion
|
||||||
|
src/Button.tsx:67 - transition: all → list properties
|
||||||
|
|
||||||
|
## src/Modal.tsx
|
||||||
|
|
||||||
|
src/Modal.tsx:12 - missing overscroll-behavior: contain
|
||||||
|
src/Modal.tsx:34 - "..." → "…"
|
||||||
|
|
||||||
|
## src/Card.tsx
|
||||||
|
|
||||||
|
✓ pass
|
||||||
|
```
|
||||||
|
|
||||||
|
State issue + location. Skip explanation unless fix non-obvious. No preamble.
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"schema_version": 2,
|
||||||
|
"tool": "web-craft-suite/fetch_web_guidelines.py",
|
||||||
|
"tool_version": "2.0.0",
|
||||||
|
"tool_sha256": "f3d56f1eb68cf36f25ed96bcebfc011f9e4a9bfe7ec42f8e7bf604e3df9ef1e9",
|
||||||
|
"generated_at": "2026-08-08T09:09:07.386778+00:00",
|
||||||
|
"run_id": "0945f220-ddc8-4e5c-9e4f-5d0f6939f3c8",
|
||||||
|
"source": "https://raw.githubusercontent.com/vercel-labs/web-interface-guidelines/main/command.md",
|
||||||
|
"repository": "vercel-labs/web-interface-guidelines",
|
||||||
|
"ref": "main",
|
||||||
|
"path": "command.md",
|
||||||
|
"commit_sha": "4e799d45c17aec1498c269287a83b9dba22b966b",
|
||||||
|
"immutable_source": "https://raw.githubusercontent.com/vercel-labs/web-interface-guidelines/4e799d45c17aec1498c269287a83b9dba22b966b/command.md",
|
||||||
|
"final_url": "https://raw.githubusercontent.com/vercel-labs/web-interface-guidelines/4e799d45c17aec1498c269287a83b9dba22b966b/command.md",
|
||||||
|
"fetched_at": "2026-08-08T09:09:07.386778+00:00",
|
||||||
|
"document": {
|
||||||
|
"path": "web-interface-guidelines.md",
|
||||||
|
"sha256": "eea73cb6dd46fee9faec9973e8e7fe198b5f07ec326f14d276a56e50287e1cab",
|
||||||
|
"bytes": 6939
|
||||||
|
},
|
||||||
|
"content_type": "text/plain",
|
||||||
|
"proxy_used": false,
|
||||||
|
"proxy_source": "none"
|
||||||
|
}
|
||||||
@@ -13,3 +13,6 @@ resources/singbox/*/sing-box.exe
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
*.log
|
*.log
|
||||||
*.map
|
*.map
|
||||||
|
|
||||||
|
# 多模态免费网关凭据:打包前由发布环境注入,绝不提交。
|
||||||
|
resources/multimodal-gateway.json
|
||||||
|
|||||||
@@ -1,38 +1,38 @@
|
|||||||
<!-- codex-memory:start -->
|
<!-- codex-memory:start -->
|
||||||
## 项目记忆(Codex 插件市场自动维护 · 回答前据此 · 勿手动编辑本段)
|
## 项目记忆(Codex 插件市场自动维护 · 回答前据此 · 勿手动编辑本段)
|
||||||
**关于用户 / 长期事实(务必遵守)**:
|
**关于用户 / 长期事实(务必遵守)**:
|
||||||
- 与助手沟通用中文优先
|
- 跟AI/沟通用中文,给生成模型的提示词可中英混写。
|
||||||
**当前状态**:已确认失败原因是后端优先选到了需59aiapi私有token的content_url,导致401;正确应选同一结果中的pulseaify mp4。已修改URL提取逻辑优先选择真实视频文件URL,并验证15秒结果可下载播放。
|
- 图片/视频节点统一 composer 风格
|
||||||
|
- 背景采用浅色网格风格,控件用悬浮pill
|
||||||
|
- 应用候选需按包身份精确匹配
|
||||||
|
- 面板改随机用户名/密码/路径,写SQLite+bcrypt
|
||||||
|
- 画布做成独立全屏工作台,采用 tldraw 轻量白板中性风格,支持全屏与可拖动缩放,不做聊天内大表单
|
||||||
|
- 助手身份使用 kimi-k3
|
||||||
|
**当前状态**:首条需求:你好
|
||||||
**已定决策**:
|
**已定决策**:
|
||||||
- 59aiapi content不直接下载
|
- 热更必须递增contentVersion,不能覆盖旧版本
|
||||||
- 不再优先使用video_url/content_url
|
- 竖屏按9:16换算为1440×2560与2160×3840
|
||||||
- 优先选.mp4等真实视频文件URL
|
- 2K按2560×1440计算,4K按3840×2160计算。
|
||||||
- URL提取不能误选任务API
|
- 2K/4K仅对Seedance开放,Grok保持原有限制。
|
||||||
- 前端下载应优先使用 dataUrl
|
- 热更版本递增为 1.5.82,避免复用旧版本。
|
||||||
- 下载受保护域名需带Bearer鉴权
|
- Grok 继续保留 480p/720p/1080p,分辨率不扩展。
|
||||||
- Seedance 失败点在成品下载阶段
|
- Seedance 支持 2K 和 4K 并透传请求。
|
||||||
- 本地craft-website技能已安装校验通过
|
- 发布后验证manifest、bundle和SHA256
|
||||||
- URL 提取只认明确视频字段或文件
|
- 2K/4K 是否生成取决于上游模型支持。
|
||||||
- Seedance 成品下载需带鉴权
|
- 模型目录压缩阈值设为 80%
|
||||||
- skill 源 SKILL.md 移除 version
|
|
||||||
**下一步**:
|
|
||||||
- 重启应用加载新代码后再测
|
|
||||||
- 确认热更包含本次修改
|
|
||||||
- 失败时查看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-19 Seedance视频下载失败修复
|
- 2026-08-15 你好
|
||||||
- 2026-07-18 Grok视频比例选项补充
|
- 2026-08-14 你好
|
||||||
- 2026-07-18 Grok传图与提示词约定
|
- 2026-08-14 简单问候
|
||||||
- 2026-07-18 Seedance参数修复与热更状态
|
- 2026-08-14 修复长会话执行与工具调用问题
|
||||||
- 2026-07-18 插件市场会话起始
|
- 2026-08-13 codex-market-app 执行请求断开排查
|
||||||
- 2026-07-18 gen-image 技能市场更新
|
- 2026-08-12 确认助手身份与当前工作目录
|
||||||
- 2026-07-18 修复 token 统计不准
|
- 2026-08-12 修复工具箱路由切模型显示与关闭路由配置边界并发布热更
|
||||||
- 2026-07-17 调整语音连接入口位置
|
- 2026-08-09 修复Windows端Codex会话记录恢复失败并发布热更
|
||||||
- 2026-07-17 项目闲聊与广汉天气查询
|
- 2026-08-08 无实质技术内容的空对话
|
||||||
- 2026-07-17 项目任务待确认
|
- 2026-08-08 暂无实质技术内容的对话
|
||||||
- 2026-07-17 修复 Codex token 计数
|
- 2026-08-08 暂无实质技术内容的对话
|
||||||
- 2026-07-17 Respond to hello
|
- 2026-08-08 暂无实质技术内容的对话
|
||||||
> 回答涉及本段未列出的历史细节前,先运行 `rg -n "关键词" /Users/mac/.codex/memory/31c67c70b625.md` 查证再答。
|
> 回答涉及本段未列出的历史细节前,先运行 `rg -n "关键词" /Users/mac/.codex/memory/31c67c70b625.md` 查证再答。
|
||||||
<!-- codex-memory:end -->
|
<!-- codex-memory:end -->
|
||||||
|
|
||||||
|
|||||||
+5
-3
@@ -52,8 +52,10 @@ canvasRun: (kind, params) => ipcRenderer.invoke("canvas-run", { kind, params }),
|
|||||||
- `endpointPath`:默认 `/v1/images/generations`,允许用户覆盖。
|
- `endpointPath`:默认 `/v1/images/generations`,允许用户覆盖。
|
||||||
- **图生图参考图**:`image`(用户上传的 base64 dataURL)→ 主进程 `uploadMedia()` 传图床换公网直链 → 塞进请求体字段 `refField`(默认 `image`,前端高级项可改)。上传失败直接返回 `{ok:false,error}`,不静默降级。流式与非流式两条 body 都带该字段。
|
- **图生图参考图**:`image`(用户上传的 base64 dataURL)→ 主进程 `uploadMedia()` 传图床换公网直链 → 塞进请求体字段 `refField`(默认 `image`,前端高级项可改)。上传失败直接返回 `{ok:false,error}`,不静默降级。流式与非流式两条 body 都带该字段。
|
||||||
- 错误信息里带上**实际请求的 URL 和 model**,方便排查(如 404 时提示 `POST {url} model={model} → 404`)。
|
- 错误信息里带上**实际请求的 URL 和 model**,方便排查(如 404 时提示 `POST {url} model={model} → 404`)。
|
||||||
- **video** `{ mode, prompt, model?, image?, lastFrameImage?, referenceImages?[], ratio?, quality?, seconds?, referenceField?, endpointPath? }` —— **Seedance 2.0**,使用新中转契约。
|
- **video** `{ mode, prompt, model?, image?, lastFrameImage?, referenceImages?[], ratio?, quality?, seconds?, referenceField?, endpointPath? }` —— **Seedance / Seedance V2**。
|
||||||
- Seedance 节点通过 CLMM 聚合中转提交,创建端点默认 `/v1/videos`;若分组 `baseUrl` 已以 `/v1` 结尾,拼接时不得产生重复的 `/v1/v1`。
|
- 画布分组下拉会识别并优先展示 **Seedance V2**(名称含 `Seedance V2` / `seedance_v2`),与旧 Seedance/CLMM 分组并存。
|
||||||
|
- **Seedance V2** 走 Binghuo 原生:创建端点默认 `/v1/video/generations`,body 使用 `model/prompt/duration/ratio/resolution/images|start_frame|end_frame`。
|
||||||
|
- **旧 Seedance / CLMM** 继续走聚合契约:创建端点默认 `/v1/videos`;若分组 `baseUrl` 已以 `/v1` 结尾,拼接时不得产生重复的 `/v1/v1`。
|
||||||
- 请求体遵循 CLMM 文档:`model`、`prompt`、`aspect_ratio`、`resolution`、`size`、`seconds`、`mySeconds`、`reference_image_urls`。模型包含 `-Ns` 时使用 `seconds:"1"`;包含 `-gz` 时将 `mySeconds` 固定为模型声明的 N 秒。
|
- 请求体遵循 CLMM 文档:`model`、`prompt`、`aspect_ratio`、`resolution`、`size`、`seconds`、`mySeconds`、`reference_image_urls`。模型包含 `-Ns` 时使用 `seconds:"1"`;包含 `-gz` 时将 `mySeconds` 固定为模型声明的 N 秒。
|
||||||
- `mode`:`"text"` 只发送空参考图数组 | `"first"` 发送一张 `reference_image_urls` | `"firstlast"` 发送首尾两张 | `"reference"` 发送多张参考图。旧值 `"image"` 归一为 `"first"`。
|
- `mode`:`"text"` 只发送空参考图数组 | `"first"` 发送一张 `reference_image_urls` | `"firstlast"` 发送首尾两张 | `"reference"` 发送多张参考图。旧值 `"image"` 归一为 `"first"`。
|
||||||
- 图片经 `resolveMat` 处理:`data:` 先上传图床换为公网 URL,`http(s)` 原样发送;上游请求中禁止出现 base64。
|
- 图片经 `resolveMat` 处理:`data:` 先上传图床换为公网 URL,`http(s)` 原样发送;上游请求中禁止出现 base64。
|
||||||
@@ -68,7 +70,7 @@ canvasRun: (kind, params) => ipcRenderer.invoke("canvas-run", { kind, params }),
|
|||||||
|
|
||||||
### 前端节点表单要求(每个能力节点都要能配置)
|
### 前端节点表单要求(每个能力节点都要能配置)
|
||||||
- **文生图节点**:提示词 + **模型输入框(带 datalist,可点“拉取模型”从 canvasModels 填充)** + **比例下拉**(1:1 / 16:9 / 9:16 / 4:3 / 3:4 / 3:2) + **清晰度下拉**(1K / 2K / 4K)(前端据此算出 `WxH`,长边:1K=1024,2K=2048,4K=4096,短边=长边×比例取整)+ 折叠的「高级」里放**接口地址**覆盖 + 运行 + 预览/报错。
|
- **文生图节点**:提示词 + **模型输入框(带 datalist,可点“拉取模型”从 canvasModels 填充)** + **比例下拉**(1:1 / 16:9 / 9:16 / 4:3 / 3:4 / 3:2) + **清晰度下拉**(1K / 2K / 4K)(前端据此算出 `WxH`,长边:1K=1024,2K=2048,4K=4096,短边=长边×比例取整)+ 折叠的「高级」里放**接口地址**覆盖 + 运行 + 预览/报错。
|
||||||
- **视频节点**:**模式下拉(图生视频/文生视频)** + 提示词 + 模型输入框(datalist+拉取) + 比例(16:9/9:16/1:1/4:3) + 分辨率(480p/720p/1080p) + 时长(可选 4s/6s/8s) + 高级(接口地址) + 运行 + 预览/报错。图生视频模式吃上游图,文生视频不需要。
|
- **视频节点**:**模式下拉(图生视频/文生视频)** + 提示词 + 模型输入框(datalist+拉取) + 比例(16:9/9:16/1:1/4:3) + 分辨率(480p/720p/1080p/2K/4K) + 时长(可选 4s/6s/8s) + 高级(接口地址) + 运行 + 预览/报错。2K/4K 仅透传给中转,是否可用取决于所选 Seedance 模型;官方常规模型通常最高 1080p。图生视频模式吃上游图,文生视频不需要。
|
||||||
- **TTS 节点**:文本 + 模型输入框 + 音色 + 格式(mp3/wav/opus) + 运行 + 预览。
|
- **TTS 节点**:文本 + 模型输入框 + 音色 + 格式(mp3/wav/opus) + 运行 + 预览。
|
||||||
- 「拉取模型」按钮:调 `apic.canvasModels()`,把返回的 models 灌进所有模型输入框的 `<datalist>`;失败给 toast。
|
- 「拉取模型」按钮:调 `apic.canvasModels()`,把返回的 models 灌进所有模型输入框的 `<datalist>`;失败给 toast。
|
||||||
- 预置示例(cvSeedSample)里,模型字段留空并加占位提示(如「填入你中转的图像模型,如 flux-pro」),避免又用聊天模型 404。
|
- 预置示例(cvSeedSample)里,模型字段留空并加占位提示(如「填入你中转的图像模型,如 flux-pro」),避免又用聊天模型 404。
|
||||||
|
|||||||
+25
-51
@@ -3,65 +3,39 @@
|
|||||||
|
|
||||||
> 核心记忆已写进本项目 `AGENTS.md`(Codex 每次会话自动读)。本文件是更详细的近期快照,供需要时参考;关键信息不依赖本文件。
|
> 核心记忆已写进本项目 `AGENTS.md`(Codex 每次会话自动读)。本文件是更详细的近期快照,供需要时参考;关键信息不依赖本文件。
|
||||||
|
|
||||||
## 最近会话 · 2026-07-19 08:05 · Seedance视频下载失败修复
|
## 最近会话 · 2026-08-15 23:02 · 你好
|
||||||
|
|
||||||
已确认失败原因是后端优先选到了需59aiapi私有token的content_url,导致401;正确应选同一结果中的pulseaify mp4。已修改URL提取逻辑优先选择真实视频文件URL,并验证15秒结果可下载播放。
|
首条需求:你好
|
||||||
|
|
||||||
**已确定的决策**:
|
## 更早会话 · 2026-08-14 07:41 · 你好
|
||||||
- 优先选.mp4等真实视频文件URL
|
|
||||||
- 不再优先使用video_url/content_url
|
首条需求:# Context from my IDE setup: ## Active file: electron/main.js ## Open tabs: - main.js: electron/main.js ## My request: 你好
|
||||||
- 59aiapi content不直接下载
|
最后进展:在,小楼。有什么问题直接说。
|
||||||
- 前端下载优先使用dataUrl
|
|
||||||
|
**涉及文件**:electron/main.js、main.js、renderer/app.js、/ProjectDown/codex-market-app/renderer/app.js、canvas.js、/ProjectDown/codex-market-app/electron/lib/canvas.js
|
||||||
|
|
||||||
|
## 更早会话 · 2026-08-14 06:56 · 简单问候
|
||||||
|
|
||||||
|
用户仅进行了两次问候,尚未提出具体项目需求或技术任务。
|
||||||
|
|
||||||
**下一步**:
|
**下一步**:
|
||||||
- 重启应用加载新代码后再测
|
- 等待用户提供具体需求
|
||||||
- 确认热更包含本次修改
|
|
||||||
- 失败时查看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-08-14 06:14 · 修复长会话执行与工具调用问题
|
||||||
|
|
||||||
## 更早会话 · 2026-07-18 15:50 · Grok视频比例选项补充
|
已修复 DeepSeek/Kimi Chat 模式下工具调用参数转换、工具保留及长会话压缩配置问题,避免会话过长后只回复一句而不执行。DeepSeek 默认窗口 64K、Kimi 128K,压缩阈值为 80%,相关测试已全部通过(32/32)。token 统计位置和顶部全局代理入口尚未确认完成。
|
||||||
|
|
||||||
已为 Grok 视频节点补充视频格式/比例选择,并让后端按比例和分辨率组合生成真实 size。验证通过,旧画布节点可能仍需确认是否需要迁移已保存 HTML。
|
|
||||||
|
|
||||||
**已确定的决策**:
|
**已确定的决策**:
|
||||||
- Grok默认视频格式为16:9
|
- 保留标准 function 工具及完整参数 schema
|
||||||
- 支持16:9、9:16、1:1、4:3、3:4
|
- 多模态模式仅移除 view_image 工具
|
||||||
- 后端用比例+分辨率生成size
|
- DeepSeek 默认窗口设为 64K
|
||||||
- 多参考图旧接口继续用aspect_ratio
|
- Kimi 默认窗口设为 128K
|
||||||
|
- 模型目录压缩阈值设为 80%
|
||||||
|
|
||||||
**下一步**:
|
**下一步**:
|
||||||
- 确认旧画布节点是否需迁移
|
- 定位并修复 token 统计进度条位置
|
||||||
- 如需要则补节点恢复兼容逻辑
|
- 移除顶部全局代理模式入口
|
||||||
|
- 回归验证 DeepSeek/Kimi 长会话执行
|
||||||
|
- 确认 npm test 及界面功能均正常
|
||||||
|
|
||||||
**涉及文件**:/Users/mac/Documents/备份数据/ProjectDown/codex-market-app/renderer/app.js、/Users/mac/Documents/备份数据/ProjectDown/codex-market-app/electron/lib/canvas.js
|
**涉及文件**:electron/lib/translate.js、electron/lib/router.js
|
||||||
|
|
||||||
## 更早会话 · 2026-07-18 15:36 · Grok传图与提示词约定
|
|
||||||
|
|
||||||
Grok 首帧图生视频已按文档对齐:先上传到自家图床,再提交 `image.url` 形式的公网链接;前端比例选项也已补齐并完成 mock 验证。关于指令沟通,中文优先,外部生成模型用中英混写更稳。
|
|
||||||
|
|
||||||
**已确定的决策**:
|
|
||||||
- 与助手沟通用中文优先
|
|
||||||
- 外部生成提示词可中英混写
|
|
||||||
- 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
|
|
||||||
- 水印由上游烧录,客户端不加水印
|
|
||||||
|
|
||||||
**下一步**:
|
|
||||||
- 确认上游是否支持 watermark:false
|
|
||||||
- 如需上线 Seedance 修复,发布新热更
|
|
||||||
- 必要时实测无水印参数一次
|
|
||||||
|
|
||||||
**涉及文件**: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
|
|
||||||
|
|||||||
@@ -665,6 +665,20 @@ async function sessLoad() {
|
|||||||
try {
|
try {
|
||||||
sessData = await apic.sessionsList(false);
|
sessData = await apic.sessionsList(false);
|
||||||
sessLoaded = true;
|
sessLoaded = true;
|
||||||
|
$("#sess-scope").textContent = "当前会话";
|
||||||
|
sessRender();
|
||||||
|
} catch (e) {
|
||||||
|
box.innerHTML = `<div class="empty">加载失败:${esc(e.message || e)}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sessLoadAll() {
|
||||||
|
const box = $("#sess-list");
|
||||||
|
box.innerHTML = `<div class="empty"><div class="spinner"></div>加载全部记录…</div>`;
|
||||||
|
try {
|
||||||
|
sessData = await apic.sessionsList({ includeArchived: true, includeSubagent: true });
|
||||||
|
sessLoaded = true;
|
||||||
|
$("#sess-scope").textContent = "全部记录";
|
||||||
sessRender();
|
sessRender();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
box.innerHTML = `<div class="empty">加载失败:${esc(e.message || e)}</div>`;
|
box.innerHTML = `<div class="empty">加载失败:${esc(e.message || e)}</div>`;
|
||||||
@@ -676,6 +690,7 @@ $("#sess-refresh").addEventListener("click", sessLoad);
|
|||||||
document.querySelector('.tab[data-tab="sessions"]').addEventListener("click", () => {
|
document.querySelector('.tab[data-tab="sessions"]').addEventListener("click", () => {
|
||||||
if (!sessLoaded) sessLoad();
|
if (!sessLoaded) sessLoad();
|
||||||
});
|
});
|
||||||
|
$("#sess-fetch-all").addEventListener("click", sessLoadAll);
|
||||||
$("#sess-export").addEventListener("click", async (ev) => {
|
$("#sess-export").addEventListener("click", async (ev) => {
|
||||||
if (!sessCurrent) return;
|
if (!sessCurrent) return;
|
||||||
const btn = ev.currentTarget;
|
const btn = ev.currentTarget;
|
||||||
@@ -707,8 +722,9 @@ $("#sess-import").addEventListener("click", async (ev) => {
|
|||||||
try {
|
try {
|
||||||
const r = await apic.sessionsImport();
|
const r = await apic.sessionsImport();
|
||||||
if (!r.canceled) {
|
if (!r.canceled) {
|
||||||
toast(`导入完成:新增 ${r.added},跳过 ${r.skipped}`, "ok");
|
toast(`恢复完成:新增 ${r.added},跳过 ${r.skipped},侧栏重建 ${r.catalogRebuilt || 0} 条 ✅`, "ok");
|
||||||
sessLoad();
|
sessLoaded = false;
|
||||||
|
await sessLoad();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast(`导入失败:${e.message || e}`, "err");
|
toast(`导入失败:${e.message || e}`, "err");
|
||||||
@@ -1025,6 +1041,12 @@ function renderRouter(st) {
|
|||||||
claudeBtn.textContent = st.claudeApplied ? "取消接入 Claude Code" : "接入 Claude Code";
|
claudeBtn.textContent = st.claudeApplied ? "取消接入 Claude Code" : "接入 Claude Code";
|
||||||
claudeBtn.classList.toggle("ghost", !!st.claudeApplied);
|
claudeBtn.classList.toggle("ghost", !!st.claudeApplied);
|
||||||
}
|
}
|
||||||
|
const codexBtn = $("#router-apply");
|
||||||
|
if (codexBtn) {
|
||||||
|
const applied = !!st.codexApplied;
|
||||||
|
codexBtn.textContent = applied ? "取消本地路由(恢复直连)" : "启用本地路由(高级)";
|
||||||
|
codexBtn.classList.toggle("ghost", applied);
|
||||||
|
}
|
||||||
|
|
||||||
const box = $("#router-groups");
|
const box = $("#router-groups");
|
||||||
if (!st.groups.length) {
|
if (!st.groups.length) {
|
||||||
@@ -1187,13 +1209,22 @@ $("#router-save-port").addEventListener("click", async () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
$("#router-apply").addEventListener("click", async (ev) => {
|
$("#router-apply").addEventListener("click", async (ev) => {
|
||||||
if (!confirm("将修改 Codex 的 config.toml,指向本地路由(会自动备份)。\n注意:启用后账号切换请统一在本软件切,不要再用 cc-switch,以免冲突。\n\n确认?")) return;
|
const applied = routerState && routerState.codexApplied;
|
||||||
|
const message = applied
|
||||||
|
? "将取消本地路由,并立即从 Codex config.toml / auth.json 清除本工具写入的配置,恢复 Codex 原生登录直连。\n\n确认?"
|
||||||
|
: "将修改 Codex 的 config.toml,指向本地路由(会自动备份)。\n注意:启用后账号切换请统一在本软件切,不要再用 cc-switch,以免冲突。\n\n确认?";
|
||||||
|
if (!confirm(message)) return;
|
||||||
const btn = ev.currentTarget;
|
const btn = ev.currentTarget;
|
||||||
busy(btn, true, "配置中…");
|
busy(btn, true, applied ? "取消中…" : "配置中…");
|
||||||
try {
|
try {
|
||||||
const r = await apic.routerApplyCodex();
|
const r = applied ? await apic.routerDisableCodex() : await apic.routerApplyCodex();
|
||||||
renderRouter(r.status);
|
renderRouter(r.status);
|
||||||
toast("已配置到 Codex ✅ 重启 Codex 后即可通过本地路由,之后切分组无需重启", "ok");
|
toast(
|
||||||
|
applied
|
||||||
|
? "已取消本地路由 ✅ Codex 配置已恢复直连,重启 Codex 后生效"
|
||||||
|
: "已配置到 Codex ✅ 重启 Codex 后即可通过本地路由,之后切分组无需重启",
|
||||||
|
"ok"
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast("配置失败:" + (e.message || e), "err");
|
toast("配置失败:" + (e.message || e), "err");
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ mac:
|
|||||||
extraResources:
|
extraResources:
|
||||||
- from: "resources/singbox/darwin-${arch}"
|
- from: "resources/singbox/darwin-${arch}"
|
||||||
to: "singbox/darwin-${arch}"
|
to: "singbox/darwin-${arch}"
|
||||||
|
- from: "resources/multimodal-gateway.json"
|
||||||
|
to: "multimodal-gateway.json"
|
||||||
category: public.app-category.developer-tools
|
category: public.app-category.developer-tools
|
||||||
icon: build/icon.icns
|
icon: build/icon.icns
|
||||||
# —— 未购买 Apple Developer 账号 ——
|
# —— 未购买 Apple Developer 账号 ——
|
||||||
@@ -78,6 +80,8 @@ win:
|
|||||||
extraResources:
|
extraResources:
|
||||||
- from: "resources/singbox/windows-${arch}"
|
- from: "resources/singbox/windows-${arch}"
|
||||||
to: "singbox/windows-${arch}"
|
to: "singbox/windows-${arch}"
|
||||||
|
- from: "resources/multimodal-gateway.json"
|
||||||
|
to: "multimodal-gateway.json"
|
||||||
icon: build/icon.ico
|
icon: build/icon.ico
|
||||||
target:
|
target:
|
||||||
- target: nsis
|
- target: nsis
|
||||||
@@ -93,6 +97,9 @@ nsis:
|
|||||||
createStartMenuShortcut: true
|
createStartMenuShortcut: true
|
||||||
|
|
||||||
linux:
|
linux:
|
||||||
|
extraResources:
|
||||||
|
- from: "resources/multimodal-gateway.json"
|
||||||
|
to: "multimodal-gateway.json"
|
||||||
icon: build/icon.png
|
icon: build/icon.png
|
||||||
category: Development
|
category: Development
|
||||||
target:
|
target:
|
||||||
|
|||||||
+208
-10
@@ -22,18 +22,51 @@ function uniqStrings(list) {
|
|||||||
return Array.from(new Set((list || []).map((v) => String(v || "").trim()).filter(Boolean)));
|
return Array.from(new Set((list || []).map((v) => String(v || "").trim()).filter(Boolean)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function groupText(g) {
|
||||||
|
return `${(g && g.name) || ""} ${(g && g.model) || ""}`.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSeedanceV2Model(model) {
|
||||||
|
return /^(?:bh2\.0-|sd2\.0-|sdvip|gz-sd)/i.test(String(model || "").trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSeedanceV2Group(g) {
|
||||||
|
const text = groupText(g);
|
||||||
|
return /seedance[\s_-]*v\s*2|seedancev2|seedance_v2/.test(text) || isSeedanceV2Model(g && g.model);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSeedanceGroup(g) {
|
||||||
|
return /seedance/.test(groupText(g));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isGrokGroup(g) {
|
||||||
|
return /grok/.test(groupText(g));
|
||||||
|
}
|
||||||
|
|
||||||
function modelCandidatesForGroup(g) {
|
function modelCandidatesForGroup(g) {
|
||||||
const out = [];
|
const out = [];
|
||||||
if (!g) return out;
|
if (!g) return out;
|
||||||
if (g.model) out.push(g.model);
|
if (g.model) out.push(g.model);
|
||||||
if (Array.isArray(g.oneMillionModels)) out.push(...g.oneMillionModels);
|
if (Array.isArray(g.oneMillionModels)) out.push(...g.oneMillionModels);
|
||||||
|
|
||||||
const name = String(g.name || "").toLowerCase();
|
if (isGrokGroup(g)) {
|
||||||
const model = String(g.model || "").toLowerCase();
|
|
||||||
if (/grok/.test(name) || /grok/.test(model)) {
|
|
||||||
out.push("grok-video-1.5", "grok-imagine-video-1.5", "grok-imagine-video");
|
out.push("grok-video-1.5", "grok-imagine-video-1.5", "grok-imagine-video");
|
||||||
}
|
}
|
||||||
if (/seedance/.test(name) || /seedance/.test(model)) {
|
if (isSeedanceV2Group(g)) {
|
||||||
|
out.push(
|
||||||
|
"bh2.0-fast-720p",
|
||||||
|
"bh2.0-720p",
|
||||||
|
"bh2.0-1080p",
|
||||||
|
"bh2.0-mini-720p",
|
||||||
|
"SD2.0-720P",
|
||||||
|
"SD2.0-720P-fast",
|
||||||
|
"SD2.0-1080P",
|
||||||
|
"sdvip720p",
|
||||||
|
"sdvip1080p",
|
||||||
|
"gz-sd720p",
|
||||||
|
"gz-sd1080p"
|
||||||
|
);
|
||||||
|
} else if (isSeedanceGroup(g)) {
|
||||||
out.push("seedance2.0", "seedance-2.0");
|
out.push("seedance2.0", "seedance-2.0");
|
||||||
}
|
}
|
||||||
return uniqStrings(out);
|
return uniqStrings(out);
|
||||||
@@ -283,9 +316,10 @@ async function resolveMat(v) {
|
|||||||
throw new Error("素材需为 data URL 或公网 http(s) 地址");
|
throw new Error("素材需为 data URL 或公网 http(s) 地址");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 当前中转是否可用:ready = 有可用 llmGroup。host 从 baseUrl 解析域名。
|
// 当前中转是否可用:ready = 有可用分组。画布节点会按 groupId 显式选用插件专用分组。
|
||||||
function status() {
|
function status() {
|
||||||
const llm = router.llmGroup();
|
const usable = router.usableGroups();
|
||||||
|
const llm = router.llmGroup() || (usable && usable[0]);
|
||||||
if (!llm) return { ready: false, provider: null, reason: "未配置可用中转分组" };
|
if (!llm) return { ready: false, provider: null, reason: "未配置可用中转分组" };
|
||||||
return {
|
return {
|
||||||
ready: true,
|
ready: true,
|
||||||
@@ -836,17 +870,161 @@ async function runGrokVideoLegacy(base, llm, params) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// —— Seedance V2(Binghuo 原生):POST /v1/video/generations + GET /v1/video/generations/:task_id ——
|
||||||
|
// 对应 sub2api platform=seedance_v2,与 CLMM /v1/videos 契约分离,避免字段被改写。
|
||||||
|
function seedanceV2EndpointPath(endpointPath) {
|
||||||
|
const path = String(endpointPath || "").trim();
|
||||||
|
if (!path) return "/v1/video/generations";
|
||||||
|
// 若用户仍填 CLMM 路径,也归一到 V2 原生路径(sub2api 两边都能进同一 handler,但原生更稳)
|
||||||
|
if (/\/v1\/videos(?:\/generations)?$/i.test(path) || /\/videos(?:\/generations)?$/i.test(path)) {
|
||||||
|
return "/v1/video/generations";
|
||||||
|
}
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedanceV2DurationSeconds(params, model) {
|
||||||
|
const raw = Number.parseInt(String((params && params.seconds) || ""), 10);
|
||||||
|
if (Number.isInteger(raw) && raw > 0) return raw;
|
||||||
|
const fixed = String(model || "").match(/(?:^|[-_])(\d+)s(?:$|[-_])/i);
|
||||||
|
if (fixed) return Number(fixed[1]) || 5;
|
||||||
|
return 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runSeedanceV2(base, llm, params) {
|
||||||
|
const model = String(params.model || "").trim();
|
||||||
|
const prompt = String(params.prompt || "").trim();
|
||||||
|
if (!model) return { ok: false, error: "Seedance V2 必须选择模型完整名称" };
|
||||||
|
if (!prompt) return { ok: false, error: "Seedance V2 必须填写提示词" };
|
||||||
|
|
||||||
|
let mode = String(params.mode || "text");
|
||||||
|
if (mode === "image") mode = "first";
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
model,
|
||||||
|
prompt,
|
||||||
|
duration: seedanceV2DurationSeconds(params, model),
|
||||||
|
ratio: String(params.ratio || params.aspectRatio || "16:9"),
|
||||||
|
resolution: clmmResolutionFromParamsV2(params),
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (mode === "first") {
|
||||||
|
const image = await resolveMat(params.imageUrl || params.image);
|
||||||
|
if (!image) return { ok: false, error: "Seedance V2 首帧图生视频需要首帧图" };
|
||||||
|
body.start_frame = [image];
|
||||||
|
} else if (mode === "firstlast") {
|
||||||
|
const first = await resolveMat(params.imageUrl || params.image);
|
||||||
|
const last = await resolveMat(params.lastFrameImage);
|
||||||
|
if (!first || !last) return { ok: false, error: "首尾帧模式需要首帧和尾帧两张图片" };
|
||||||
|
body.start_frame = [first];
|
||||||
|
body.end_frame = [last];
|
||||||
|
} else if (mode === "reference") {
|
||||||
|
const materials = Array.isArray(params.referenceImages) ? params.referenceImages : [];
|
||||||
|
const refs = (await Promise.all(materials.map((item) => resolveMat(item)))).filter(Boolean);
|
||||||
|
if (!refs.length) return { ok: false, error: "多参考图模式至少需要 1 张参考图" };
|
||||||
|
body.images = refs;
|
||||||
|
} else if (mode !== "text") {
|
||||||
|
return { ok: false, error: "Seedance V2 暂不支持参考视频模式,请改用 文生 / 首帧 / 多参考图" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const createUrl = joinUrl(base, seedanceV2EndpointPath(params.endpointPath));
|
||||||
|
const debugBase = {
|
||||||
|
base: hostOf(base),
|
||||||
|
create_url: createUrl,
|
||||||
|
mode,
|
||||||
|
request: {
|
||||||
|
model: body.model,
|
||||||
|
duration: body.duration,
|
||||||
|
ratio: body.ratio,
|
||||||
|
resolution: body.resolution,
|
||||||
|
image_count: Array.isArray(body.images) ? body.images.length : 0,
|
||||||
|
has_start_frame: Array.isArray(body.start_frame) && body.start_frame.length > 0,
|
||||||
|
has_end_frame: Array.isArray(body.end_frame) && body.end_frame.length > 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
seedanceDebugLog({ type: "seedance_v2_video_create", ...debugBase });
|
||||||
|
|
||||||
|
const cres = await fetch(createUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers: headers(llm.key),
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const rawCreate = await cres.text().catch(() => "");
|
||||||
|
if (!cres.ok) {
|
||||||
|
seedanceDebugLog({ type: "seedance_v2_video_create_failed", ...debugBase, status: cres.status, detail: rawCreate.slice(0, 500) });
|
||||||
|
return { ok: false, error: `Seedance V2 建任务失败(${cres.status}):POST ${createUrl} → ${rawCreate.slice(0, 300)}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
data = JSON.parse(rawCreate);
|
||||||
|
} catch {
|
||||||
|
seedanceDebugLog({ type: "seedance_v2_video_create_non_json", ...debugBase, detail: rawCreate.slice(0, 500) });
|
||||||
|
return { ok: false, error: `Seedance V2 建任务返回非 JSON:${rawCreate.slice(0, 300)}` };
|
||||||
|
}
|
||||||
|
const taskId = pickTaskId(data);
|
||||||
|
if (!taskId) {
|
||||||
|
seedanceDebugLog({ type: "seedance_v2_video_missing_task_id", ...debugBase, response: JSON.stringify(data).slice(0, 500) });
|
||||||
|
return { ok: false, error: `Seedance V2 建任务未返回 task_id:${JSON.stringify(data).slice(0, 300)}` };
|
||||||
|
}
|
||||||
|
seedanceDebugLog({ type: "seedance_v2_video_accepted", ...debugBase, task_id: taskId });
|
||||||
|
|
||||||
|
// 原生路径:/v1/video/generations/:task_id
|
||||||
|
// 若 createUrl 是 /v1/videos,则沿 createUrl/:id 轮询(sub2api 也会路由到 V2 status)
|
||||||
|
const pollUrl = /\/generations$/i.test(createUrl.replace(/\/$/, ""))
|
||||||
|
? createUrl.replace(/\/$/, "") + "/" + encodeURIComponent(taskId)
|
||||||
|
: createUrl.replace(/\/$/, "") + "/" + encodeURIComponent(taskId);
|
||||||
|
const pollHeaders = { Authorization: "Bearer " + (llm.key || ""), Accept: "application/json", "User-Agent": "codex-market" };
|
||||||
|
let lastPoll = "";
|
||||||
|
for (let i = 0; i < 300; i++) {
|
||||||
|
await sleep(i === 0 ? 1000 : 3000);
|
||||||
|
try {
|
||||||
|
const pr = await fetch(pollUrl, { headers: pollHeaders });
|
||||||
|
const rawPoll = await pr.text().catch(() => "");
|
||||||
|
if (!pr.ok) {
|
||||||
|
lastPoll = `HTTP ${pr.status}: ${rawPoll.slice(0, 200)}`;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const pd = JSON.parse(rawPoll);
|
||||||
|
const d = pd && pd.data && typeof pd.data === "object" && !Array.isArray(pd.data) ? pd.data : pd;
|
||||||
|
const st = normalizeVideoStatus((d && (d.status || d.state)) || (pd && pd.status));
|
||||||
|
lastPoll = "status=" + st;
|
||||||
|
if (st === "succeeded") {
|
||||||
|
const url = clmmVideoUrlV2(pd) || clmmVideoUrlV2(d);
|
||||||
|
if (!url) return { ok: false, error: `Seedance V2 任务完成但未找到视频地址:${JSON.stringify(pd).slice(0, 300)}` };
|
||||||
|
const b64 = await downloadAsBase64(url, trustedDownloadHeaders(url, llm));
|
||||||
|
seedanceDebugLog({ type: "seedance_v2_video_done", ...debugBase, task_id: taskId, 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;
|
||||||
|
seedanceDebugLog({ type: "seedance_v2_video_failed", ...debugBase, task_id: taskId, status: st, response: rawPoll.slice(0, 500) });
|
||||||
|
return { ok: false, error: `Seedance V2 任务失败:${typeof err === "string" ? err : JSON.stringify(err)}` };
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
lastPoll = "请求异常:" + String((e && e.message) || e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
seedanceDebugLog({ type: "seedance_v2_video_timeout", ...debugBase, task_id: taskId, last_poll: lastPoll || "" });
|
||||||
|
return { ok: false, error: `Seedance V2 轮询 15 分钟未完成(task_id=${taskId},最近一次:${lastPoll || "无"})` };
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, error: "Seedance V2 视频生成异常:" + String((e && e.message) || e) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// —— CLMM 视频统一适配:Seedance/Grok 节点都通过中转的 /videos 契约提交 ——
|
// —— CLMM 视频统一适配:Seedance/Grok 节点都通过中转的 /videos 契约提交 ——
|
||||||
// 文档契约:POST /videos,字段使用 aspect_ratio/resolution/size/seconds/mySeconds/reference_image_urls。
|
// 文档契约:POST /videos,字段使用 aspect_ratio/resolution/size/seconds/mySeconds/reference_image_urls。
|
||||||
function clmmResolutionFromParamsV2(params) {
|
function clmmResolutionFromParamsV2(params) {
|
||||||
const value = String(params.resolution || params.vsize || "720p").trim();
|
const value = String(params.resolution || params.vsize || "720p").trim();
|
||||||
return /^(480|720|1080)p$/i.test(value) ? value : "720p";
|
// 2K/4K 由中转和具体模型决定是否可用;客户端只负责保留并透传。
|
||||||
|
return /^(480|720|1080)p$/i.test(value) || /^(2|4)k$/i.test(value) ? value.toLowerCase() : "720p";
|
||||||
}
|
}
|
||||||
|
|
||||||
function clmmSizeFromParamsV2(params, resolution) {
|
function clmmSizeFromParamsV2(params, resolution) {
|
||||||
const ratio = String(params.aspectRatio || params.ratio || "16:9").trim();
|
const ratio = String(params.aspectRatio || params.ratio || "16:9").trim();
|
||||||
const match = ratio.match(/^(\d+(?:\.\d+)?):(\d+(?:\.\d+)?)$/);
|
const match = ratio.match(/^(\d+(?:\.\d+)?):(\d+(?:\.\d+)?)$/);
|
||||||
const shortSide = /1080/i.test(resolution) ? 1080 : /480/i.test(resolution) ? 480 : 720;
|
// 采用常见视频尺寸:2K=2560x1440,4K=3840x2160(横屏基准短边)。
|
||||||
|
const shortSide = /4k/i.test(resolution) ? 2160 : /2k/i.test(resolution) ? 1440 : /1080/i.test(resolution) ? 1080 : /480/i.test(resolution) ? 480 : 720;
|
||||||
if (!match) return `${Math.round(shortSide * 16 / 9)}x${shortSide}`;
|
if (!match) return `${Math.round(shortSide * 16 / 9)}x${shortSide}`;
|
||||||
const rw = Number(match[1]);
|
const rw = Number(match[1]);
|
||||||
const rh = Number(match[2]);
|
const rh = Number(match[2]);
|
||||||
@@ -1016,7 +1194,14 @@ async function run({ kind, params }, onPartial) {
|
|||||||
let r;
|
let r;
|
||||||
if (kind === "image") r = await runImage(base, g, p, onPartial);
|
if (kind === "image") r = await runImage(base, g, p, onPartial);
|
||||||
else if (kind === "tts") r = await runTts(base, g, p);
|
else if (kind === "tts") r = await runTts(base, g, p);
|
||||||
else if (kind === "video") r = await runClmmVideoV2(base, g, p, seedanceDebugLog, "Seedance");
|
else if (kind === "video") {
|
||||||
|
// Seedance V2 分组走 Binghuo 原生;旧 Seedance/CLMM 分组继续走 /v1/videos
|
||||||
|
const forceV2 = isSeedanceV2Model(p.model) || /seedance[\s_-]*v\s*2|seedancev2|\/v1\/video\/generations/i.test(
|
||||||
|
String(p.endpointPath || "") + " " + String(g.name || "") + " " + String(g.model || "")
|
||||||
|
);
|
||||||
|
if (forceV2 || isSeedanceV2Group(g)) r = await runSeedanceV2(base, g, p);
|
||||||
|
else r = await runClmmVideoV2(base, g, p, seedanceDebugLog, "Seedance");
|
||||||
|
}
|
||||||
else if (kind === "grokVideo") r = await runClmmVideoV2(base, g, p, grokDebugLog, "Grok");
|
else if (kind === "grokVideo") r = await runClmmVideoV2(base, g, p, grokDebugLog, "Grok");
|
||||||
else return { ok: false, error: "未知的画布任务类型:" + kind };
|
else return { ok: false, error: "未知的画布任务类型:" + kind };
|
||||||
const model = (r && r._model) || p.model || g.model;
|
const model = (r && r._model) || p.model || g.model;
|
||||||
@@ -1043,6 +1228,11 @@ function groups() {
|
|||||||
model: g.model || "",
|
model: g.model || "",
|
||||||
models: modelCandidatesForGroup(g),
|
models: modelCandidatesForGroup(g),
|
||||||
host: hostOf(g.baseUrl),
|
host: hostOf(g.baseUrl),
|
||||||
|
codexImage: !!g.codexImage,
|
||||||
|
codexVideo: !!g.codexVideo,
|
||||||
|
seedanceV2: isSeedanceV2Group(g),
|
||||||
|
seedance: isSeedanceGroup(g),
|
||||||
|
grok: isGrokGroup(g),
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1066,4 +1256,12 @@ async function listModels(groupId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { status, groups, run, listModels, downloadAsBase64 };
|
module.exports = {
|
||||||
|
status,
|
||||||
|
groups,
|
||||||
|
run,
|
||||||
|
listModels,
|
||||||
|
downloadAsBase64,
|
||||||
|
clmmResolutionFromParamsV2,
|
||||||
|
clmmSizeFromParamsV2,
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,303 @@
|
|||||||
|
// 创意画布本地项目库:画布结构与生成媒体分开保存,避免大体积 data URL 污染存档。
|
||||||
|
const { app } = require("electron");
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const crypto = require("crypto");
|
||||||
|
|
||||||
|
const STORE_VERSION = 1;
|
||||||
|
const ID_RE = /^[a-zA-Z0-9_-]{8,80}$/;
|
||||||
|
const MEDIA_RE = /^[a-zA-Z0-9_.-]{8,180}$/;
|
||||||
|
|
||||||
|
function rootDir() {
|
||||||
|
return path.join(app.getPath("userData"), "canvas-workspaces");
|
||||||
|
}
|
||||||
|
|
||||||
|
function indexPath() {
|
||||||
|
return path.join(rootDir(), "index.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
function workspaceDir(id) {
|
||||||
|
assertId(id);
|
||||||
|
return path.join(rootDir(), id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function workspacePath(id) {
|
||||||
|
return path.join(workspaceDir(id), "canvas.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertId(id) {
|
||||||
|
if (!ID_RE.test(String(id || ""))) throw new Error("非法画布标识");
|
||||||
|
}
|
||||||
|
|
||||||
|
function atomicWrite(file, content) {
|
||||||
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||||
|
const tmp = file + "." + process.pid + ".tmp";
|
||||||
|
fs.writeFileSync(tmp, content);
|
||||||
|
try {
|
||||||
|
fs.renameSync(tmp, file);
|
||||||
|
} catch (err) {
|
||||||
|
// Windows 不一定允许 rename 覆盖现有文件,明确兜底后再完成替换。
|
||||||
|
if (process.platform !== "win32" || !["EEXIST", "EPERM", "EACCES"].includes(err && err.code)) {
|
||||||
|
try { fs.rmSync(tmp, { force: true }); } catch {}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
fs.rmSync(file, { force: true });
|
||||||
|
fs.renameSync(tmp, file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function atomicWriteAsync(file, content) {
|
||||||
|
await fs.promises.mkdir(path.dirname(file), { recursive: true });
|
||||||
|
const tmp = file + "." + process.pid + "." + Date.now() + ".tmp";
|
||||||
|
await fs.promises.writeFile(tmp, content);
|
||||||
|
try {
|
||||||
|
await fs.promises.rename(tmp, file);
|
||||||
|
} catch (err) {
|
||||||
|
if (process.platform !== "win32" || !["EEXIST", "EPERM", "EACCES"].includes(err && err.code)) {
|
||||||
|
try { await fs.promises.rm(tmp, { force: true }); } catch {}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
await fs.promises.rm(file, { force: true });
|
||||||
|
await fs.promises.rename(tmp, file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeJson(file, fallback) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(fs.readFileSync(file, "utf8"));
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTitle(value) {
|
||||||
|
const title = String(value || "未命名画布").replace(/[\r\n]+/g, " ").trim();
|
||||||
|
return (title || "未命名画布").slice(0, 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readIndex() {
|
||||||
|
const data = safeJson(indexPath(), { version: STORE_VERSION, workspaces: [] });
|
||||||
|
const workspaces = Array.isArray(data.workspaces) ? data.workspaces : [];
|
||||||
|
return { version: STORE_VERSION, workspaces };
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeIndex(index) {
|
||||||
|
atomicWrite(indexPath(), JSON.stringify(index, null, 2) + "\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function summary(workspace) {
|
||||||
|
const out = workspace && workspace.outputs && typeof workspace.outputs === "object" ? workspace.outputs : {};
|
||||||
|
const assetIds = new Set();
|
||||||
|
for (const value of Object.values(out)) {
|
||||||
|
if (value && value.assetId) assetIds.add(value.assetId);
|
||||||
|
for (const item of value && Array.isArray(value.history) ? value.history : []) {
|
||||||
|
if (item && item.assetId) assetIds.add(item.assetId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const materials = workspace && workspace.materials && typeof workspace.materials === "object" ? workspace.materials : {};
|
||||||
|
for (const value of Object.values(materials)) {
|
||||||
|
if (!value || typeof value !== "object") continue;
|
||||||
|
for (const slot of ["image", "first", "last"]) {
|
||||||
|
if (value[slot] && value[slot].assetId) assetIds.add(value[slot].assetId);
|
||||||
|
}
|
||||||
|
for (const item of Array.isArray(value.refs) ? value.refs : []) {
|
||||||
|
if (item && item.assetId) assetIds.add(item.assetId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const nodes = workspace && workspace.canvas && workspace.canvas.drawflow && workspace.canvas.drawflow.drawflow;
|
||||||
|
const data = nodes && nodes.Home && nodes.Home.data;
|
||||||
|
return { nodeCount: data ? Object.keys(data).length : 0, mediaCount: assetIds.size };
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateIndex(workspace) {
|
||||||
|
const index = readIndex();
|
||||||
|
const meta = {
|
||||||
|
id: workspace.id,
|
||||||
|
name: normalizeTitle(workspace.name),
|
||||||
|
createdAt: workspace.createdAt,
|
||||||
|
updatedAt: workspace.updatedAt,
|
||||||
|
...summary(workspace),
|
||||||
|
};
|
||||||
|
const existing = index.workspaces.findIndex((item) => item.id === meta.id);
|
||||||
|
if (existing >= 0) index.workspaces[existing] = meta;
|
||||||
|
else index.workspaces.push(meta);
|
||||||
|
index.workspaces.sort((a, b) => String(b.updatedAt || "").localeCompare(String(a.updatedAt || "")));
|
||||||
|
writeIndex(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
function list() {
|
||||||
|
return readIndex().workspaces.sort((a, b) => String(b.updatedAt || "").localeCompare(String(a.updatedAt || "")));
|
||||||
|
}
|
||||||
|
|
||||||
|
function create(name) {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const id = "cv_" + crypto.randomUUID().replace(/-/g, "");
|
||||||
|
const workspace = {
|
||||||
|
version: STORE_VERSION,
|
||||||
|
id,
|
||||||
|
name: normalizeTitle(name),
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
canvas: { drawflow: { drawflow: { Home: { data: {} } } }, module: "Home", zoom: 1, canvasX: 0, canvasY: 0, addCount: 0 },
|
||||||
|
nodeState: {},
|
||||||
|
outputs: {},
|
||||||
|
materials: {},
|
||||||
|
};
|
||||||
|
atomicWrite(workspacePath(id), JSON.stringify(workspace, null, 2) + "\n");
|
||||||
|
updateIndex(workspace);
|
||||||
|
return workspace;
|
||||||
|
}
|
||||||
|
|
||||||
|
function load(id) {
|
||||||
|
assertId(id);
|
||||||
|
const workspace = safeJson(workspacePath(id), null);
|
||||||
|
if (!workspace || workspace.id !== id) throw new Error("画布记录不存在或已损坏");
|
||||||
|
return workspace;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rename(id, name) {
|
||||||
|
assertId(id);
|
||||||
|
const title = String(name || "").replace(/[\r\n]+/g, " " ).trim();
|
||||||
|
if (!title) throw new Error("画布名称不能为空");
|
||||||
|
const workspace = load(id);
|
||||||
|
workspace.name = normalizeTitle(title);
|
||||||
|
workspace.updatedAt = new Date().toISOString();
|
||||||
|
atomicWrite(workspacePath(id), JSON.stringify(workspace, null, 2) + "\n");
|
||||||
|
updateIndex(workspace);
|
||||||
|
return { id, name: workspace.name, updatedAt: workspace.updatedAt, ...summary(workspace) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function save(payload) {
|
||||||
|
if (!payload || typeof payload !== "object") throw new Error("画布保存数据无效");
|
||||||
|
assertId(payload.id);
|
||||||
|
const previous = load(payload.id);
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const workspace = {
|
||||||
|
version: STORE_VERSION,
|
||||||
|
id: payload.id,
|
||||||
|
name: normalizeTitle(payload.name || previous.name),
|
||||||
|
createdAt: previous.createdAt || now,
|
||||||
|
updatedAt: now,
|
||||||
|
canvas: payload.canvas && typeof payload.canvas === "object" ? payload.canvas : previous.canvas,
|
||||||
|
nodeState: payload.nodeState && typeof payload.nodeState === "object" ? payload.nodeState : {},
|
||||||
|
outputs: payload.outputs && typeof payload.outputs === "object" ? payload.outputs : {},
|
||||||
|
materials: payload.materials && typeof payload.materials === "object" ? payload.materials : {},
|
||||||
|
};
|
||||||
|
atomicWrite(workspacePath(workspace.id), JSON.stringify(workspace, null, 2) + "\n");
|
||||||
|
updateIndex(workspace);
|
||||||
|
return { id: workspace.id, name: workspace.name, updatedAt: workspace.updatedAt, ...summary(workspace) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function dataUrlParts(dataUrl) {
|
||||||
|
const match = /^data:([^;,]+)?(;base64)?,(.*)$/is.exec(String(dataUrl || ""));
|
||||||
|
if (!match) throw new Error("媒体缓存失败:非法 data URL");
|
||||||
|
const mime = match[1] || "application/octet-stream";
|
||||||
|
const data = Buffer.from(match[3], match[2] ? "base64" : "utf8");
|
||||||
|
if (!data.length) throw new Error("媒体缓存失败:内容为空");
|
||||||
|
return { mime, data };
|
||||||
|
}
|
||||||
|
|
||||||
|
function extension(mime, kind) {
|
||||||
|
if (/png/i.test(mime)) return "png";
|
||||||
|
if (/jpe?g/i.test(mime)) return "jpg";
|
||||||
|
if (/webp/i.test(mime)) return "webp";
|
||||||
|
if (/gif/i.test(mime)) return "gif";
|
||||||
|
if (/mp4/i.test(mime) || kind === "video" || kind === "grokVideo") return "mp4";
|
||||||
|
if (/wav/i.test(mime)) return "wav";
|
||||||
|
if (/ogg|opus/i.test(mime)) return "opus";
|
||||||
|
if (/mpeg|mp3/i.test(mime) || kind === "tts") return "mp3";
|
||||||
|
return "bin";
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeOutputs(snapshotOutputs, storedOutputs) {
|
||||||
|
return {
|
||||||
|
...(snapshotOutputs && typeof snapshotOutputs === "object" ? snapshotOutputs : {}),
|
||||||
|
...(storedOutputs && typeof storedOutputs === "object" ? storedOutputs : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeMaterials(snapshotMaterials, storedMaterials) {
|
||||||
|
const snapshot = snapshotMaterials && typeof snapshotMaterials === "object" ? snapshotMaterials : {};
|
||||||
|
const stored = storedMaterials && typeof storedMaterials === "object" ? storedMaterials : {};
|
||||||
|
const merged = { ...snapshot };
|
||||||
|
for (const [nodeId, storedRefs] of Object.entries(stored)) {
|
||||||
|
const snapshotRefs = snapshot[nodeId] && typeof snapshot[nodeId] === "object" ? snapshot[nodeId] : {};
|
||||||
|
const diskRefs = storedRefs && typeof storedRefs === "object" ? storedRefs : {};
|
||||||
|
const refs = [];
|
||||||
|
const seen = new Set();
|
||||||
|
for (const item of [...(Array.isArray(snapshotRefs.refs) ? snapshotRefs.refs : []), ...(Array.isArray(diskRefs.refs) ? diskRefs.refs : [])]) {
|
||||||
|
if (!item || !item.assetId || seen.has(item.assetId)) continue;
|
||||||
|
seen.add(item.assetId);
|
||||||
|
refs.push(item);
|
||||||
|
}
|
||||||
|
merged[nodeId] = { ...snapshotRefs, ...diskRefs, refs };
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cacheMedia({ workspaceId, nodeId, kind, dataUrl, role, slot, sourceUrl, meta, workspaceState }) {
|
||||||
|
assertId(workspaceId);
|
||||||
|
if (!/^\d+$/.test(String(nodeId || ""))) throw new Error("非法节点标识");
|
||||||
|
const { mime, data } = dataUrlParts(dataUrl);
|
||||||
|
const hash = crypto.createHash("sha256").update(data).digest("hex").slice(0, 16);
|
||||||
|
const assetId = `node-${nodeId}-${Date.now()}-${hash}.${extension(mime, kind)}`;
|
||||||
|
const file = path.join(workspaceDir(workspaceId), "media", assetId);
|
||||||
|
await atomicWriteAsync(file, data);
|
||||||
|
// 文件写入期间可能触发画布自动保存。落盘后重新读取,始终从最新存档合并媒体索引。
|
||||||
|
const previous = load(workspaceId);
|
||||||
|
const workspace = workspaceState && workspaceState.id === workspaceId
|
||||||
|
? {
|
||||||
|
...previous,
|
||||||
|
name: normalizeTitle(workspaceState.name || previous.name),
|
||||||
|
canvas: workspaceState.canvas || previous.canvas,
|
||||||
|
nodeState: workspaceState.nodeState || previous.nodeState,
|
||||||
|
outputs: mergeOutputs(workspaceState.outputs, previous.outputs),
|
||||||
|
materials: mergeMaterials(workspaceState.materials, previous.materials),
|
||||||
|
}
|
||||||
|
: previous;
|
||||||
|
const ref = { assetId };
|
||||||
|
if (role === "output") {
|
||||||
|
workspace.outputs = workspace.outputs && typeof workspace.outputs === "object" ? workspace.outputs : {};
|
||||||
|
const previousOutput = workspace.outputs[nodeId];
|
||||||
|
const history = previousOutput && Array.isArray(previousOutput.history) ? previousOutput.history.slice() : [];
|
||||||
|
if (previousOutput && previousOutput.assetId && previousOutput.assetId !== assetId) {
|
||||||
|
history.push({
|
||||||
|
kind: previousOutput.kind,
|
||||||
|
assetId: previousOutput.assetId,
|
||||||
|
sourceUrl: previousOutput.sourceUrl || "",
|
||||||
|
meta: previousOutput.meta || {},
|
||||||
|
cachedAt: previousOutput.cachedAt || workspace.updatedAt || workspace.createdAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
workspace.outputs[nodeId] = {
|
||||||
|
kind, assetId, sourceUrl: String(sourceUrl || ""),
|
||||||
|
meta: meta && typeof meta === "object" ? meta : {},
|
||||||
|
cachedAt: new Date().toISOString(),
|
||||||
|
history,
|
||||||
|
};
|
||||||
|
} else if (role === "material") {
|
||||||
|
workspace.materials = workspace.materials && typeof workspace.materials === "object" ? workspace.materials : {};
|
||||||
|
const refs = (workspace.materials[nodeId] = workspace.materials[nodeId] || { refs: [] });
|
||||||
|
if (slot === "refs") refs.refs = (Array.isArray(refs.refs) ? refs.refs : []).concat(ref);
|
||||||
|
else if (["image", "first", "last"].includes(slot)) refs[slot] = ref;
|
||||||
|
}
|
||||||
|
if (role === "output" || role === "material") {
|
||||||
|
workspace.updatedAt = new Date().toISOString();
|
||||||
|
atomicWrite(workspacePath(workspaceId), JSON.stringify(workspace, null, 2) + "\n");
|
||||||
|
updateIndex(workspace);
|
||||||
|
}
|
||||||
|
return { assetId, mime, bytes: data.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readMedia({ workspaceId, assetId }) {
|
||||||
|
assertId(workspaceId);
|
||||||
|
if (!MEDIA_RE.test(String(assetId || ""))) throw new Error("非法媒体标识");
|
||||||
|
const file = path.join(workspaceDir(workspaceId), "media", assetId);
|
||||||
|
try { await fs.promises.access(file, fs.constants.R_OK); }
|
||||||
|
catch { throw new Error("本地缓存媒体不存在"); }
|
||||||
|
const ext = path.extname(assetId).slice(1).toLowerCase();
|
||||||
|
const mime = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", webp: "image/webp", gif: "image/gif", mp4: "video/mp4", wav: "audio/wav", mp3: "audio/mpeg", opus: "audio/opus" }[ext] || "application/octet-stream";
|
||||||
|
return { dataUrl: `data:${mime};base64,${(await fs.promises.readFile(file)).toString("base64")}`, mime };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { list, create, load, rename, save, cacheMedia, readMedia };
|
||||||
+30
-15
@@ -3,11 +3,19 @@
|
|||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
const os = require("os");
|
const os = require("os");
|
||||||
|
const codexDataDir = require("./codex_data_dir");
|
||||||
|
|
||||||
const CODEX_HOME = path.join(os.homedir(), ".codex");
|
const DEFAULT_CODEX_HOME = path.join(os.homedir(), ".codex");
|
||||||
const CONFIG_PATH = path.join(CODEX_HOME, "config.toml");
|
|
||||||
const ENABLED_FILE = "enabled-plugins.toml"; // 备份包里记录启用状态的文件名
|
const ENABLED_FILE = "enabled-plugins.toml"; // 备份包里记录启用状态的文件名
|
||||||
|
|
||||||
|
function getCodexHome() {
|
||||||
|
try { return codexDataDir.get(); } catch { return DEFAULT_CODEX_HOME; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function getConfigPath() {
|
||||||
|
return path.join(getCodexHome(), "config.toml");
|
||||||
|
}
|
||||||
|
|
||||||
// 打包时【绝对排除】的隐私 / 垃圾文件(就算勾了也不会被打包上传)
|
// 打包时【绝对排除】的隐私 / 垃圾文件(就算勾了也不会被打包上传)
|
||||||
const BLOCKLIST_NAMES = new Set([
|
const BLOCKLIST_NAMES = new Set([
|
||||||
"auth.json",
|
"auth.json",
|
||||||
@@ -172,8 +180,9 @@ function configPluginHeaders(text) {
|
|||||||
|
|
||||||
// 把选中插件在本机的启用状态,写进备份包的 enabled-plugins.toml。返回记录的 keys。
|
// 把选中插件在本机的启用状态,写进备份包的 enabled-plugins.toml。返回记录的 keys。
|
||||||
function writeEnabledFile(selected, staging) {
|
function writeEnabledFile(selected, staging) {
|
||||||
const srcText = fs.existsSync(CONFIG_PATH)
|
const configPath = getConfigPath();
|
||||||
? fs.readFileSync(CONFIG_PATH, "utf-8")
|
const srcText = fs.existsSync(configPath)
|
||||||
|
? fs.readFileSync(configPath, "utf-8")
|
||||||
: "";
|
: "";
|
||||||
const enabledNow = configPluginHeaders(srcText);
|
const enabledNow = configPluginHeaders(srcText);
|
||||||
const keys = selected
|
const keys = selected
|
||||||
@@ -308,9 +317,10 @@ function packageItems(selected, staging, log) {
|
|||||||
function enablePluginsInConfig(keys, log) {
|
function enablePluginsInConfig(keys, log) {
|
||||||
keys = keys.filter((k) => k);
|
keys = keys.filter((k) => k);
|
||||||
if (!keys.length) return;
|
if (!keys.length) return;
|
||||||
fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true });
|
const configPath = getConfigPath();
|
||||||
let text = fs.existsSync(CONFIG_PATH)
|
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
||||||
? fs.readFileSync(CONFIG_PATH, "utf-8")
|
let text = fs.existsSync(configPath)
|
||||||
|
? fs.readFileSync(configPath, "utf-8")
|
||||||
: "";
|
: "";
|
||||||
const existing = configPluginHeaders(text);
|
const existing = configPluginHeaders(text);
|
||||||
const toAdd = keys.filter((k) => !existing.has(k));
|
const toAdd = keys.filter((k) => !existing.has(k));
|
||||||
@@ -319,9 +329,9 @@ function enablePluginsInConfig(keys, log) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// 备份
|
// 备份
|
||||||
if (fs.existsSync(CONFIG_PATH)) {
|
if (fs.existsSync(configPath)) {
|
||||||
const bak = CONFIG_PATH + ".bak-exporter"; // config.toml.bak-exporter
|
const bak = configPath + ".bak-exporter"; // config.toml.bak-exporter
|
||||||
fs.copyFileSync(CONFIG_PATH, bak);
|
fs.copyFileSync(configPath, bak);
|
||||||
log(` 已备份 config.toml -> ${path.basename(bak)}`);
|
log(` 已备份 config.toml -> ${path.basename(bak)}`);
|
||||||
}
|
}
|
||||||
if (text && !text.endsWith("\n")) {
|
if (text && !text.endsWith("\n")) {
|
||||||
@@ -333,14 +343,15 @@ function enablePluginsInConfig(keys, log) {
|
|||||||
blocks.push("enabled = true");
|
blocks.push("enabled = true");
|
||||||
blocks.push("");
|
blocks.push("");
|
||||||
}
|
}
|
||||||
fs.writeFileSync(CONFIG_PATH, text + blocks.join("\n"), "utf-8");
|
fs.writeFileSync(configPath, text + blocks.join("\n"), "utf-8");
|
||||||
log(` 已在 config.toml 启用 ${toAdd.length} 个插件:${toAdd.join(", ")}`);
|
log(` 已在 config.toml 启用 ${toAdd.length} 个插件:${toAdd.join(", ")}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 把选中的(来自 clone 的)项拷回 ~/.codex,并可选地在 config.toml 里启用插件。
|
// 把选中的(来自 clone 的)项拷回 ~/.codex,并可选地在 config.toml 里启用插件。
|
||||||
function restoreItems(selected, log, enable = true) {
|
function restoreItems(selected, log, enable = true) {
|
||||||
|
const codexHome = getCodexHome();
|
||||||
for (const it of selected) {
|
for (const it of selected) {
|
||||||
const dst = path.join(CODEX_HOME, it.rel);
|
const dst = path.join(codexHome, it.rel);
|
||||||
fs.mkdirSync(path.dirname(dst), { recursive: true });
|
fs.mkdirSync(path.dirname(dst), { recursive: true });
|
||||||
log(` 还原 ${it.category}: ${it.name} -> ${dst}`);
|
log(` 还原 ${it.category}: ${it.name} -> ${dst}`);
|
||||||
copyClean(it.path, dst);
|
copyClean(it.path, dst);
|
||||||
@@ -352,9 +363,8 @@ function restoreItems(selected, log, enable = true) {
|
|||||||
log("还原完成 ✅ 重启 codex 生效");
|
log("还原完成 ✅ 重启 codex 生效");
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
const api = {
|
||||||
CODEX_HOME,
|
getCodexHome,
|
||||||
CONFIG_PATH,
|
|
||||||
ENABLED_FILE,
|
ENABLED_FILE,
|
||||||
humanSize,
|
humanSize,
|
||||||
dirSize,
|
dirSize,
|
||||||
@@ -366,3 +376,8 @@ module.exports = {
|
|||||||
enablePluginsInConfig,
|
enablePluginsInConfig,
|
||||||
restoreItems,
|
restoreItems,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
Object.defineProperty(api, "CODEX_HOME", { enumerable: true, get: getCodexHome });
|
||||||
|
Object.defineProperty(api, "CONFIG_PATH", { enumerable: true, get: getConfigPath });
|
||||||
|
|
||||||
|
module.exports = api;
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
// codex_data_dir.js —— 会话相关功能使用的 Codex 数据目录,可由用户覆盖默认 ~/.codex。
|
||||||
|
const { app } = require("electron");
|
||||||
|
const fs = require("fs");
|
||||||
|
const os = require("os");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const DEFAULT_DIR = path.join(os.homedir(), ".codex");
|
||||||
|
let loaded = false;
|
||||||
|
let configuredDir = null;
|
||||||
|
|
||||||
|
function configFile() {
|
||||||
|
return path.join(app.getPath("userData"), "codex-data-dir.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalize(dir) {
|
||||||
|
if (typeof dir !== "string" || !dir.trim() || !path.isAbsolute(dir.trim())) return null;
|
||||||
|
return path.resolve(dir.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function load() {
|
||||||
|
if (loaded) return;
|
||||||
|
loaded = true;
|
||||||
|
try {
|
||||||
|
configuredDir = normalize(JSON.parse(fs.readFileSync(configFile(), "utf-8")).path);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function get() {
|
||||||
|
load();
|
||||||
|
return configuredDir || DEFAULT_DIR;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCodexDataDir(dir) {
|
||||||
|
try {
|
||||||
|
const names = fs.readdirSync(dir);
|
||||||
|
return names.includes("sessions") || names.includes("archived_sessions") || names.includes("config.toml") ||
|
||||||
|
names.includes("auth.json") || names.some((name) => /^state_.*\.sqlite(?:-(?:wal|shm))?$/.test(name));
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDirectory(dir) {
|
||||||
|
try {
|
||||||
|
return fs.statSync(dir).isDirectory();
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeAtomic(file, text) {
|
||||||
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||||
|
const tmp = file + ".tmp-" + process.pid + "-" + Date.now();
|
||||||
|
const fd = fs.openSync(tmp, "w", 0o600);
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(fd, text, "utf-8");
|
||||||
|
fs.fsyncSync(fd);
|
||||||
|
} finally {
|
||||||
|
fs.closeSync(fd);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
fs.renameSync(tmp, file);
|
||||||
|
} finally {
|
||||||
|
try { fs.rmSync(tmp, { force: true }); } catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function set(dir) {
|
||||||
|
const next = normalize(dir);
|
||||||
|
if (!next || !isDirectory(next)) {
|
||||||
|
throw new Error("请选择存在的 Codex 数据目录");
|
||||||
|
}
|
||||||
|
if (!isCodexDataDir(next)) {
|
||||||
|
throw new Error("所选目录未找到 Codex 数据(sessions、config.toml 或 state_*.sqlite)");
|
||||||
|
}
|
||||||
|
configuredDir = next === DEFAULT_DIR ? null : next;
|
||||||
|
loaded = true;
|
||||||
|
writeAtomic(configFile(), JSON.stringify({ path: configuredDir || "", updatedAt: new Date().toISOString() }, null, 2));
|
||||||
|
return info();
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
configuredDir = null;
|
||||||
|
loaded = true;
|
||||||
|
writeAtomic(configFile(), JSON.stringify({ path: "", updatedAt: new Date().toISOString() }, null, 2));
|
||||||
|
return info();
|
||||||
|
}
|
||||||
|
|
||||||
|
function info() {
|
||||||
|
const dir = get();
|
||||||
|
return { path: dir, isDefault: dir === DEFAULT_DIR, exists: isDirectory(dir) };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { get, set, reset, info, isCodexDataDir };
|
||||||
@@ -0,0 +1,537 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
const fs = require("fs");
|
||||||
|
const os = require("os");
|
||||||
|
const path = require("path");
|
||||||
|
const { execFile, execFileSync } = require("child_process");
|
||||||
|
|
||||||
|
const EXE_NAMES = process.platform === "win32"
|
||||||
|
? ["ChatGPT.exe", "Codex.exe", "OpenAI ChatGPT.exe", "ChatGPT Desktop.exe", "Codex Desktop.exe"]
|
||||||
|
: ["ChatGPT", "Codex"];
|
||||||
|
|
||||||
|
const WIN_PROCESS_NAMES = EXE_NAMES.filter((n) => /\.exe$/i.test(n));
|
||||||
|
let lastDiscovery = null;
|
||||||
|
|
||||||
|
function existsFile(p) {
|
||||||
|
try {
|
||||||
|
return !!p && fs.statSync(p).isFile();
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWindowsPackagedPath(p) {
|
||||||
|
return process.platform === "win32" && /[\\/]WindowsApps[\\/]|[\\/]Program Files[\\/]WindowsApps[\\/]/i.test(String(p || ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOwnWindowsAppCandidate(name, appId) {
|
||||||
|
const hay = [name, appId].filter(Boolean).join(" ");
|
||||||
|
return /tokendun|codexmarket|codex-market|td工具箱|td toolbox|插件市场|技能市场/i.test(hay);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOpenAiDesktopCandidate(name, appId) {
|
||||||
|
if (isOwnWindowsAppCandidate(name, appId)) return false;
|
||||||
|
const n = String(name || "").trim();
|
||||||
|
const hay = [name, appId].filter(Boolean).join(" ");
|
||||||
|
return /openai|chatgpt/i.test(hay) || /(^|[^a-z])codex([^a-z]|$)/i.test(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addCandidate(out, seen, label, p, opts = {}) {
|
||||||
|
if (!p) return;
|
||||||
|
if (opts.launchType === "appId") {
|
||||||
|
const appId = String(opts.appId || p || "").trim();
|
||||||
|
if (!appId || isOwnWindowsAppCandidate(opts.name || "", appId)) return;
|
||||||
|
const key = "appid:" + appId.toLowerCase();
|
||||||
|
if (seen.has(key)) return;
|
||||||
|
seen.add(key);
|
||||||
|
out.push({ label, path: appId, appId, name: opts.name || "", launchType: "appId", packaged: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let abs = "";
|
||||||
|
try {
|
||||||
|
abs = path.resolve(p);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const key = process.platform === "win32" ? abs.toLowerCase() : abs;
|
||||||
|
if (seen.has(key) || !existsFile(abs)) return;
|
||||||
|
seen.add(key);
|
||||||
|
out.push({
|
||||||
|
label,
|
||||||
|
path: abs,
|
||||||
|
launchType: opts.launchType || "exe",
|
||||||
|
packaged: !!opts.packaged || isWindowsPackagedPath(abs),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function addChecked(report, p) {
|
||||||
|
if (!report || !p) return;
|
||||||
|
try {
|
||||||
|
report.checkedPaths.push(path.resolve(p));
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldScanDir(name, depth) {
|
||||||
|
if (!name) return false;
|
||||||
|
if (/^(programs)$/i.test(name)) return true;
|
||||||
|
if (/codex|chatgpt|openai/i.test(name)) return true;
|
||||||
|
return depth === 0 && process.platform !== "win32";
|
||||||
|
}
|
||||||
|
|
||||||
|
function scanDirForExecutables(base, out, seen, opts = {}) {
|
||||||
|
const maxDepth = opts.maxDepth == null ? 4 : opts.maxDepth;
|
||||||
|
const maxEntries = opts.maxEntries == null ? 400 : opts.maxEntries;
|
||||||
|
const maxHits = opts.maxHits == null ? 12 : opts.maxHits;
|
||||||
|
const report = opts.report || null;
|
||||||
|
const targetNames = new Set(EXE_NAMES.map((n) => process.platform === "win32" ? n.toLowerCase() : n));
|
||||||
|
let visited = 0;
|
||||||
|
if (report && base) report.scannedRoots.push(base);
|
||||||
|
|
||||||
|
function walk(dir, depth) {
|
||||||
|
if (!dir || depth > maxDepth || out.length >= maxHits || visited >= maxEntries) return;
|
||||||
|
let entries;
|
||||||
|
try {
|
||||||
|
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
visited += entries.length;
|
||||||
|
for (const ent of entries) {
|
||||||
|
if (out.length >= maxHits) return;
|
||||||
|
const full = path.join(dir, ent.name);
|
||||||
|
const entName = process.platform === "win32" ? ent.name.toLowerCase() : ent.name;
|
||||||
|
if (ent.isFile() && targetNames.has(entName)) {
|
||||||
|
addCandidate(out, seen, "ChatGPT/Codex 桌面程序(扫描)", full);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ent.isDirectory() && depth < maxDepth && shouldScanDir(ent.name, depth)) {
|
||||||
|
walk(full, depth + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
walk(base, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function psJson(script, timeout = 3500) {
|
||||||
|
if (process.platform !== "win32") return null;
|
||||||
|
const shells = ["powershell.exe", "pwsh.exe"];
|
||||||
|
for (const shell of shells) {
|
||||||
|
try {
|
||||||
|
const out = execFileSync(shell, ["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script], {
|
||||||
|
encoding: "utf-8",
|
||||||
|
windowsHide: true,
|
||||||
|
timeout,
|
||||||
|
}).trim();
|
||||||
|
if (!out) return null;
|
||||||
|
return JSON.parse(out);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveWindowsShortcut(lnkPath) {
|
||||||
|
if (process.platform !== "win32" || !lnkPath) return null;
|
||||||
|
const script = [
|
||||||
|
"$p=$env:TD_LNK_PATH",
|
||||||
|
"$s=(New-Object -ComObject WScript.Shell).CreateShortcut($p)",
|
||||||
|
"$o=[pscustomobject]@{TargetPath=$s.TargetPath;Arguments=$s.Arguments;WorkingDirectory=$s.WorkingDirectory}",
|
||||||
|
"$o|ConvertTo-Json -Compress",
|
||||||
|
].join(";");
|
||||||
|
const shells = ["powershell.exe", "pwsh.exe"];
|
||||||
|
for (const shell of shells) {
|
||||||
|
try {
|
||||||
|
const out = execFileSync(shell, ["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script], {
|
||||||
|
encoding: "utf-8",
|
||||||
|
env: { ...process.env, TD_LNK_PATH: lnkPath },
|
||||||
|
windowsHide: true,
|
||||||
|
timeout: 2500,
|
||||||
|
}).trim();
|
||||||
|
if (!out) return null;
|
||||||
|
return JSON.parse(out);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectWindowsShortcuts(base, out, opts = {}) {
|
||||||
|
if (process.platform !== "win32" || !base) return;
|
||||||
|
const maxDepth = opts.maxDepth == null ? 5 : opts.maxDepth;
|
||||||
|
const maxEntries = opts.maxEntries == null ? 900 : opts.maxEntries;
|
||||||
|
const maxHits = opts.maxHits == null ? 24 : opts.maxHits;
|
||||||
|
let visited = 0;
|
||||||
|
|
||||||
|
function walk(dir, depth) {
|
||||||
|
if (!dir || depth > maxDepth || out.length >= maxHits || visited >= maxEntries) return;
|
||||||
|
let entries;
|
||||||
|
try {
|
||||||
|
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
visited += entries.length;
|
||||||
|
for (const ent of entries) {
|
||||||
|
if (out.length >= maxHits) return;
|
||||||
|
const full = path.join(dir, ent.name);
|
||||||
|
if (ent.isFile() && /\.lnk$/i.test(ent.name) && /codex|chatgpt|openai/i.test(ent.name)) {
|
||||||
|
out.push(full);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ent.isDirectory() && depth < maxDepth && shouldScanDir(ent.name, depth)) {
|
||||||
|
walk(full, depth + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
walk(base, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addSquirrelShortcutTargets(out, seen, report, shortcut, targetPath, args) {
|
||||||
|
if (!targetPath || !/update\.exe$/i.test(path.basename(targetPath || ""))) return;
|
||||||
|
const m = String(args || "").match(/--processStart\s+(?:"([^"]+)"|([^\s]+))/i);
|
||||||
|
const exeName = (m && (m[1] || m[2])) || "";
|
||||||
|
if (!exeName || !/\.exe$/i.test(exeName)) return;
|
||||||
|
const root = path.dirname(targetPath);
|
||||||
|
let entries = [];
|
||||||
|
try {
|
||||||
|
entries = fs.readdirSync(root, { withFileTypes: true });
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const appDirs = entries
|
||||||
|
.filter((ent) => ent.isDirectory() && /^app-/i.test(ent.name))
|
||||||
|
.map((ent) => path.join(root, ent.name))
|
||||||
|
.sort()
|
||||||
|
.reverse();
|
||||||
|
for (const dir of appDirs) {
|
||||||
|
const exe = path.join(dir, exeName);
|
||||||
|
addChecked(report, exe);
|
||||||
|
addCandidate(out, seen, "ChatGPT/Codex 桌面程序(开始菜单快捷方式)", exe);
|
||||||
|
}
|
||||||
|
if (report) report.shortcutTargets.push({ shortcut, targetPath, args: args || "", inferred: exeName });
|
||||||
|
}
|
||||||
|
|
||||||
|
function addWindowsShortcutCandidates(out, seen, report, roots) {
|
||||||
|
const shortcuts = [];
|
||||||
|
for (const root of roots.filter(Boolean)) collectWindowsShortcuts(root, shortcuts);
|
||||||
|
for (const lnk of shortcuts) {
|
||||||
|
if (report) report.shortcuts.push(lnk);
|
||||||
|
const resolved = resolveWindowsShortcut(lnk);
|
||||||
|
if (!resolved) continue;
|
||||||
|
const targetPath = resolved.TargetPath || resolved.targetPath || "";
|
||||||
|
const args = resolved.Arguments || resolved.arguments || "";
|
||||||
|
if (report) report.shortcutTargets.push({ shortcut: lnk, targetPath, args });
|
||||||
|
if (targetPath && /\.exe$/i.test(targetPath) && !/update\.exe$/i.test(path.basename(targetPath))) {
|
||||||
|
addChecked(report, targetPath);
|
||||||
|
addCandidate(out, seen, "ChatGPT/Codex 桌面程序(开始菜单快捷方式)", targetPath);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
addSquirrelShortcutTargets(out, seen, report, lnk, targetPath, args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addWindowsAppxCandidates(out, seen, report) {
|
||||||
|
const script = [
|
||||||
|
"$startApps=Get-StartApps | Where-Object { $_.Name -match 'ChatGPT|Codex|OpenAI' -or $_.AppID -match 'ChatGPT|Codex|OpenAI' }",
|
||||||
|
"$startRows=@()",
|
||||||
|
"foreach($a in $startApps){ $startRows += [pscustomobject]@{Name=$a.Name;AppID=$a.AppID} }",
|
||||||
|
"$pkgs=Get-AppxPackage | Where-Object { $_.Name -match 'ChatGPT|Codex|OpenAI' -or $_.PackageFullName -match 'ChatGPT|Codex|OpenAI' }",
|
||||||
|
"$rows=@()",
|
||||||
|
"$appIds=@()",
|
||||||
|
"foreach($p in $pkgs){",
|
||||||
|
" $loc=$p.InstallLocation",
|
||||||
|
" if($loc){",
|
||||||
|
" $manifestPath=Join-Path $loc 'AppxManifest.xml'",
|
||||||
|
" if(Test-Path -LiteralPath $manifestPath){",
|
||||||
|
" try{",
|
||||||
|
" [xml]$manifest=Get-Content -LiteralPath $manifestPath -ErrorAction Stop",
|
||||||
|
" foreach($app in @($manifest.Package.Applications.Application)){",
|
||||||
|
" $id=[string]$app.Id",
|
||||||
|
" if($id){ $appIds += [pscustomobject]@{Package=$p.Name;Family=$p.PackageFamilyName;AppID=($p.PackageFamilyName + '!' + $id);InstallLocation=$loc} }",
|
||||||
|
" }",
|
||||||
|
" }catch{}",
|
||||||
|
" }",
|
||||||
|
" $exes=Get-ChildItem -LiteralPath $loc -Recurse -Filter *.exe -ErrorAction SilentlyContinue | Where-Object { $_.Name -match 'ChatGPT|Codex|OpenAI' } | Select-Object -First 8",
|
||||||
|
" foreach($e in $exes){ $rows += [pscustomobject]@{Package=$p.Name;Path=$e.FullName} }",
|
||||||
|
" }",
|
||||||
|
"}",
|
||||||
|
"[pscustomobject]@{StartApps=$startRows;Packages=$rows;AppIds=$appIds}|ConvertTo-Json -Depth 5 -Compress",
|
||||||
|
].join(";");
|
||||||
|
const data = psJson(script, 6500);
|
||||||
|
const startApps = data && data.StartApps ? (Array.isArray(data.StartApps) ? data.StartApps : [data.StartApps]) : [];
|
||||||
|
const arr = data && data.Packages ? (Array.isArray(data.Packages) ? data.Packages : [data.Packages]) : [];
|
||||||
|
const appIds = data && data.AppIds ? (Array.isArray(data.AppIds) ? data.AppIds : [data.AppIds]) : [];
|
||||||
|
for (const app of startApps) {
|
||||||
|
const appId = app && app.AppID ? String(app.AppID) : "";
|
||||||
|
const name = app && app.Name ? String(app.Name) : "";
|
||||||
|
if (!appId || !isOpenAiDesktopCandidate(name, appId)) continue;
|
||||||
|
if (report) report.appx.push({ package: "StartApps", name: app.Name || "", appId });
|
||||||
|
addCandidate(out, seen, "ChatGPT/Codex 桌面程序(开始菜单 AppID)", appId, {
|
||||||
|
launchType: "appId",
|
||||||
|
appId,
|
||||||
|
name,
|
||||||
|
packaged: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const app of appIds) {
|
||||||
|
const appId = app && app.AppID ? String(app.AppID) : "";
|
||||||
|
const name = app && app.Package ? String(app.Package) : "";
|
||||||
|
if (!appId || !isOpenAiDesktopCandidate(name, appId)) continue;
|
||||||
|
if (report) report.appx.push({ package: name, family: app.Family || "", appId, manifest: true });
|
||||||
|
addCandidate(out, seen, "ChatGPT/Codex 桌面程序(MSIX AppID)", appId, {
|
||||||
|
launchType: "appId",
|
||||||
|
appId,
|
||||||
|
name,
|
||||||
|
packaged: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const row of arr) {
|
||||||
|
const p = row && row.Path ? String(row.Path) : "";
|
||||||
|
if (report && row) report.appx.push({ package: row.Package || "", path: p });
|
||||||
|
addChecked(report, p);
|
||||||
|
// Program Files\WindowsApps 里的包内 exe 通常不能被普通进程直接启动;
|
||||||
|
// 只记录诊断,不当作启动候选,避免弹系统错误框。
|
||||||
|
if (p && !isWindowsPackagedPath(p)) {
|
||||||
|
addCandidate(out, seen, "ChatGPT/Codex 桌面程序(Microsoft Store/MSIX)", p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function desktopExecutableCandidates() {
|
||||||
|
const home = os.homedir();
|
||||||
|
const out = [];
|
||||||
|
const seen = new Set();
|
||||||
|
const report = {
|
||||||
|
platform: process.platform,
|
||||||
|
checkedPaths: [],
|
||||||
|
scannedRoots: [],
|
||||||
|
shortcuts: [],
|
||||||
|
shortcutTargets: [],
|
||||||
|
appx: [],
|
||||||
|
candidates: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (process.platform === "darwin") {
|
||||||
|
[
|
||||||
|
path.join(home, "Applications/ChatGPT.app/Contents/MacOS/ChatGPT"),
|
||||||
|
"/Applications/ChatGPT.app/Contents/MacOS/ChatGPT",
|
||||||
|
path.join(home, "Applications/Codex.app/Contents/MacOS/Codex"),
|
||||||
|
"/Applications/Codex.app/Contents/MacOS/Codex",
|
||||||
|
].forEach((p) => {
|
||||||
|
addChecked(report, p);
|
||||||
|
addCandidate(out, seen, "ChatGPT/Codex 桌面程序", p);
|
||||||
|
});
|
||||||
|
const sorted = sortCandidates(out);
|
||||||
|
lastDiscovery = { ...report, candidates: sorted };
|
||||||
|
return sorted;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
const local = process.env.LOCALAPPDATA || path.join(home, "AppData", "Local");
|
||||||
|
const appdata = process.env.APPDATA || path.join(home, "AppData", "Roaming");
|
||||||
|
const programData = process.env.ProgramData || "C:\\ProgramData";
|
||||||
|
const pf = process.env.ProgramFiles || "C:\\Program Files";
|
||||||
|
const pf86 = process.env["ProgramFiles(x86)"] || "";
|
||||||
|
const windowsApps = path.join(local, "Microsoft", "WindowsApps");
|
||||||
|
const userStartMenu = path.join(appdata, "Microsoft", "Windows", "Start Menu", "Programs");
|
||||||
|
const systemStartMenu = path.join(programData, "Microsoft", "Windows", "Start Menu", "Programs");
|
||||||
|
const known = [
|
||||||
|
[local, "Programs", "ChatGPT", "ChatGPT.exe"],
|
||||||
|
[local, "Programs", "OpenAI", "ChatGPT", "ChatGPT.exe"],
|
||||||
|
[local, "Programs", "OpenAI ChatGPT", "ChatGPT.exe"],
|
||||||
|
[local, "Programs", "ChatGPT Desktop", "ChatGPT.exe"],
|
||||||
|
[local, "ChatGPT", "ChatGPT.exe"],
|
||||||
|
[local, "OpenAI", "ChatGPT", "ChatGPT.exe"],
|
||||||
|
[local, "OpenAI", "ChatGPT", "bin", "ChatGPT.exe"],
|
||||||
|
[pf, "ChatGPT", "ChatGPT.exe"],
|
||||||
|
[pf, "OpenAI", "ChatGPT", "ChatGPT.exe"],
|
||||||
|
[pf, "OpenAI ChatGPT", "ChatGPT.exe"],
|
||||||
|
pf86 && [pf86, "ChatGPT", "ChatGPT.exe"],
|
||||||
|
pf86 && [pf86, "OpenAI", "ChatGPT", "ChatGPT.exe"],
|
||||||
|
[local, "OpenAI", "Codex", "bin", "Codex.exe"],
|
||||||
|
[local, "Programs", "Codex", "Codex.exe"],
|
||||||
|
[local, "Programs", "codex", "Codex.exe"],
|
||||||
|
[local, "Programs", "OpenAI Codex", "Codex.exe"],
|
||||||
|
[local, "Programs", "Codex Desktop", "Codex.exe"],
|
||||||
|
[local, "Codex", "Codex.exe"],
|
||||||
|
[pf, "Codex", "Codex.exe"],
|
||||||
|
[pf, "OpenAI Codex", "Codex.exe"],
|
||||||
|
pf86 && [pf86, "Codex", "Codex.exe"],
|
||||||
|
].filter(Boolean);
|
||||||
|
for (const parts of known) {
|
||||||
|
const p = path.join(...parts);
|
||||||
|
addChecked(report, p);
|
||||||
|
addCandidate(out, seen, "ChatGPT/Codex 桌面程序", p);
|
||||||
|
}
|
||||||
|
for (const p of [path.join(windowsApps, "ChatGPT.exe"), path.join(windowsApps, "Codex.exe")]) {
|
||||||
|
addChecked(report, p);
|
||||||
|
if (existsFile(p)) {
|
||||||
|
report.appx.push({ package: "WindowsApps alias", path: path.resolve(p) });
|
||||||
|
addCandidate(out, seen, "ChatGPT/Codex 桌面程序(WindowsApps 别名)", p, {
|
||||||
|
launchType: "alias",
|
||||||
|
packaged: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
addWindowsShortcutCandidates(out, seen, report, [userStartMenu, systemStartMenu]);
|
||||||
|
addWindowsAppxCandidates(out, seen, report);
|
||||||
|
for (const base of [path.join(local, "Programs"), local, pf, pf86, windowsApps].filter(Boolean)) {
|
||||||
|
scanDirForExecutables(base, out, seen, { maxDepth: 5, maxEntries: 1600, maxHits: 24, report });
|
||||||
|
}
|
||||||
|
const sorted = sortCandidates(out);
|
||||||
|
lastDiscovery = { ...report, candidates: sorted };
|
||||||
|
return sorted;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastDiscovery = { ...report, candidates: out };
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortCandidates(items) {
|
||||||
|
return [...items].sort((a, b) => scoreCandidate(b.path) - scoreCandidate(a.path));
|
||||||
|
}
|
||||||
|
|
||||||
|
function scoreCandidate(p) {
|
||||||
|
let score = 0;
|
||||||
|
if (/chatgpt/i.test(path.basename(p))) score += 100;
|
||||||
|
if (/codex/i.test(path.basename(p))) score += 50;
|
||||||
|
if (isWindowsPackagedPath(p)) score -= 1000;
|
||||||
|
if (/localappdata|AppData[\\/]Local|Applications/i.test(p)) score += 10;
|
||||||
|
if (/programs/i.test(p)) score += 5;
|
||||||
|
return score;
|
||||||
|
}
|
||||||
|
|
||||||
|
function codexExecutable() {
|
||||||
|
const first = desktopExecutableCandidates().find((item) => !item.packaged);
|
||||||
|
return first ? first.path : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function discoveryReport() {
|
||||||
|
return lastDiscovery || {
|
||||||
|
platform: process.platform,
|
||||||
|
checkedPaths: [],
|
||||||
|
scannedRoots: [],
|
||||||
|
shortcuts: [],
|
||||||
|
shortcutTargets: [],
|
||||||
|
appx: [],
|
||||||
|
candidates: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function discoverySummary() {
|
||||||
|
const r = discoveryReport();
|
||||||
|
const parts = [];
|
||||||
|
if (r.checkedPaths && r.checkedPaths.length) parts.push(`固定路径 ${r.checkedPaths.length} 个`);
|
||||||
|
if (r.scannedRoots && r.scannedRoots.length) parts.push(`目录扫描 ${r.scannedRoots.length} 处`);
|
||||||
|
if (r.shortcuts && r.shortcuts.length) parts.push(`快捷方式 ${r.shortcuts.length} 个`);
|
||||||
|
if (r.appx && r.appx.length) parts.push(`Store/MSIX 线索 ${r.appx.length} 个`);
|
||||||
|
if (r.candidates && r.candidates.length) parts.push(`命中 ${r.candidates.length} 个`);
|
||||||
|
return parts.join(",");
|
||||||
|
}
|
||||||
|
|
||||||
|
function codexRunning() {
|
||||||
|
try {
|
||||||
|
if (process.platform === "darwin") {
|
||||||
|
return execFileSync("pgrep", ["-f", "(Codex.app/Contents/MacOS/Codex|ChatGPT.app/Contents/MacOS/ChatGPT)"], { encoding: "utf-8" }).trim().length > 0;
|
||||||
|
}
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
return windowsProcessRows().length > 0;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function windowsProcessRows() {
|
||||||
|
if (process.platform !== "win32") return [];
|
||||||
|
const escapedNames = WIN_PROCESS_NAMES.map((name) => "'" + name.replace(/'/g, "''") + "'").join(",");
|
||||||
|
const script = [
|
||||||
|
`$names=@(${escapedNames})`,
|
||||||
|
"$pkg='WindowsApps\\\\OpenAI\\.(Codex|CodexBeta|ChatGPT-Desktop)_'",
|
||||||
|
"$rows=Get-CimInstance Win32_Process | Where-Object { $names -contains $_.Name -or ($_.ExecutablePath -match $pkg) -or ($_.CommandLine -match $pkg) } | ForEach-Object {",
|
||||||
|
" [pscustomobject]@{Name=$_.Name;ProcessId=$_.ProcessId;ExecutablePath=$_.ExecutablePath;CommandLine=$_.CommandLine}",
|
||||||
|
"}",
|
||||||
|
"$rows|ConvertTo-Json -Depth 4 -Compress",
|
||||||
|
].join(";");
|
||||||
|
const data = psJson(script, 4500);
|
||||||
|
if (!data) return [];
|
||||||
|
return Array.isArray(data) ? data : [data];
|
||||||
|
}
|
||||||
|
|
||||||
|
function windowsProcessDiagnostics(port) {
|
||||||
|
const rows = windowsProcessRows();
|
||||||
|
const portRe = new RegExp("--remote-debugging-port(?:=|\\s+)" + String(port || ""), "i");
|
||||||
|
const hasDebugArg = rows.some((row) => portRe.test(String((row && row.CommandLine) || "")));
|
||||||
|
const summary = rows.map((row) => {
|
||||||
|
const cmd = String((row && row.CommandLine) || "").replace(/\s+/g, " ").slice(0, 260);
|
||||||
|
const name = (row && row.Name) || "process";
|
||||||
|
const pid = (row && row.ProcessId) || "?";
|
||||||
|
return `${name}#${pid}${cmd ? " " + cmd : ""}`;
|
||||||
|
}).join(" | ");
|
||||||
|
return { rows, hasDebugArg, summary };
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitForCodexExit(maxMs = 9000) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const t0 = Date.now();
|
||||||
|
const tick = () => {
|
||||||
|
if (!codexRunning()) {
|
||||||
|
resolve(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Date.now() - t0 >= maxMs) {
|
||||||
|
resolve(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTimeout(tick, 300);
|
||||||
|
};
|
||||||
|
tick();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function killCodex() {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
if (process.platform === "darwin") {
|
||||||
|
execFile("osascript", ["-e", 'tell application "Codex" to quit', "-e", 'tell application "ChatGPT" to quit'], () => {
|
||||||
|
execFile("pkill", ["-f", "(Codex.app/Contents/MacOS/Codex|ChatGPT.app/Contents/MacOS/ChatGPT)"], () => {
|
||||||
|
waitForCodexExit(12000).then((exited) => resolve({ exited }));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
let pending = WIN_PROCESS_NAMES.length;
|
||||||
|
const done = () => {
|
||||||
|
pending -= 1;
|
||||||
|
if (pending <= 0) {
|
||||||
|
waitForCodexExit(9000).then((ok) => {
|
||||||
|
if (!ok) {
|
||||||
|
try {
|
||||||
|
execFileSync("powershell.exe", [
|
||||||
|
"-NoProfile", "-ExecutionPolicy", "Bypass", "-Command",
|
||||||
|
"$pkg='WindowsApps\\\\OpenAI\\.(Codex|CodexBeta|ChatGPT-Desktop)_'; Get-CimInstance Win32_Process | Where-Object { @('ChatGPT.exe','Codex.exe','OpenAI ChatGPT.exe','ChatGPT Desktop.exe','Codex Desktop.exe') -contains $_.Name -or ($_.ExecutablePath -match $pkg) -or ($_.CommandLine -match $pkg) } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }",
|
||||||
|
], { windowsHide: true, timeout: 3500 });
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
waitForCodexExit(5000).then((exited) => resolve({ exited }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for (const name of WIN_PROCESS_NAMES) execFile("taskkill", ["/IM", name, "/T", "/F"], done);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve({ exited: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
desktopExecutableCandidates,
|
||||||
|
codexExecutable,
|
||||||
|
isWindowsPackagedPath,
|
||||||
|
discoveryReport,
|
||||||
|
discoverySummary,
|
||||||
|
codexRunning,
|
||||||
|
windowsProcessDiagnostics,
|
||||||
|
killCodex,
|
||||||
|
};
|
||||||
@@ -0,0 +1,364 @@
|
|||||||
|
// 上传附件的本地预处理层:MiniMax 负责感知,DeepSeek 只消费结构化证据。
|
||||||
|
const crypto = require("crypto");
|
||||||
|
const { gatewayConfig } = require("./multimodal-config");
|
||||||
|
|
||||||
|
const cache = new Map();
|
||||||
|
const CACHE_TTL_MS = 10 * 60 * 1000;
|
||||||
|
const MAX_IMAGES_PER_REQUEST = 8;
|
||||||
|
const MAX_IMAGE_VALUE_LENGTH = 12 * 1024 * 1024;
|
||||||
|
const MAX_BINARY_BYTES = 25 * 1024 * 1024;
|
||||||
|
const MAX_PDF_PAGES = 20;
|
||||||
|
const MAX_PDF_TEXT_LENGTH = 32000;
|
||||||
|
|
||||||
|
const SYSTEM_PROMPT = [
|
||||||
|
"你是附件理解服务。只根据附件可见内容回答,不要猜测。",
|
||||||
|
"返回严格 JSON,不要 Markdown:",
|
||||||
|
'{"summary":"简短概括","items":[{"kind":"ocr|scene|ui|chart","text":"证据","confidence":0.0}]}',
|
||||||
|
"OCR 保留原文;图表需给出数据、单位与趋势;无法确认时降低 confidence。",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
function sha256(value) {
|
||||||
|
return crypto.createHash("sha256").update(String(value || "")).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
function textFromPart(part) {
|
||||||
|
if (!part || typeof part !== "object") return "";
|
||||||
|
return String(part.text || part.content || "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function urlFromPart(part) {
|
||||||
|
if (!part || typeof part !== "object") return "";
|
||||||
|
const inputImage = part.input_image && typeof part.input_image === "object" ? part.input_image : {};
|
||||||
|
const inputFile = part.input_file && typeof part.input_file === "object" ? part.input_file : {};
|
||||||
|
const inputAudio = part.input_audio && typeof part.input_audio === "object" ? part.input_audio : {};
|
||||||
|
const candidate =
|
||||||
|
(part.image_url && (part.image_url.url || part.image_url)) ||
|
||||||
|
(part.file_url && (part.file_url.url || part.file_url)) ||
|
||||||
|
(part.audio_url && (part.audio_url.url || part.audio_url)) ||
|
||||||
|
inputImage.image_url ||
|
||||||
|
inputImage.url ||
|
||||||
|
inputImage.data ||
|
||||||
|
inputFile.file_url ||
|
||||||
|
inputFile.file_data ||
|
||||||
|
inputFile.url ||
|
||||||
|
inputFile.data ||
|
||||||
|
inputAudio.audio_url ||
|
||||||
|
inputAudio.url ||
|
||||||
|
inputAudio.data ||
|
||||||
|
part.url ||
|
||||||
|
part.data ||
|
||||||
|
part.file_data ||
|
||||||
|
"";
|
||||||
|
return typeof candidate === "string" ? candidate.trim() : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function kindFromPart(part, value) {
|
||||||
|
const type = String((part && part.type) || "").toLowerCase();
|
||||||
|
const name = String((part && (part.filename || part.name)) || "").toLowerCase();
|
||||||
|
const mime = String((part && (part.mime_type || part.mimeType)) || "").toLowerCase();
|
||||||
|
if (type.includes("image") || /^data:image\//i.test(value) || /\.(png|jpe?g|gif|webp|bmp)$/i.test(name)) return "image";
|
||||||
|
if (type.includes("audio") || /^data:audio\//i.test(value) || /\.(mp3|wav|m4a|aac|ogg|webm)$/i.test(name)) return "audio";
|
||||||
|
if (type.includes("file") || type.includes("pdf") || mime.includes("pdf") || /\.pdf$/i.test(name)) return "document";
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectAttachments(request) {
|
||||||
|
const attachments = [];
|
||||||
|
const input = request && request.input;
|
||||||
|
if (!Array.isArray(input)) return attachments;
|
||||||
|
for (const message of input) {
|
||||||
|
if (!message || !Array.isArray(message.content)) continue;
|
||||||
|
for (const part of message.content) {
|
||||||
|
const value = urlFromPart(part);
|
||||||
|
const kind = kindFromPart(part, value);
|
||||||
|
if (!kind) continue;
|
||||||
|
const nestedFile = part.input_file && typeof part.input_file === "object" ? part.input_file : {};
|
||||||
|
const nestedAudio = part.input_audio && typeof part.input_audio === "object" ? part.input_audio : {};
|
||||||
|
const format = String(nestedAudio.format || "").trim().toLowerCase();
|
||||||
|
const name = String(
|
||||||
|
part.filename ||
|
||||||
|
part.name ||
|
||||||
|
nestedFile.filename ||
|
||||||
|
nestedFile.name ||
|
||||||
|
(kind === "audio" && format ? `audio.${format}` : "")
|
||||||
|
);
|
||||||
|
attachments.push({
|
||||||
|
id: `${kind}:${sha256(value || `${name}:${textFromPart(part)}`)}`,
|
||||||
|
kind,
|
||||||
|
value,
|
||||||
|
name,
|
||||||
|
mimeType: String(part.mime_type || part.mimeType || nestedFile.mime_type || nestedFile.mimeType || ""),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return attachments;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractContentText(payload) {
|
||||||
|
const message = payload && payload.choices && payload.choices[0] && payload.choices[0].message;
|
||||||
|
const content = message && message.content;
|
||||||
|
if (typeof content === "string") return content;
|
||||||
|
if (Array.isArray(content)) {
|
||||||
|
return content.map((part) => (typeof part === "string" ? part : (part && (part.text || part.content)) || "")).join("");
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseEvidence(text) {
|
||||||
|
const raw = String(text || "").trim();
|
||||||
|
const fenced = raw.replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/i, "");
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(fenced);
|
||||||
|
return {
|
||||||
|
summary: String(parsed.summary || "").trim(),
|
||||||
|
items: Array.isArray(parsed.items)
|
||||||
|
? parsed.items.slice(0, 40).map((item) => ({
|
||||||
|
kind: String((item && item.kind) || "scene"),
|
||||||
|
text: String((item && item.text) || "").trim(),
|
||||||
|
confidence: Number.isFinite(Number(item && item.confidence)) ? Number(item.confidence) : null,
|
||||||
|
})).filter((item) => item.text)
|
||||||
|
: [],
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { summary: raw.slice(0, 6000), items: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function chatEndpoint(baseUrl) {
|
||||||
|
const base = String(baseUrl || "").replace(/\/+$/, "");
|
||||||
|
if (/\/chat\/completions$/i.test(base)) return base;
|
||||||
|
if (/\/v\d+$/i.test(base)) return `${base}/chat/completions`;
|
||||||
|
return `${base}/v1/chat/completions`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function transcriptionEndpoint(baseUrl) {
|
||||||
|
const base = String(baseUrl || "").replace(/\/+$/, "");
|
||||||
|
if (/\/v\d+$/i.test(base)) return `${base}/audio/transcriptions`;
|
||||||
|
return `${base}/v1/audio/transcriptions`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function binaryFromAsset(asset, requestFetch) {
|
||||||
|
const value = String(asset.value || "").trim();
|
||||||
|
if (!value) throw new Error("附件没有可读取的数据或 URL");
|
||||||
|
if (/^data:/i.test(value)) {
|
||||||
|
const comma = value.indexOf(",");
|
||||||
|
if (comma < 0) throw new Error("附件 data URL 格式无效");
|
||||||
|
const meta = value.slice(0, comma);
|
||||||
|
const body = value.slice(comma + 1);
|
||||||
|
const buffer = /;base64/i.test(meta)
|
||||||
|
? Buffer.from(body, "base64")
|
||||||
|
: Buffer.from(decodeURIComponent(body), "utf-8");
|
||||||
|
if (buffer.length > MAX_BINARY_BYTES) throw new Error("附件超过 25MB 限制");
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
if (/^https?:\/\//i.test(value)) {
|
||||||
|
const response = await requestFetch(value, { headers: { "user-agent": "codex-market" } });
|
||||||
|
if (!response.ok) throw new Error(`读取附件失败(HTTP ${response.status})`);
|
||||||
|
const size = Number(response.headers && response.headers.get && response.headers.get("content-length"));
|
||||||
|
if (Number.isFinite(size) && size > MAX_BINARY_BYTES) throw new Error("附件超过 25MB 限制");
|
||||||
|
const buffer = Buffer.from(await response.arrayBuffer());
|
||||||
|
if (buffer.length > MAX_BINARY_BYTES) throw new Error("附件超过 25MB 限制");
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
const buffer = Buffer.from(value, "base64");
|
||||||
|
if (!buffer.length || buffer.length > MAX_BINARY_BYTES) throw new Error("附件内容无效或超过 25MB 限制");
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function understandPdf(asset, requestFetch) {
|
||||||
|
const data = await binaryFromAsset(asset, requestFetch);
|
||||||
|
const pdfjs = await import("pdfjs-dist/legacy/build/pdf.mjs");
|
||||||
|
const task = pdfjs.getDocument({ data: new Uint8Array(data), isEvalSupported: false });
|
||||||
|
const document = await task.promise;
|
||||||
|
const items = [];
|
||||||
|
for (let pageNumber = 1; pageNumber <= Math.min(document.numPages, MAX_PDF_PAGES); pageNumber += 1) {
|
||||||
|
const page = await document.getPage(pageNumber);
|
||||||
|
const content = await page.getTextContent();
|
||||||
|
const text = content.items.map((item) => String(item.str || "")).join(" ").replace(/\s+/g, " ").trim();
|
||||||
|
if (text) items.push({ kind: "document", text: text.slice(0, MAX_PDF_TEXT_LENGTH), page: pageNumber, confidence: 1 });
|
||||||
|
}
|
||||||
|
if (!items.length) {
|
||||||
|
return {
|
||||||
|
summary: "PDF 未提取到可复制文本,可能是扫描件;需要后续 OCR/页面视觉解析。",
|
||||||
|
items: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
summary: `PDF 共 ${document.numPages} 页,已提取 ${items.length} 页文本。`,
|
||||||
|
items,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function audioMimeType(asset) {
|
||||||
|
if (asset.mimeType) return asset.mimeType;
|
||||||
|
const name = String(asset.name || "").toLowerCase();
|
||||||
|
if (name.endsWith(".wav")) return "audio/wav";
|
||||||
|
if (name.endsWith(".mp3")) return "audio/mpeg";
|
||||||
|
if (name.endsWith(".m4a")) return "audio/mp4";
|
||||||
|
if (name.endsWith(".aac")) return "audio/aac";
|
||||||
|
if (name.endsWith(".ogg")) return "audio/ogg";
|
||||||
|
return "audio/webm";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function understandAudio(asset, config, requestFetch) {
|
||||||
|
const data = await binaryFromAsset(asset, requestFetch);
|
||||||
|
const mimeType = audioMimeType(asset);
|
||||||
|
const filename = asset.name || "audio.webm";
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("model", config.sttModel);
|
||||||
|
form.append("file", new Blob([data], { type: mimeType }), filename);
|
||||||
|
const response = await requestFetch(transcriptionEndpoint(config.baseUrl), {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${config.key}`,
|
||||||
|
"user-agent": "codex-market",
|
||||||
|
},
|
||||||
|
body: form,
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const detail = (await response.text().catch(() => "")).slice(0, 300);
|
||||||
|
throw new Error(`音频转写失败(HTTP ${response.status}${detail ? `:${detail}` : ""})`);
|
||||||
|
}
|
||||||
|
const result = await response.json();
|
||||||
|
const segments = Array.isArray(result.segments) ? result.segments : [];
|
||||||
|
const items = segments.map((segment) => ({
|
||||||
|
kind: "transcript",
|
||||||
|
text: String(segment.text || "").trim(),
|
||||||
|
start_ms: Number.isFinite(Number(segment.start)) ? Math.round(Number(segment.start) * 1000) : null,
|
||||||
|
end_ms: Number.isFinite(Number(segment.end)) ? Math.round(Number(segment.end) * 1000) : null,
|
||||||
|
confidence: null,
|
||||||
|
})).filter((item) => item.text);
|
||||||
|
return {
|
||||||
|
summary: String(result.text || items.map((item) => item.text).join(" ")).slice(0, MAX_PDF_TEXT_LENGTH),
|
||||||
|
items,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function understandImages(images, instruction, config, requestFetch) {
|
||||||
|
const content = [
|
||||||
|
{ type: "text", text: `用户问题:${String(instruction || "请识别附件内容").slice(0, 4000)}` },
|
||||||
|
...images.map((asset) => ({ type: "image_url", image_url: { url: asset.value } })),
|
||||||
|
];
|
||||||
|
const response = await requestFetch(chatEndpoint(config.baseUrl), {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
authorization: `Bearer ${config.key}`,
|
||||||
|
"user-agent": "codex-market",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: config.model,
|
||||||
|
temperature: 0.1,
|
||||||
|
max_tokens: 1800,
|
||||||
|
stream: false,
|
||||||
|
messages: [
|
||||||
|
{ role: "system", content: SYSTEM_PROMPT },
|
||||||
|
{ role: "user", content },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const detail = (await response.text().catch(() => "")).slice(0, 300);
|
||||||
|
throw new Error(`MiniMax 识别失败(HTTP ${response.status}${detail ? `:${detail}` : ""})`);
|
||||||
|
}
|
||||||
|
return parseEvidence(extractContentText(await response.json()));
|
||||||
|
}
|
||||||
|
|
||||||
|
function evidenceText(evidence) {
|
||||||
|
return `[TD 多模态识别证据]\n${JSON.stringify(evidence)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendEvidence(request, evidence) {
|
||||||
|
if (!Array.isArray(request.input)) return;
|
||||||
|
let target = null;
|
||||||
|
for (let i = request.input.length - 1; i >= 0; i -= 1) {
|
||||||
|
const item = request.input[i];
|
||||||
|
if (item && item.type === "message" && item.role === "user" && Array.isArray(item.content)) {
|
||||||
|
target = item;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!target) {
|
||||||
|
target = { type: "message", role: "user", content: [] };
|
||||||
|
request.input.push(target);
|
||||||
|
}
|
||||||
|
target.content.push({ type: "input_text", text: evidenceText(evidence) });
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupCache(now) {
|
||||||
|
for (const [key, value] of cache) if (now - value.at > CACHE_TTL_MS) cache.delete(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enrichResponsesRequest(request, { instruction = "", fetchImpl = global.fetch } = {}) {
|
||||||
|
const config = gatewayConfig();
|
||||||
|
const attachments = collectAttachments(request);
|
||||||
|
if (!attachments.length) return { processed: false, attachments: [] };
|
||||||
|
const imageAssets = attachments
|
||||||
|
.filter((asset) => asset.kind === "image" && asset.value && asset.value.length <= MAX_IMAGE_VALUE_LENGTH)
|
||||||
|
.slice(0, MAX_IMAGES_PER_REQUEST);
|
||||||
|
const evidence = {
|
||||||
|
version: 1,
|
||||||
|
provider: "minimax",
|
||||||
|
modality: attachments.length > 1 ? "mixed" : attachments[0].kind,
|
||||||
|
summary: "",
|
||||||
|
items: [],
|
||||||
|
unsupported: [],
|
||||||
|
};
|
||||||
|
const summaries = [];
|
||||||
|
|
||||||
|
if (imageAssets.length) {
|
||||||
|
if (!config.key) {
|
||||||
|
summaries.push("已检测到图片附件,但免费多模态网关尚未注入访问密钥,不能识别图片内容。不要声称已查看或识别该图片。");
|
||||||
|
} else {
|
||||||
|
cleanupCache(Date.now());
|
||||||
|
const cacheKey = sha256(`${config.model}:${instruction}:${imageAssets.map((asset) => asset.id).join(",")}`);
|
||||||
|
const cached = cache.get(cacheKey);
|
||||||
|
if (cached) {
|
||||||
|
summaries.push(cached.value.summary);
|
||||||
|
evidence.items.push(...cached.value.items);
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const understood = await understandImages(imageAssets, instruction, config, fetchImpl);
|
||||||
|
summaries.push(understood.summary);
|
||||||
|
evidence.items.push(...understood.items);
|
||||||
|
cache.set(cacheKey, { at: Date.now(), value: understood });
|
||||||
|
} catch (error) {
|
||||||
|
summaries.push(String((error && error.message) || "MiniMax 图片识别失败"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const asset of attachments.filter((item) => item.kind === "document")) {
|
||||||
|
try {
|
||||||
|
const understood = await understandPdf(asset, fetchImpl);
|
||||||
|
summaries.push(understood.summary);
|
||||||
|
evidence.items.push(...understood.items);
|
||||||
|
} catch (error) {
|
||||||
|
evidence.unsupported.push({ kind: "document", name: asset.name || "未命名附件" });
|
||||||
|
summaries.push(String((error && error.message) || "PDF 解析失败"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const asset of attachments.filter((item) => item.kind === "audio")) {
|
||||||
|
if (!config.key || !config.sttModel) {
|
||||||
|
evidence.unsupported.push({ kind: "audio", name: asset.name || "未命名附件" });
|
||||||
|
summaries.push("音频已检测到,但免费多模态网关尚未配置音频转写模型。");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const understood = await understandAudio(asset, config, fetchImpl);
|
||||||
|
summaries.push(understood.summary);
|
||||||
|
evidence.items.push(...understood.items);
|
||||||
|
} catch (error) {
|
||||||
|
summaries.push(String((error && error.message) || "音频转写失败"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
evidence.summary = summaries.filter(Boolean).join("\n").slice(0, MAX_PDF_TEXT_LENGTH);
|
||||||
|
appendEvidence(request, evidence);
|
||||||
|
return { processed: true, attachments, evidence };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
collectAttachments,
|
||||||
|
enrichResponsesRequest,
|
||||||
|
parseEvidence,
|
||||||
|
};
|
||||||
+158
-4
@@ -32,6 +32,7 @@ const seenRollouts = new Set(); // 已见过的 rollout 文件(判定「新会
|
|||||||
const queue = [];
|
const queue = [];
|
||||||
let working = false;
|
let working = false;
|
||||||
let cfg = null;
|
let cfg = null;
|
||||||
|
const deletedSessionIds = new Set();
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// 配置(userData/memory.json)
|
// 配置(userData/memory.json)
|
||||||
@@ -123,6 +124,33 @@ function flushDb() {
|
|||||||
log("会话记忆:记忆库写盘失败 " + (e.message || e));
|
log("会话记忆:记忆库写盘失败 " + (e.message || e));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
function flushDbStrict() {
|
||||||
|
clearTimeout(persistTimer);
|
||||||
|
if (!db) return;
|
||||||
|
const file = dbFile();
|
||||||
|
const tmp = file + ".td-delete-" + process.pid + ".tmp";
|
||||||
|
const old = file + ".td-delete-" + process.pid + ".old";
|
||||||
|
fs.rmSync(old, { force: true });
|
||||||
|
const fd = fs.openSync(tmp, "w");
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(fd, Buffer.from(db.export()));
|
||||||
|
fs.fsyncSync(fd);
|
||||||
|
} finally {
|
||||||
|
fs.closeSync(fd);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(file)) fs.renameSync(file, old);
|
||||||
|
try {
|
||||||
|
fs.renameSync(tmp, file);
|
||||||
|
} catch (e) {
|
||||||
|
if (fs.existsSync(old)) fs.renameSync(old, file);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
try { fs.rmSync(old, { force: true }); } catch {}
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(tmp, { force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
function all(sql, params = []) {
|
function all(sql, params = []) {
|
||||||
const rows = [];
|
const rows = [];
|
||||||
const st = db.prepare(sql);
|
const st = db.prepare(sql);
|
||||||
@@ -270,6 +298,7 @@ async function summarizeFile(file) {
|
|||||||
const items = sessions.listConversations({ includeSubagent: false });
|
const items = sessions.listConversations({ includeSubagent: false });
|
||||||
const item = items.find((i) => i.path === file);
|
const item = items.find((i) => i.path === file);
|
||||||
if (!item || !item.id) return; // 子 agent 线程或读不到元信息 → 跳过
|
if (!item || !item.id) return; // 子 agent 线程或读不到元信息 → 跳过
|
||||||
|
if (deletedSessionIds.has(item.id)) return;
|
||||||
const conv = sessions.readConversation(file);
|
const conv = sessions.readConversation(file);
|
||||||
if (!conv.messages.some((m) => m.role === "user")) return; // 空会话不摘要
|
if (!conv.messages.some((m) => m.role === "user")) return; // 空会话不摘要
|
||||||
let r;
|
let r;
|
||||||
@@ -280,6 +309,7 @@ async function summarizeFile(file) {
|
|||||||
r = localSummarize(item, conv);
|
r = localSummarize(item, conv);
|
||||||
}
|
}
|
||||||
await ensureDb();
|
await ensureDb();
|
||||||
|
if (deletedSessionIds.has(item.id)) return;
|
||||||
db.run(
|
db.run(
|
||||||
`INSERT INTO sessions (id, ts, project, cwd, topic, summary, decisions, next_steps, files,
|
`INSERT INTO sessions (id, ts, project, cwd, topic, summary, decisions, next_steps, files,
|
||||||
rollout_path, rollout_size, engine, updated_at)
|
rollout_path, rollout_size, engine, updated_at)
|
||||||
@@ -618,6 +648,65 @@ function writeProjectCore(cwd) {
|
|||||||
if (nt !== text) writeFileAtomic(f, nt);
|
if (nt !== text) writeFileAtomic(f, nt);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function removeProjectCore(cwd) {
|
||||||
|
const f = path.join(cwd, "AGENTS.md");
|
||||||
|
let text = "";
|
||||||
|
try { text = fs.readFileSync(f, "utf-8"); } catch { return; }
|
||||||
|
const si = text.indexOf(AGENTS_START);
|
||||||
|
const ei = text.indexOf(AGENTS_END);
|
||||||
|
if (si < 0 || ei <= si) return;
|
||||||
|
const next = text.slice(0, si) + text.slice(ei + AGENTS_END.length);
|
||||||
|
writeFileAtomic(f, next);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeArchiveSession(cwd, id) {
|
||||||
|
if (!cwd || !id) return;
|
||||||
|
const f = archivePath(cwd);
|
||||||
|
let text = "";
|
||||||
|
try { text = fs.readFileSync(f, "utf-8"); } catch { return; }
|
||||||
|
const lines = text.split("\n");
|
||||||
|
const kept = [];
|
||||||
|
let dropping = false;
|
||||||
|
for (const line of lines) {
|
||||||
|
const header = line.match(/^## .* \[([0-9a-f-]{36})\]\s*$/i);
|
||||||
|
if (header) dropping = header[1] === id;
|
||||||
|
if (!dropping) kept.push(line);
|
||||||
|
}
|
||||||
|
const next = kept.join("\n");
|
||||||
|
if (next.trim()) writeFileAtomic(f, next);
|
||||||
|
else fs.rmSync(f, { force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshScopeAfterDelete(cwd, id) {
|
||||||
|
db.run("DELETE FROM core_decisions WHERE scope = ? AND session_id = ?", [cwd, id]);
|
||||||
|
const latest = all("SELECT * FROM sessions WHERE cwd = ? ORDER BY ts DESC LIMIT 1", [cwd])[0];
|
||||||
|
if (!latest) {
|
||||||
|
db.run("DELETE FROM core WHERE scope = ?", [cwd]);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
db.run(
|
||||||
|
`INSERT INTO core (scope,state,next_steps,files,updated_at) VALUES (?,?,?,?,?)
|
||||||
|
ON CONFLICT(scope) DO UPDATE SET state=excluded.state,next_steps=excluded.next_steps,files=excluded.files,updated_at=excluded.updated_at`,
|
||||||
|
[cwd, clip(latest.summary, 300), JSON.stringify(parseArr(latest.next_steps).slice(0, 6)), JSON.stringify(parseArr(latest.files).slice(0, 8)), new Date().toISOString()]
|
||||||
|
);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeGeneratedHandoff(cwd) {
|
||||||
|
const file = handoffPath(cwd);
|
||||||
|
let text = "";
|
||||||
|
try { text = fs.readFileSync(file, "utf-8"); } catch { return; }
|
||||||
|
if (text.startsWith("<!-- 本文件由「Codex 插件市场 · 会话记忆」自动生成与覆盖,请勿手动编辑 -->")) {
|
||||||
|
fs.rmSync(file, { force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function canWriteGeneratedHandoff(cwd) {
|
||||||
|
let text = "";
|
||||||
|
try { text = fs.readFileSync(handoffPath(cwd), "utf-8"); } catch { return true; }
|
||||||
|
return text.startsWith("<!-- 本文件由「Codex 插件市场 · 会话记忆」自动生成与覆盖,请勿手动编辑 -->");
|
||||||
|
}
|
||||||
|
|
||||||
// 新版首启:先把已有 sessions 迁移进结构化 core,再把所有已知项目 AGENTS.md 升级为核心块格式
|
// 新版首启:先把已有 sessions 迁移进结构化 core,再把所有已知项目 AGENTS.md 升级为核心块格式
|
||||||
function refreshAllCores() {
|
function refreshAllCores() {
|
||||||
try {
|
try {
|
||||||
@@ -738,16 +827,19 @@ function start(opts = {}) {
|
|||||||
if (opts.log) log = opts.log;
|
if (opts.log) log = opts.log;
|
||||||
if (opts.notify) notify = opts.notify;
|
if (opts.notify) notify = opts.notify;
|
||||||
loadCfg();
|
loadCfg();
|
||||||
// 先把已存在的会话标记为「已见」,这样只有启动后【新开】的会话才触发「即刻结算上一个」
|
const markSeen = () => {
|
||||||
|
// 标记已存在会话需要递归扫描 ~/.codex/sessions,启动时可延后,避免阻塞主进程响应点击。
|
||||||
try {
|
try {
|
||||||
for (const it of sessions.listConversations({ includeSubagent: true })) seenRollouts.add(it.path);
|
for (const it of sessions.listConversations({ includeSubagent: true })) seenRollouts.add(it.path);
|
||||||
} catch {}
|
} catch {}
|
||||||
|
};
|
||||||
// 监视器常开(总开关只控制是否处理),这样开关切换无需重启
|
// 监视器常开(总开关只控制是否处理),这样开关切换无需重启
|
||||||
try {
|
try {
|
||||||
fs.mkdirSync(sessions.SESSIONS_DIR, { recursive: true });
|
const sessionsDir = sessions.getSessionsDir();
|
||||||
watcher = fs.watch(sessions.SESSIONS_DIR, { recursive: true }, (_ev, name) => {
|
fs.mkdirSync(sessionsDir, { recursive: true });
|
||||||
|
watcher = fs.watch(sessionsDir, { recursive: true }, (_ev, name) => {
|
||||||
if (!name || !/rollout-.*\.jsonl$/.test(String(name))) return;
|
if (!name || !/rollout-.*\.jsonl$/.test(String(name))) return;
|
||||||
const full = path.join(sessions.SESSIONS_DIR, String(name));
|
const full = path.join(sessionsDir, String(name));
|
||||||
// 「开新会话即刻结算上一个」:首次见到某 rollout 文件 = 新会话开始 → 立刻把其它待处理的(多半是刚关掉的那个)先存好
|
// 「开新会话即刻结算上一个」:首次见到某 rollout 文件 = 新会话开始 → 立刻把其它待处理的(多半是刚关掉的那个)先存好
|
||||||
if (!seenRollouts.has(full)) {
|
if (!seenRollouts.has(full)) {
|
||||||
seenRollouts.add(full);
|
seenRollouts.add(full);
|
||||||
@@ -759,7 +851,15 @@ function start(opts = {}) {
|
|||||||
log("会话记忆:无法监视 sessions 目录 " + (e.message || e));
|
log("会话记忆:无法监视 sessions 目录 " + (e.message || e));
|
||||||
}
|
}
|
||||||
sweepTimer = setInterval(sweep, 5000);
|
sweepTimer = setInterval(sweep, 5000);
|
||||||
|
if (opts.startupScan === false) return;
|
||||||
|
const startupScan = () => {
|
||||||
|
if (!loadCfg().enabled) return;
|
||||||
|
markSeen();
|
||||||
backfill().catch((e) => log("会话记忆:启动扫描失败 " + (e.message || e)));
|
backfill().catch((e) => log("会话记忆:启动扫描失败 " + (e.message || e)));
|
||||||
|
};
|
||||||
|
const delay = Number(opts.startupDelayMs) || 0;
|
||||||
|
if (delay > 0) setTimeout(startupScan, delay);
|
||||||
|
else startupScan();
|
||||||
}
|
}
|
||||||
|
|
||||||
function stop() {
|
function stop() {
|
||||||
@@ -771,6 +871,14 @@ function stop() {
|
|||||||
flushDb(); // 退出前把内存库落盘
|
flushDb(); // 退出前把内存库落盘
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function restartWatching() {
|
||||||
|
stop();
|
||||||
|
dirty.clear();
|
||||||
|
seenRollouts.clear();
|
||||||
|
queue.length = 0;
|
||||||
|
start();
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// 给 UI 的接口
|
// 给 UI 的接口
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -856,15 +964,61 @@ async function deleteRow(id) {
|
|||||||
notify();
|
notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function deleteSession(id, cwd) {
|
||||||
|
deletedSessionIds.add(String(id || ""));
|
||||||
|
await ensureDb();
|
||||||
|
const rows = all("SELECT cwd, rollout_path FROM sessions WHERE id = ?", [id]);
|
||||||
|
for (const row of rows) {
|
||||||
|
if (row.rollout_path) {
|
||||||
|
dirty.delete(row.rollout_path);
|
||||||
|
seenRollouts.delete(row.rollout_path);
|
||||||
|
for (let i = queue.length - 1; i >= 0; i--) if (queue[i] === row.rollout_path) queue.splice(i, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const storedScopes = Array.from(new Set(rows.map((row) => row.cwd).filter(Boolean)));
|
||||||
|
const scopes = storedScopes.length ? storedScopes : (cwd ? [cwd] : []);
|
||||||
|
const snapshot = Buffer.from(db.export());
|
||||||
|
try {
|
||||||
|
db.run("BEGIN");
|
||||||
|
db.run("DELETE FROM sessions WHERE id = ?", [id]);
|
||||||
|
for (const scope of scopes) refreshScopeAfterDelete(scope, id);
|
||||||
|
db.run("COMMIT");
|
||||||
|
flushDbStrict();
|
||||||
|
} catch (e) {
|
||||||
|
try { db.run("ROLLBACK"); } catch {}
|
||||||
|
try { db.close(); } catch {}
|
||||||
|
db = new SQLmod.Database(snapshot);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
for (const scope of scopes) removeArchiveSession(scope, id);
|
||||||
|
for (const scope of scopes) {
|
||||||
|
if (!scope) continue;
|
||||||
|
try { if (!fs.statSync(scope).isDirectory()) continue; } catch { continue; }
|
||||||
|
try {
|
||||||
|
if (all("SELECT 1 FROM sessions WHERE cwd = ? LIMIT 1", [scope]).length) {
|
||||||
|
writeProjectCore(scope);
|
||||||
|
if (canWriteGeneratedHandoff(scope)) writeHandoff(scope);
|
||||||
|
} else {
|
||||||
|
removeProjectCore(scope);
|
||||||
|
removeGeneratedHandoff(scope);
|
||||||
|
}
|
||||||
|
} catch (e) { log("会话记忆派生文件清理失败:" + (e.message || e)); }
|
||||||
|
}
|
||||||
|
notify();
|
||||||
|
return { id, scopes };
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
start,
|
start,
|
||||||
stop,
|
stop,
|
||||||
|
restartWatching,
|
||||||
statusInfo,
|
statusInfo,
|
||||||
list,
|
list,
|
||||||
setEnabled,
|
setEnabled,
|
||||||
toggleProject,
|
toggleProject,
|
||||||
resummarize,
|
resummarize,
|
||||||
deleteRow,
|
deleteRow,
|
||||||
|
deleteSession,
|
||||||
handoffPath,
|
handoffPath,
|
||||||
renderProjectCore,
|
renderProjectCore,
|
||||||
writeProjectCore,
|
writeProjectCore,
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
// 多模态网关凭据只允许在打包时注入,不写进仓库或路由分组配置。
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const DEFAULT_BASE_URL = "https://api.tokendun.com";
|
||||||
|
const DEFAULT_MODEL = "MiniMax-M3";
|
||||||
|
|
||||||
|
function readJson(file) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(fs.readFileSync(file, "utf-8"));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function configFiles() {
|
||||||
|
const files = [];
|
||||||
|
if (process.env.TD_MULTIMODAL_GATEWAY_CONFIG) files.push(process.env.TD_MULTIMODAL_GATEWAY_CONFIG);
|
||||||
|
// 热更新目录不随完整安装包携带 resources;将凭据放在 userData 后可被热更新代码稳定读取。
|
||||||
|
try {
|
||||||
|
const { app } = require("electron");
|
||||||
|
if (app && app.getPath) files.push(path.join(app.getPath("userData"), "multimodal-gateway.json"));
|
||||||
|
} catch {}
|
||||||
|
if (process.resourcesPath) files.push(path.join(process.resourcesPath, "multimodal-gateway.json"));
|
||||||
|
files.push(path.join(__dirname, "..", "..", "resources", "multimodal-gateway.json"));
|
||||||
|
return Array.from(new Set(files));
|
||||||
|
}
|
||||||
|
|
||||||
|
function gatewayConfig() {
|
||||||
|
const fileConfig = configFiles().map(readJson).find(Boolean) || {};
|
||||||
|
const baseUrl = String(process.env.TD_MULTIMODAL_GATEWAY_URL || fileConfig.baseUrl || DEFAULT_BASE_URL).trim();
|
||||||
|
const key = String(process.env.TD_MULTIMODAL_GATEWAY_KEY || fileConfig.key || "").trim();
|
||||||
|
const model = String(process.env.TD_MULTIMODAL_MODEL || fileConfig.model || DEFAULT_MODEL).trim();
|
||||||
|
const sttModel = String(process.env.TD_MULTIMODAL_STT_MODEL || fileConfig.sttModel || "").trim();
|
||||||
|
return { baseUrl, key, model, sttModel };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { gatewayConfig };
|
||||||
+748
-120
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,7 @@ const os = require("os");
|
|||||||
const { exec } = require("child_process");
|
const { exec } = require("child_process");
|
||||||
|
|
||||||
const codex = require("./codex");
|
const codex = require("./codex");
|
||||||
|
const codexDesktop = require("./codex_desktop");
|
||||||
|
|
||||||
const IS_WIN = process.platform === "win32";
|
const IS_WIN = process.platform === "win32";
|
||||||
|
|
||||||
@@ -85,6 +86,11 @@ async function detectInstalls() {
|
|||||||
add("Codex 数据目录(配置 · 登录 · 会话 · 插件)", "data", path.join(home, ".codex"));
|
add("Codex 数据目录(配置 · 登录 · 会话 · 插件)", "data", path.join(home, ".codex"));
|
||||||
|
|
||||||
// 2) PATH 里的可执行文件(which / where 可能返回多行)
|
// 2) PATH 里的可执行文件(which / where 可能返回多行)
|
||||||
|
for (const item of codexDesktop.desktopExecutableCandidates()) {
|
||||||
|
add(item.label || "ChatGPT/Codex 桌面程序", "bin", item.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) PATH 里的可执行文件(which / where 可能返回多行)
|
||||||
const whichOut = IS_WIN ? await run("where codex") : await run("which -a codex");
|
const whichOut = IS_WIN ? await run("where codex") : await run("which -a codex");
|
||||||
if (whichOut) {
|
if (whichOut) {
|
||||||
for (const line of whichOut.split(/\r?\n/)) {
|
for (const line of whichOut.split(/\r?\n/)) {
|
||||||
@@ -103,7 +109,7 @@ async function detectInstalls() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3) npm 全局包与 npm 全局 bin
|
// 4) npm 全局包与 npm 全局 bin
|
||||||
const npmRoot = await run(IS_WIN ? "npm.cmd root -g" : "npm root -g");
|
const npmRoot = await run(IS_WIN ? "npm.cmd root -g" : "npm root -g");
|
||||||
if (npmRoot) add("@openai/codex 安装包(npm 全局)", "npm-package", path.join(npmRoot, "@openai", "codex"));
|
if (npmRoot) add("@openai/codex 安装包(npm 全局)", "npm-package", path.join(npmRoot, "@openai", "codex"));
|
||||||
|
|
||||||
@@ -116,7 +122,7 @@ async function detectInstalls() {
|
|||||||
add("@openai/codex 安装包(npm 全局)", "npm-package", path.join(npmPrefix, IS_WIN ? "node_modules" : "lib/node_modules", "@openai", "codex"));
|
add("@openai/codex 安装包(npm 全局)", "npm-package", path.join(npmPrefix, IS_WIN ? "node_modules" : "lib/node_modules", "@openai", "codex"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4) 各平台常见固定位置兜底
|
// 5) 各平台常见固定位置兜底
|
||||||
if (IS_WIN) {
|
if (IS_WIN) {
|
||||||
const appdata = process.env.APPDATA || path.join(home, "AppData", "Roaming");
|
const appdata = process.env.APPDATA || path.join(home, "AppData", "Roaming");
|
||||||
for (const n of ["codex", "codex.cmd", "codex.ps1"]) {
|
for (const n of ["codex", "codex.cmd", "codex.ps1"]) {
|
||||||
|
|||||||
+824
-159
File diff suppressed because it is too large
Load Diff
+464
-24
@@ -2,12 +2,38 @@
|
|||||||
// 直接读 ~/.codex/sessions / archived_sessions 下的 rollout-*.jsonl,不依赖数据库,跨平台。
|
// 直接读 ~/.codex/sessions / archived_sessions 下的 rollout-*.jsonl,不依赖数据库,跨平台。
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
const os = require("os");
|
const { spawnSync } = require("child_process");
|
||||||
|
const initSqlJs = require("sql.js");
|
||||||
|
const { cleanThreadTitle, cleanUserVisibleText, stripInternalThoughtBlocks, looksLikeSkillMentionTitle } = require("./title_clean");
|
||||||
|
const codexDataDir = require("./codex_data_dir");
|
||||||
|
|
||||||
const CODEX_HOME = path.join(os.homedir(), ".codex");
|
let permanentDeleteBusy = false;
|
||||||
const SESSIONS_DIR = path.join(CODEX_HOME, "sessions");
|
|
||||||
const ARCHIVED_SESSIONS_DIR = path.join(CODEX_HOME, "archived_sessions");
|
function paths() {
|
||||||
const SESSION_INDEX = path.join(CODEX_HOME, "session_index.jsonl");
|
const codexHome = codexDataDir.get();
|
||||||
|
return {
|
||||||
|
codexHome,
|
||||||
|
sessionsDir: path.join(codexHome, "sessions"),
|
||||||
|
archivedSessionsDir: path.join(codexHome, "archived_sessions"),
|
||||||
|
sessionIndex: path.join(codexHome, "session_index.jsonl"),
|
||||||
|
titleCleanMarker: path.join(codexHome, ".td-title-clean-v1.json"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isInside(base, file) {
|
||||||
|
const rel = path.relative(path.resolve(base), path.resolve(file));
|
||||||
|
return rel && !rel.startsWith(".." + path.sep) && rel !== ".." && !path.isAbsolute(rel);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sessionRoots() {
|
||||||
|
const { sessionsDir, archivedSessionsDir } = paths();
|
||||||
|
return [sessionsDir, archivedSessionsDir];
|
||||||
|
}
|
||||||
|
|
||||||
|
function sessionFileAllowed(file) {
|
||||||
|
return sessionRoots().some((root) => isInside(root, file)) &&
|
||||||
|
path.basename(file).startsWith("rollout-") && path.extname(file) === ".jsonl";
|
||||||
|
}
|
||||||
|
|
||||||
// 递归找出所有 rollout-*.jsonl
|
// 递归找出所有 rollout-*.jsonl
|
||||||
function findRolloutFiles(base) {
|
function findRolloutFiles(base) {
|
||||||
@@ -33,12 +59,12 @@ function findRolloutFiles(base) {
|
|||||||
function loadIndexTitles() {
|
function loadIndexTitles() {
|
||||||
const map = {};
|
const map = {};
|
||||||
try {
|
try {
|
||||||
const txt = fs.readFileSync(SESSION_INDEX, "utf-8");
|
const txt = fs.readFileSync(paths().sessionIndex, "utf-8");
|
||||||
for (const line of txt.split("\n")) {
|
for (const line of txt.split("\n")) {
|
||||||
if (!line.trim()) continue;
|
if (!line.trim()) continue;
|
||||||
try {
|
try {
|
||||||
const d = JSON.parse(line);
|
const d = JSON.parse(line);
|
||||||
if (d.id && d.thread_name) map[d.id] = d.thread_name;
|
if (d.id && d.thread_name) map[d.id] = cleanThreadTitle(d.thread_name, "", 80);
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
@@ -76,13 +102,372 @@ function peekMeta(file) {
|
|||||||
meta.threadSource = p.thread_source || "";
|
meta.threadSource = p.thread_source || "";
|
||||||
meta.provider = p.model_provider || "";
|
meta.provider = p.model_provider || "";
|
||||||
} else if (d.type === "event_msg" && p.type === "user_message" && !meta.title) {
|
} else if (d.type === "event_msg" && p.type === "user_message" && !meta.title) {
|
||||||
meta.title = String(p.message || "").replace(/\s+/g, " ").trim().slice(0, 60);
|
meta.title = cleanThreadTitle(p.message || "", "", 200);
|
||||||
}
|
}
|
||||||
if (meta.id && meta.title) break;
|
if (meta.id && meta.title) break;
|
||||||
}
|
}
|
||||||
return meta;
|
return meta;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sqlite3Available() {
|
||||||
|
const r = spawnSync("sqlite3", ["--version"], { encoding: "utf-8" });
|
||||||
|
return !r.error && r.status === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function runSqlite3(dbPath, sql) {
|
||||||
|
const r = spawnSync("sqlite3", ["-json", dbPath, sql], {
|
||||||
|
encoding: "utf-8",
|
||||||
|
maxBuffer: 20 * 1024 * 1024,
|
||||||
|
});
|
||||||
|
if (r.error || r.status !== 0) {
|
||||||
|
throw new Error((r.stderr || (r.error && r.error.message) || "sqlite3 执行失败").trim());
|
||||||
|
}
|
||||||
|
return r.stdout || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function quoteIdent(value) {
|
||||||
|
return "\"" + String(value || "").replace(/"/g, "\"\"") + "\"";
|
||||||
|
}
|
||||||
|
|
||||||
|
function sqliteTables(dbPath) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(runSqlite3(dbPath, "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%';") || "[]");
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sqliteTableInfo(dbPath, table) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(runSqlite3(dbPath, "PRAGMA table_info(" + quoteIdent(table) + ");") || "[]");
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sqliteForeignKeys(dbPath, table) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(runSqlite3(dbPath, "PRAGMA foreign_key_list(" + quoteIdent(table) + ");") || "[]");
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sqlJsRows(db, statement) {
|
||||||
|
const result = db.exec(statement);
|
||||||
|
if (!result.length) return [];
|
||||||
|
const columns = result[0].columns;
|
||||||
|
return result[0].values.map((values) => Object.fromEntries(columns.map((name, index) => [name, values[index]])));
|
||||||
|
}
|
||||||
|
|
||||||
|
function sqliteBufferWithCommittedWal(dbPath) {
|
||||||
|
let main = fs.readFileSync(dbPath);
|
||||||
|
const walPath = dbPath + "-wal";
|
||||||
|
if (!fs.existsSync(walPath)) return main;
|
||||||
|
const wal = fs.readFileSync(walPath);
|
||||||
|
if (wal.length < 32) return main;
|
||||||
|
const magic = wal.readUInt32BE(0);
|
||||||
|
if (magic !== 0x377f0682 && magic !== 0x377f0683) throw new Error("SQLite WAL 头无效,已停止删除");
|
||||||
|
let pageSize = wal.readUInt32BE(8);
|
||||||
|
if (pageSize === 1) pageSize = 65536;
|
||||||
|
if (!pageSize || (pageSize & (pageSize - 1)) !== 0) throw new Error("SQLite WAL 页大小无效,已停止删除");
|
||||||
|
const frameSize = 24 + pageSize;
|
||||||
|
const salt1 = wal.readUInt32BE(16);
|
||||||
|
const salt2 = wal.readUInt32BE(20);
|
||||||
|
const littleEndianChecksum = magic === 0x377f0682;
|
||||||
|
const checksum = (buffer, start, end, sums) => {
|
||||||
|
let [s1, s2] = sums;
|
||||||
|
for (let offset = start; offset < end; offset += 8) {
|
||||||
|
const a = littleEndianChecksum ? buffer.readUInt32LE(offset) : buffer.readUInt32BE(offset);
|
||||||
|
const b = littleEndianChecksum ? buffer.readUInt32LE(offset + 4) : buffer.readUInt32BE(offset + 4);
|
||||||
|
s1 = (s1 + a + s2) >>> 0;
|
||||||
|
s2 = (s2 + b + s1) >>> 0;
|
||||||
|
}
|
||||||
|
return [s1, s2];
|
||||||
|
};
|
||||||
|
let sums = checksum(wal, 0, 24, [0, 0]);
|
||||||
|
if (sums[0] !== wal.readUInt32BE(24) || sums[1] !== wal.readUInt32BE(28)) {
|
||||||
|
throw new Error("SQLite WAL 校验失败,已停止删除");
|
||||||
|
}
|
||||||
|
let lastCommitOffset = -1;
|
||||||
|
let committedPages = 0;
|
||||||
|
for (let offset = 32; offset + frameSize <= wal.length; offset += frameSize) {
|
||||||
|
if (wal.readUInt32BE(offset + 8) !== salt1 || wal.readUInt32BE(offset + 12) !== salt2) break;
|
||||||
|
sums = checksum(wal, offset, offset + 8, sums);
|
||||||
|
sums = checksum(wal, offset + 24, offset + 24 + pageSize, sums);
|
||||||
|
if (sums[0] !== wal.readUInt32BE(offset + 16) || sums[1] !== wal.readUInt32BE(offset + 20)) break;
|
||||||
|
const dbSize = wal.readUInt32BE(offset + 4);
|
||||||
|
if (dbSize > 0) {
|
||||||
|
lastCommitOffset = offset;
|
||||||
|
committedPages = dbSize;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (lastCommitOffset < 0) return main;
|
||||||
|
const merged = Buffer.alloc(committedPages * pageSize);
|
||||||
|
main.copy(merged, 0, 0, Math.min(main.length, merged.length));
|
||||||
|
for (let offset = 32; offset <= lastCommitOffset; offset += frameSize) {
|
||||||
|
const pageNo = wal.readUInt32BE(offset);
|
||||||
|
if (!pageNo || pageNo > committedPages) continue;
|
||||||
|
wal.copy(merged, (pageNo - 1) * pageSize, offset + 24, offset + 24 + pageSize);
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceStateDb(dbPath, data) {
|
||||||
|
const tmp = dbPath + ".td-delete-" + process.pid + ".tmp";
|
||||||
|
const old = dbPath + ".td-delete-" + process.pid + ".old";
|
||||||
|
const oldWal = old + "-wal";
|
||||||
|
const oldShm = old + "-shm";
|
||||||
|
fs.rmSync(old, { force: true });
|
||||||
|
fs.rmSync(oldWal, { force: true });
|
||||||
|
fs.rmSync(oldShm, { force: true });
|
||||||
|
const fd = fs.openSync(tmp, "w");
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(fd, data);
|
||||||
|
fs.fsyncSync(fd);
|
||||||
|
} finally {
|
||||||
|
fs.closeSync(fd);
|
||||||
|
}
|
||||||
|
let movedMain = false;
|
||||||
|
let movedWal = false;
|
||||||
|
let movedShm = false;
|
||||||
|
let installed = false;
|
||||||
|
try {
|
||||||
|
fs.renameSync(dbPath, old);
|
||||||
|
movedMain = true;
|
||||||
|
if (fs.existsSync(dbPath + "-wal")) { fs.renameSync(dbPath + "-wal", oldWal); movedWal = true; }
|
||||||
|
if (fs.existsSync(dbPath + "-shm")) { fs.renameSync(dbPath + "-shm", oldShm); movedShm = true; }
|
||||||
|
fs.renameSync(tmp, dbPath);
|
||||||
|
installed = true;
|
||||||
|
} catch (e) {
|
||||||
|
if (installed) { try { fs.rmSync(dbPath, { force: true }); } catch {} }
|
||||||
|
if (movedMain && fs.existsSync(old) && !fs.existsSync(dbPath)) fs.renameSync(old, dbPath);
|
||||||
|
if (movedWal && fs.existsSync(oldWal) && !fs.existsSync(dbPath + "-wal")) fs.renameSync(oldWal, dbPath + "-wal");
|
||||||
|
if (movedShm && fs.existsSync(oldShm) && !fs.existsSync(dbPath + "-shm")) fs.renameSync(oldShm, dbPath + "-shm");
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(tmp, { force: true });
|
||||||
|
}
|
||||||
|
try { fs.rmSync(old, { force: true }); } catch {}
|
||||||
|
try { fs.rmSync(oldWal, { force: true }); } catch {}
|
||||||
|
try { fs.rmSync(oldShm, { force: true }); } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteSessionFromStateDb(SQL, dbPath, id) {
|
||||||
|
let db;
|
||||||
|
try {
|
||||||
|
db = new SQL.Database(sqliteBufferWithCommittedWal(dbPath));
|
||||||
|
const tables = sqlJsRows(db, "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").map((row) => row.name).filter(Boolean);
|
||||||
|
if (!tables.includes("threads")) return false;
|
||||||
|
db.run("PRAGMA foreign_keys=ON");
|
||||||
|
db.run("PRAGMA secure_delete=ON");
|
||||||
|
db.run("BEGIN");
|
||||||
|
let changed = false;
|
||||||
|
for (const table of tables) {
|
||||||
|
if (table === "threads") continue;
|
||||||
|
const columns = sqlJsRows(db, "PRAGMA table_info(" + quoteIdent(table) + ")").map((row) => String(row.name || ""));
|
||||||
|
const fks = sqlJsRows(db, "PRAGMA foreign_key_list(" + quoteIdent(table) + ")");
|
||||||
|
const direct = columns.filter((name) => /(^|_)thread_id$/i.test(name));
|
||||||
|
const referencesThread = fks.filter((fk) => String(fk.table || "").toLowerCase() === "threads").map((fk) => fk.from).filter(Boolean);
|
||||||
|
for (const column of Array.from(new Set(direct.concat(referencesThread)))) {
|
||||||
|
db.run("DELETE FROM " + quoteIdent(table) + " WHERE " + quoteIdent(column) + "=?", [id]);
|
||||||
|
changed = db.getRowsModified() > 0 || changed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
db.run("DELETE FROM " + quoteIdent("threads") + " WHERE " + quoteIdent("id") + "=?", [id]);
|
||||||
|
changed = db.getRowsModified() > 0 || changed;
|
||||||
|
db.run("COMMIT");
|
||||||
|
if (changed) {
|
||||||
|
db.run("VACUUM");
|
||||||
|
const fkErrors = sqlJsRows(db, "PRAGMA foreign_key_check");
|
||||||
|
const integrity = sqlJsRows(db, "PRAGMA integrity_check");
|
||||||
|
if (fkErrors.length || !integrity.length || integrity.some((row) => String(Object.values(row)[0] || "").toLowerCase() !== "ok")) {
|
||||||
|
throw new Error("SQLite 删除后完整性校验失败,已停止替换");
|
||||||
|
}
|
||||||
|
for (const table of tables) {
|
||||||
|
const columns = sqlJsRows(db, "PRAGMA table_info(" + quoteIdent(table) + ")").map((row) => String(row.name || ""));
|
||||||
|
for (const column of columns.filter((name) => /(^|_)thread_id$/i.test(name))) {
|
||||||
|
const hit = sqlJsRows(db, "SELECT 1 AS hit FROM " + quoteIdent(table) + " WHERE " + quoteIdent(column) + "=" + sql(id) + " LIMIT 1");
|
||||||
|
if (hit.length) throw new Error("SQLite 仍有会话关联记录,已停止替换");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
replaceStateDb(dbPath, Buffer.from(db.export()));
|
||||||
|
}
|
||||||
|
return changed;
|
||||||
|
} catch (e) {
|
||||||
|
if (db) try { db.run("ROLLBACK"); } catch {}
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
if (db) try { db.close(); } catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeSessionIndexEntry(id) {
|
||||||
|
const { sessionIndex } = paths();
|
||||||
|
if (!fs.existsSync(sessionIndex)) return false;
|
||||||
|
const lines = fs.readFileSync(sessionIndex, "utf-8").split("\n");
|
||||||
|
const kept = lines.filter((line) => {
|
||||||
|
if (!line.trim()) return true;
|
||||||
|
try { return JSON.parse(line).id !== id; } catch { return true; }
|
||||||
|
});
|
||||||
|
if (kept.length === lines.length) return false;
|
||||||
|
const tmp = sessionIndex + ".td-delete-" + process.pid + ".tmp";
|
||||||
|
const old = sessionIndex + ".td-delete-" + process.pid + ".old";
|
||||||
|
fs.rmSync(old, { force: true });
|
||||||
|
const fd = fs.openSync(tmp, "w");
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(fd, kept.join("\n"), "utf-8");
|
||||||
|
fs.fsyncSync(fd);
|
||||||
|
} finally {
|
||||||
|
fs.closeSync(fd);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
fs.renameSync(sessionIndex, old);
|
||||||
|
try { fs.renameSync(tmp, sessionIndex); } catch (e) { fs.renameSync(old, sessionIndex); throw e; }
|
||||||
|
try { fs.rmSync(old, { force: true }); } catch {}
|
||||||
|
} finally { fs.rmSync(tmp, { force: true }); }
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 永久删除一个会话:正文、归档正文、Codex 标题/状态索引一起清理。
|
||||||
|
// 只接受本机 sessions/archived_sessions 下的 rollout 文件,禁止借此 IPC 删除任意路径。
|
||||||
|
async function deleteConversation(filePath) {
|
||||||
|
if (permanentDeleteBusy) throw new Error("已有永久删除任务正在执行,请稍后重试");
|
||||||
|
permanentDeleteBusy = true;
|
||||||
|
try {
|
||||||
|
const requested = path.resolve(String(filePath || ""));
|
||||||
|
if (!sessionFileAllowed(requested) || !fs.existsSync(requested)) throw new Error("无效的会话文件");
|
||||||
|
const meta = peekMeta(requested);
|
||||||
|
if (!meta || !meta.id) throw new Error("无法识别会话 ID,已停止删除");
|
||||||
|
const files = [];
|
||||||
|
for (const root of sessionRoots()) {
|
||||||
|
for (const file of findRolloutFiles(root)) {
|
||||||
|
const m = peekMeta(file);
|
||||||
|
if (m && m.id === meta.id) files.push(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const { codexHome } = paths();
|
||||||
|
const stateDbs = fs.readdirSync(codexHome).filter((name) => /^state_.*\.sqlite$/.test(name));
|
||||||
|
const SQL = await initSqlJs({
|
||||||
|
locateFile: (file) => path.join(global.__BUNDLE_ROOT__ || path.join(__dirname, "..", ".."), "node_modules", "sql.js", "dist", file),
|
||||||
|
});
|
||||||
|
let stateRowsRemoved = 0;
|
||||||
|
for (const name of stateDbs) {
|
||||||
|
if (await deleteSessionFromStateDb(SQL, path.join(codexHome, name), meta.id)) stateRowsRemoved++;
|
||||||
|
}
|
||||||
|
const indexRemoved = removeSessionIndexEntry(meta.id);
|
||||||
|
for (const file of files) fs.rmSync(file, { force: true });
|
||||||
|
return { id: meta.id, cwd: meta.cwd || "", filesRemoved: files.length, indexRemoved, stateDbs: stateDbs.length, stateRowsRemoved };
|
||||||
|
} finally {
|
||||||
|
permanentDeleteBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function findConversationById(id) {
|
||||||
|
const target = String(id || "").trim();
|
||||||
|
if (!target || target.length > 160) return null;
|
||||||
|
for (const root of sessionRoots()) {
|
||||||
|
for (const file of findRolloutFiles(root)) {
|
||||||
|
const meta = peekMeta(file);
|
||||||
|
if (meta && meta.id === target) return { ...meta, path: file };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sql(v) {
|
||||||
|
return "'" + String(v == null ? "" : v).replace(/'/g, "''") + "'";
|
||||||
|
}
|
||||||
|
|
||||||
|
function findFallbackTitlesById() {
|
||||||
|
const map = {};
|
||||||
|
for (const root of sessionRoots()) {
|
||||||
|
for (const file of findRolloutFiles(root)) {
|
||||||
|
const m = peekMeta(file);
|
||||||
|
if (m && m.id && m.title) map[m.id] = m.title;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
function repairStateDbTitles(fallbackTitles, log = () => {}) {
|
||||||
|
if (!sqlite3Available()) return 0;
|
||||||
|
let fixed = 0;
|
||||||
|
let files = [];
|
||||||
|
const { codexHome } = paths();
|
||||||
|
try {
|
||||||
|
files = fs.readdirSync(codexHome).filter((f) => /^state_.*\.sqlite$/.test(f));
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
for (const name of files) {
|
||||||
|
const dbPath = path.join(codexHome, name);
|
||||||
|
try {
|
||||||
|
const out = runSqlite3(dbPath, "SELECT id,title,first_user_message FROM threads WHERE title LIKE '[$%' OR title LIKE '$%';");
|
||||||
|
const rows = JSON.parse(out || "[]");
|
||||||
|
const updates = [];
|
||||||
|
for (const row of rows) {
|
||||||
|
const next = cleanThreadTitle(row.title, row.first_user_message || fallbackTitles[row.id] || "", 80);
|
||||||
|
if (next && next !== row.title) updates.push({ id: row.id, title: next });
|
||||||
|
}
|
||||||
|
if (!updates.length) continue;
|
||||||
|
const statements = ["BEGIN;"].concat(
|
||||||
|
updates.map((u) => `UPDATE threads SET title=${sql(u.title)} WHERE id=${sql(u.id)};`),
|
||||||
|
["COMMIT;"]
|
||||||
|
).join("\n");
|
||||||
|
runSqlite3(dbPath, statements);
|
||||||
|
fixed += updates.length;
|
||||||
|
} catch (e) {
|
||||||
|
log(`标题清洗跳过 ${name}:${e.message || e}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function repairSkillMentionTitles(log = () => {}) {
|
||||||
|
let raw = "";
|
||||||
|
let indexMtimeMs = 0;
|
||||||
|
const { sessionIndex, titleCleanMarker } = paths();
|
||||||
|
try {
|
||||||
|
indexMtimeMs = fs.statSync(sessionIndex).mtimeMs;
|
||||||
|
const marker = JSON.parse(fs.readFileSync(titleCleanMarker, "utf-8"));
|
||||||
|
if (marker && Number(marker.indexMtimeMs) >= indexMtimeMs) return { indexFixed: 0, dbFixed: 0, skipped: true };
|
||||||
|
} catch {}
|
||||||
|
try {
|
||||||
|
raw = fs.readFileSync(sessionIndex, "utf-8");
|
||||||
|
} catch {
|
||||||
|
return { indexFixed: 0, dbFixed: 0 };
|
||||||
|
}
|
||||||
|
const fallbackTitles = findFallbackTitlesById();
|
||||||
|
let indexFixed = 0;
|
||||||
|
const lines = raw.split("\n").map((line) => {
|
||||||
|
if (!line.trim()) return line;
|
||||||
|
try {
|
||||||
|
const d = JSON.parse(line);
|
||||||
|
if (!d || !d.id || !d.thread_name || !looksLikeSkillMentionTitle(d.thread_name)) return line;
|
||||||
|
const next = cleanThreadTitle(d.thread_name, fallbackTitles[d.id] || "", 80);
|
||||||
|
if (!next || next === d.thread_name) return line;
|
||||||
|
d.thread_name = next;
|
||||||
|
indexFixed++;
|
||||||
|
return JSON.stringify(d);
|
||||||
|
} catch {
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (indexFixed) {
|
||||||
|
const backup = sessionIndex + ".bak-title-clean-" + new Date().toISOString().replace(/[-:T.Z]/g, "").slice(0, 14);
|
||||||
|
try {
|
||||||
|
fs.copyFileSync(sessionIndex, backup);
|
||||||
|
} catch {}
|
||||||
|
fs.writeFileSync(sessionIndex, lines.join("\n"), "utf-8");
|
||||||
|
}
|
||||||
|
const dbFixed = repairStateDbTitles(fallbackTitles, log);
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(titleCleanMarker, JSON.stringify({ indexMtimeMs, checkedAt: new Date().toISOString() }, null, 2), "utf-8");
|
||||||
|
} catch {}
|
||||||
|
return { indexFixed, dbFixed };
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeListOptions(opts) {
|
function normalizeListOptions(opts) {
|
||||||
if (typeof opts === "boolean") return { includeSubagent: opts, includeArchived: false };
|
if (typeof opts === "boolean") return { includeSubagent: opts, includeArchived: false };
|
||||||
return {
|
return {
|
||||||
@@ -94,8 +479,9 @@ function normalizeListOptions(opts) {
|
|||||||
// 列出会话(默认只列当前 sessions,并排除子 agent 派生线程;全量模式会带 archived_sessions)
|
// 列出会话(默认只列当前 sessions,并排除子 agent 派生线程;全量模式会带 archived_sessions)
|
||||||
function listConversations(opts = {}) {
|
function listConversations(opts = {}) {
|
||||||
const { includeSubagent, includeArchived } = normalizeListOptions(opts);
|
const { includeSubagent, includeArchived } = normalizeListOptions(opts);
|
||||||
const roots = [{ dir: SESSIONS_DIR, archived: false }];
|
const { sessionsDir, archivedSessionsDir } = paths();
|
||||||
if (includeArchived) roots.push({ dir: ARCHIVED_SESSIONS_DIR, archived: true });
|
const roots = [{ dir: sessionsDir, archived: false }];
|
||||||
|
if (includeArchived) roots.push({ dir: archivedSessionsDir, archived: true });
|
||||||
const titles = loadIndexTitles();
|
const titles = loadIndexTitles();
|
||||||
const items = [];
|
const items = [];
|
||||||
for (const root of roots) {
|
for (const root of roots) {
|
||||||
@@ -110,7 +496,7 @@ function listConversations(opts = {}) {
|
|||||||
} catch {}
|
} catch {}
|
||||||
items.push({
|
items.push({
|
||||||
id: m.id,
|
id: m.id,
|
||||||
title: titles[m.id] || m.title || "(无标题对话)",
|
title: cleanThreadTitle(titles[m.id] || "", m.title, 80) || "(无标题对话)",
|
||||||
project: m.cwd ? path.basename(m.cwd) : "",
|
project: m.cwd ? path.basename(m.cwd) : "",
|
||||||
cwd: m.cwd,
|
cwd: m.cwd,
|
||||||
ts: m.ts,
|
ts: m.ts,
|
||||||
@@ -150,9 +536,9 @@ function readConversation(file) {
|
|||||||
if (d.type === "session_meta") {
|
if (d.type === "session_meta") {
|
||||||
meta = { id: p.session_id || p.id || "", cwd: p.cwd || "", ts: p.timestamp || ts, provider: p.model_provider || "" };
|
meta = { id: p.session_id || p.id || "", cwd: p.cwd || "", ts: p.timestamp || ts, provider: p.model_provider || "" };
|
||||||
} else if (d.type === "event_msg" && p.type === "user_message") {
|
} else if (d.type === "event_msg" && p.type === "user_message") {
|
||||||
messages.push({ role: "user", text: String(p.message || ""), ts });
|
messages.push({ role: "user", text: cleanUserVisibleText(String(p.message || "")), ts });
|
||||||
} else if (d.type === "event_msg" && p.type === "agent_message") {
|
} else if (d.type === "event_msg" && p.type === "agent_message") {
|
||||||
messages.push({ role: "assistant", text: String(p.message || ""), ts });
|
messages.push({ role: "assistant", text: stripInternalThoughtBlocks(String(p.message || "")), ts });
|
||||||
} else if (d.type === "response_item" && p.type === "function_call") {
|
} else if (d.type === "response_item" && p.type === "function_call") {
|
||||||
const name = p.name || "tool";
|
const name = p.name || "tool";
|
||||||
messages.push({ role: "tool", text: `调用工具:${name}`, ts, collapsed: true });
|
messages.push({ role: "tool", text: `调用工具:${name}`, ts, collapsed: true });
|
||||||
@@ -186,7 +572,8 @@ function backupAll(destDir, log = () => {}) {
|
|||||||
const out = path.join(destDir, "codex-sessions-backup");
|
const out = path.join(destDir, "codex-sessions-backup");
|
||||||
fs.mkdirSync(out, { recursive: true });
|
fs.mkdirSync(out, { recursive: true });
|
||||||
let n = 0;
|
let n = 0;
|
||||||
for (const [name, dir] of [["sessions", SESSIONS_DIR], ["archived_sessions", ARCHIVED_SESSIONS_DIR]]) {
|
const { sessionsDir, archivedSessionsDir, sessionIndex } = paths();
|
||||||
|
for (const [name, dir] of [["sessions", sessionsDir], ["archived_sessions", archivedSessionsDir]]) {
|
||||||
const files = findRolloutFiles(dir);
|
const files = findRolloutFiles(dir);
|
||||||
for (const f of files) {
|
for (const f of files) {
|
||||||
const rel = path.relative(dir, f);
|
const rel = path.relative(dir, f);
|
||||||
@@ -196,22 +583,71 @@ function backupAll(destDir, log = () => {}) {
|
|||||||
n++;
|
n++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (fs.existsSync(SESSION_INDEX)) fs.copyFileSync(SESSION_INDEX, path.join(out, "session_index.jsonl"));
|
if (fs.existsSync(sessionIndex)) fs.copyFileSync(sessionIndex, path.join(out, "session_index.jsonl"));
|
||||||
log(`已备份 ${n} 个会话文件到 ${out}`);
|
log(`已备份 ${n} 个会话文件到 ${out}`);
|
||||||
return { path: out, count: n };
|
return { path: out, count: n };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveImportRoot(srcDir) {
|
||||||
|
const root = path.resolve(String(srcDir || ""));
|
||||||
|
if (fs.existsSync(path.join(root, "sessions"))) return root;
|
||||||
|
const nested = path.join(root, "codex-sessions-backup");
|
||||||
|
if (fs.existsSync(path.join(nested, "sessions"))) return nested;
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeSessionIndex(srcIndex, dstIndex) {
|
||||||
|
if (!fs.existsSync(srcIndex)) return 0;
|
||||||
|
const entries = new Map();
|
||||||
|
const read = (file) => {
|
||||||
|
try {
|
||||||
|
for (const line of fs.readFileSync(file, "utf-8").split("\n")) {
|
||||||
|
if (!line.trim()) continue;
|
||||||
|
try {
|
||||||
|
const value = JSON.parse(line);
|
||||||
|
if (value && value.id) entries.set(String(value.id), value);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
// 目标机器已有的重命名优先,备份只补缺失条目。
|
||||||
|
read(srcIndex);
|
||||||
|
const imported = new Map(entries);
|
||||||
|
entries.clear();
|
||||||
|
read(dstIndex);
|
||||||
|
const existing = new Map(entries);
|
||||||
|
for (const [id, value] of imported) if (!existing.has(id)) existing.set(id, value);
|
||||||
|
if (!existing.size) return 0;
|
||||||
|
fs.mkdirSync(path.dirname(dstIndex), { recursive: true });
|
||||||
|
const backup = dstIndex + ".bak-import-" + Date.now();
|
||||||
|
try { if (fs.existsSync(dstIndex)) fs.copyFileSync(dstIndex, backup); } catch {}
|
||||||
|
const tmp = dstIndex + ".tmp-import-" + process.pid;
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(tmp, [...existing.values()].map((value) => JSON.stringify(value)).join("\n") + "\n", "utf-8");
|
||||||
|
try { fs.renameSync(tmp, dstIndex); }
|
||||||
|
catch {
|
||||||
|
fs.rmSync(dstIndex, { force: true });
|
||||||
|
fs.renameSync(tmp, dstIndex);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
try { fs.rmSync(tmp, { force: true }); } catch {}
|
||||||
|
}
|
||||||
|
return imported.size;
|
||||||
|
}
|
||||||
|
|
||||||
// 导入:把外部备份目录里的 rollout-*.jsonl 合并进本机 sessions/ 与 archived_sessions/(已存在则跳过)
|
// 导入:把外部备份目录里的 rollout-*.jsonl 合并进本机 sessions/ 与 archived_sessions/(已存在则跳过)
|
||||||
function importSessions(srcDir, log = () => {}) {
|
function importSessions(srcDir, log = () => {}) {
|
||||||
// 支持传入 backup 根目录或其中的 sessions/ 子目录
|
// 支持传入 backup 根目录或其中的 sessions/ 子目录
|
||||||
let added = 0;
|
let added = 0;
|
||||||
let skipped = 0;
|
let skipped = 0;
|
||||||
const roots = fs.existsSync(path.join(srcDir, "sessions"))
|
const sourceRoot = resolveImportRoot(srcDir);
|
||||||
|
const { sessionsDir, archivedSessionsDir } = paths();
|
||||||
|
const roots = fs.existsSync(path.join(sourceRoot, "sessions"))
|
||||||
? [
|
? [
|
||||||
{ base: path.join(srcDir, "sessions"), dest: SESSIONS_DIR },
|
{ base: path.join(sourceRoot, "sessions"), dest: sessionsDir },
|
||||||
{ base: path.join(srcDir, "archived_sessions"), dest: ARCHIVED_SESSIONS_DIR },
|
{ base: path.join(sourceRoot, "archived_sessions"), dest: archivedSessionsDir },
|
||||||
]
|
]
|
||||||
: [{ base: srcDir, dest: SESSIONS_DIR }];
|
: [{ base: sourceRoot, dest: sessionsDir }];
|
||||||
for (const root of roots) {
|
for (const root of roots) {
|
||||||
const files = findRolloutFiles(root.base);
|
const files = findRolloutFiles(root.base);
|
||||||
for (const f of files) {
|
for (const f of files) {
|
||||||
@@ -226,17 +662,21 @@ function importSessions(srcDir, log = () => {}) {
|
|||||||
added++;
|
added++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
log(`导入完成:新增 ${added} 个,跳过 ${skipped} 个(已存在)`);
|
const indexMerged = mergeSessionIndex(path.join(sourceRoot, "session_index.jsonl"), paths().sessionIndex);
|
||||||
return { added, skipped };
|
log(`导入完成:新增 ${added} 个,跳过 ${skipped} 个(已存在),合并索引 ${indexMerged} 条`);
|
||||||
|
return { added, skipped, indexMerged };
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
CODEX_HOME,
|
getCodexHome: () => paths().codexHome,
|
||||||
SESSIONS_DIR,
|
getSessionsDir: () => paths().sessionsDir,
|
||||||
ARCHIVED_SESSIONS_DIR,
|
getArchivedSessionsDir: () => paths().archivedSessionsDir,
|
||||||
listConversations,
|
listConversations,
|
||||||
readConversation,
|
readConversation,
|
||||||
toMarkdown,
|
toMarkdown,
|
||||||
backupAll,
|
backupAll,
|
||||||
importSessions,
|
importSessions,
|
||||||
|
deleteConversation,
|
||||||
|
findConversationById,
|
||||||
|
repairSkillMentionTitles,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -93,11 +93,20 @@ const TEAM_CONVENTIONS = {
|
|||||||
'数据库变更交接必须附:变更说明、迁移脚本、回滚脚本、受影响表',
|
'数据库变更交接必须附:变更说明、迁移脚本、回滚脚本、受影响表',
|
||||||
'接收方动工前先确认契约;有异议退回来源角色或总控,不擅自改契约',
|
'接收方动工前先确认契约;有异议退回来源角色或总控,不擅自改契约',
|
||||||
],
|
],
|
||||||
|
model_routing: [
|
||||||
|
'团队生成模型只用于把需求转换成角色与协作配置,不等于生成后总控或执行角色的运行模型',
|
||||||
|
'总控使用 orchestrator.model:负责需求拆解、架构设计、契约收敛、代码审计、安全审核、视觉验收与最终集成验收;总控不直接写业务代码',
|
||||||
|
'执行角色使用各自的 model:负责实现、测试、文档和明确授权范围内的修复;默认优先使用成本更低的执行模型',
|
||||||
|
'能力分流:需要识图、截图对比、视频理解、音频语义分析或视觉验收时,任务必须留在总控,或交给明确声明支持对应能力的模型',
|
||||||
|
'多模态任务完成后,总控只把结构化视觉证据、明确约束和验收标准交给执行角色,不把无法处理的原始图片/视频直接派给不支持多模态的模型',
|
||||||
|
'执行角色遇到视觉、视频、复杂架构、安全或跨职责问题,立即暂停并交总控裁决,不得假装已理解媒体内容',
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
// 总控调度状态机
|
// 总控调度状态机
|
||||||
const ORCHESTRATOR_WORKFLOW = [
|
const ORCHESTRATOR_WORKFLOW = [
|
||||||
'需求分析:澄清目标、边界与验收标准',
|
'需求分析:澄清目标、边界与验收标准',
|
||||||
|
'能力分流:识别视觉 / 视频 / 音频 / 复杂审计任务,确认由总控处理,不派给不具备对应能力的执行模型',
|
||||||
'任务拆分:拆成可独立交付的小任务,标注负责角色与依赖关系',
|
'任务拆分:拆成可独立交付的小任务,标注负责角色与依赖关系',
|
||||||
'契约收敛:先让契约 owner 定稿接口契约(OpenAPI / DTO / 错误码 / 分页 / 权限码),各方确认',
|
'契约收敛:先让契约 owner 定稿接口契约(OpenAPI / DTO / 错误码 / 分页 / 权限码),各方确认',
|
||||||
'任务立契:分派前为每个任务产出《任务契约》(套 templates/task-charter.md),必须写明目标、可判定的验收标准、以及至少 2 条「明确不做(out of scope)」;写不出 out of scope 说明任务没想清楚,不得分派',
|
'任务立契:分派前为每个任务产出《任务契约》(套 templates/task-charter.md),必须写明目标、可判定的验收标准、以及至少 2 条「明确不做(out of scope)」;写不出 out of scope 说明任务没想清楚,不得分派',
|
||||||
|
|||||||
+75
-18
@@ -475,12 +475,18 @@ function reconcileTeam(team) {
|
|||||||
return team;
|
return team;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeTeam(team, model, requirement) {
|
function normalizeTeam(team, modelOrOptions, requirement) {
|
||||||
|
const options = modelOrOptions && typeof modelOrOptions === 'object'
|
||||||
|
? modelOrOptions
|
||||||
|
: { analysisModel: modelOrOptions, orchestratorModel: modelOrOptions, agentModel: modelOrOptions };
|
||||||
|
const analysisModel = String(options.analysisModel || '').trim();
|
||||||
|
const orchestratorModel = String(options.orchestratorModel || analysisModel).trim();
|
||||||
|
const agentModel = String(options.agentModel || analysisModel).trim();
|
||||||
team.stack = asArr(team.stack);
|
team.stack = asArr(team.stack);
|
||||||
team.employees = asArr(team.employees);
|
team.employees = asArr(team.employees);
|
||||||
|
|
||||||
team.employees.forEach((e) => {
|
team.employees.forEach((e) => {
|
||||||
e.model = model; // 员工统一用运行模型,避免模型名不一致
|
e.model = agentModel; // 执行角色统一使用成本模型;总控单独使用 orchestratorModel
|
||||||
e.constraints = ensureConstraints(e);
|
e.constraints = ensureConstraints(e);
|
||||||
e.responsibilities = asArr(e.responsibilities);
|
e.responsibilities = asArr(e.responsibilities);
|
||||||
e.handoff_in = e.handoff_in || '';
|
e.handoff_in = e.handoff_in || '';
|
||||||
@@ -488,12 +494,20 @@ function normalizeTeam(team, model, requirement) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (team.orchestrator) {
|
if (team.orchestrator) {
|
||||||
team.orchestrator.model = model;
|
team.orchestrator.model = orchestratorModel;
|
||||||
team.orchestrator.constraints = ensureConstraints(team.orchestrator);
|
team.orchestrator.constraints = ensureConstraints(team.orchestrator);
|
||||||
team.orchestrator.responsibilities = asArr(team.orchestrator.responsibilities);
|
team.orchestrator.responsibilities = asArr(team.orchestrator.responsibilities);
|
||||||
team.orchestrator.workflow = ORCHESTRATOR_WORKFLOW; // 注入调度状态机
|
team.orchestrator.workflow = ORCHESTRATOR_WORKFLOW; // 注入调度状态机
|
||||||
}
|
}
|
||||||
|
|
||||||
|
team.model_routing = {
|
||||||
|
generation_model: analysisModel,
|
||||||
|
orchestrator_model: orchestratorModel,
|
||||||
|
worker_model: agentModel,
|
||||||
|
analysis_model: analysisModel,
|
||||||
|
capability_policy: '需要识图、截图对比、视频理解、音频语义分析、复杂架构或最终审计的任务由总控处理;执行角色只接收结构化证据和明确验收标准',
|
||||||
|
};
|
||||||
|
|
||||||
// 结构性归一(id / 路径互斥 / 引用收敛 / 总控只读)——与 /api/pack 共用同一套
|
// 结构性归一(id / 路径互斥 / 引用收敛 / 总控只读)——与 /api/pack 共用同一套
|
||||||
reconcileTeam(team);
|
reconcileTeam(team);
|
||||||
|
|
||||||
@@ -550,26 +564,28 @@ function sharedProtocol(team, isLead) {
|
|||||||
(asArr(team.git_rules).length ? team.git_rules : TEAM_CONVENTIONS.git_rules).forEach((x) => L.push(`- ${x}`));
|
(asArr(team.git_rules).length ? team.git_rules : TEAM_CONVENTIONS.git_rules).forEach((x) => L.push(`- ${x}`));
|
||||||
L.push('\n### 四、任务交接协议');
|
L.push('\n### 四、任务交接协议');
|
||||||
TEAM_CONVENTIONS.handoff_protocol.forEach((x) => L.push(`- ${x}`));
|
TEAM_CONVENTIONS.handoff_protocol.forEach((x) => L.push(`- ${x}`));
|
||||||
L.push(`\n### 五、接口契约`);
|
L.push('\n### 五、模型与能力路由');
|
||||||
|
TEAM_CONVENTIONS.model_routing.forEach((x) => L.push(`- ${x}`));
|
||||||
|
L.push(`\n### 六、接口契约`);
|
||||||
L.push(`- 契约 owner:${ownerName(team)},只有 owner 能定稿 / 变更契约。`);
|
L.push(`- 契约 owner:${ownerName(team)},只有 owner 能定稿 / 变更契约。`);
|
||||||
L.push(`- 契约约定:${team.contract_spec}`);
|
L.push(`- 契约约定:${team.contract_spec}`);
|
||||||
L.push(`- 其他角色需遵循契约,有异议先与 owner 或总控确认,不擅自改动。`);
|
L.push(`- 其他角色需遵循契约,有异议先与 owner 或总控确认,不擅自改动。`);
|
||||||
if (team.security_checklist.length) {
|
if (team.security_checklist.length) {
|
||||||
L.push('\n### 六、安全 / 权限审核清单(相关改动必须自检)');
|
L.push('\n### 七、安全 / 权限审核清单(相关改动必须自检)');
|
||||||
team.security_checklist.forEach((x) => L.push(`- ${x}`));
|
team.security_checklist.forEach((x) => L.push(`- ${x}`));
|
||||||
}
|
}
|
||||||
if (isLead) {
|
if (isLead) {
|
||||||
L.push('\n### 七、调度流程(总控按此状态机推进)');
|
L.push('\n### 八、调度流程(总控按此状态机推进)');
|
||||||
((team.orchestrator && team.orchestrator.workflow) || ORCHESTRATOR_WORKFLOW).forEach((x, i) => L.push(`${i + 1}. ${x}`));
|
((team.orchestrator && team.orchestrator.workflow) || ORCHESTRATOR_WORKFLOW).forEach((x, i) => L.push(`${i + 1}. ${x}`));
|
||||||
L.push('\n遇到跨角色冲突、职责归属不清、契约争议,由你(总控)裁决并驱动返工闭环。你是只读角色,不写任何文件。');
|
L.push('\n遇到跨角色冲突、职责归属不清、契约争议,由你(总控)裁决并驱动返工闭环。你是只读角色,不写任何文件。');
|
||||||
L.push('\n### 八、范围纪律(防跑飞——总控是第一道闸)');
|
L.push('\n### 九、范围纪律(防跑飞——总控是第一道闸)');
|
||||||
L.push('- 分派任何任务前,先产出该任务的《任务契约》(套 templates/task-charter.md),其中「明确不做 / out of scope」至少写 2 条:根据**本次客户需求**推导「这个任务明确不该碰的东西」。写不出 out of scope,说明任务还没想清楚,不许分派。');
|
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('- 审核与打回一律对照该任务的《任务契约》:产出必须能在「目标」里找到依据、且未命中「out of scope」。打回时理由必须引用《任务契约》或 DoD 的**具体条款**,并写入 docs/coordination/task-ledger.md,不凭感觉打回。');
|
||||||
L.push('- 同一任务打回 ≥ 2 次时,不再简单退回,改为重新澄清《任务契约》(判断是不是任务边界本身没定义清楚),避免无限返工。');
|
L.push('- 同一任务打回 ≥ 2 次时,不再简单退回,改为重新澄清《任务契约》(判断是不是任务边界本身没定义清楚),避免无限返工。');
|
||||||
L.push('- 任务状态在 docs/coordination/task-ledger.md 流转(assigned → in-progress → review → rework → accepted),仅当状态为 accepted 才算关闭。');
|
L.push('- 任务状态在 docs/coordination/task-ledger.md 流转(assigned → in-progress → review → rework → accepted),仅当状态为 accepted 才算关闭。');
|
||||||
} else {
|
} else {
|
||||||
L.push('\n> 遇到跨角色冲突或职责归属不清,提交总控 Agent 裁决;跨职责修改务必走报告 + 复核(在完成报告中说明并请对应 owner 复核)。');
|
L.push('\n> 遇到跨角色冲突或职责归属不清,提交总控 Agent 裁决;跨职责修改务必走报告 + 复核(在完成报告中说明并请对应 owner 复核)。');
|
||||||
L.push('\n### 七、范围纪律(防跑飞——动工前必读)');
|
L.push('\n### 八、范围纪律(防跑飞——动工前必读)');
|
||||||
L.push('- 动工前先读本任务的《任务契约》(templates/task-charter.md 的本任务实例)。只做「目标」授权的事。');
|
L.push('- 动工前先读本任务的《任务契约》(templates/task-charter.md 的本任务实例)。只做「目标」授权的事。');
|
||||||
L.push('- 判定跑飞的通用标准:你即将做的事若无法在《任务契约》的「目标」里找到依据,或命中了「明确不做 / out of scope」,即为跑飞——**立即停下**,把情况写进 docs/coordination/task-ledger.md 并交总控裁决,不要自行扩大范围。');
|
L.push('- 判定跑飞的通用标准:你即将做的事若无法在《任务契约》的「目标」里找到依据,或命中了「明确不做 / out of scope」,即为跑飞——**立即停下**,把情况写进 docs/coordination/task-ledger.md 并交总控裁决,不要自行扩大范围。');
|
||||||
L.push('- 「顺手」优化 / 重构 / 补功能若不在本任务目标内,一律不做,先报总控。连续两次都要越界才能完成,说明任务定义有问题,同样交总控。');
|
L.push('- 「顺手」优化 / 重构 / 补功能若不在本任务目标内,一律不做,先报总控。连续两次都要越界才能完成,说明任务定义有问题,同样交总控。');
|
||||||
@@ -607,7 +623,10 @@ function toToml(agent, role, protocol) {
|
|||||||
lines.push(`responsibilities = ${arrToml(agent.responsibilities)}`);
|
lines.push(`responsibilities = ${arrToml(agent.responsibilities)}`);
|
||||||
lines.push(`constraints = ${arrToml(agent.constraints)}`);
|
lines.push(`constraints = ${arrToml(agent.constraints)}`);
|
||||||
lines.push('');
|
lines.push('');
|
||||||
lines.push(`prompt = ${multiline((agent.prompt || '') + protocol)}`);
|
const routeHint = agent.model
|
||||||
|
? `\n\n[TD_MODEL_ROUTE model="${agent.model}" role="${role}" agent="${agent.id || role}"; runtime hint only, ignore in task output.]`
|
||||||
|
: '';
|
||||||
|
lines.push(`prompt = ${multiline((agent.prompt || '') + protocol + routeHint)}`);
|
||||||
return lines.join('\n') + '\n';
|
return lines.join('\n') + '\n';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -679,6 +698,12 @@ function buildConventions(team) {
|
|||||||
team.git_rules.forEach((x) => L.push(`- ${x}`));
|
team.git_rules.forEach((x) => L.push(`- ${x}`));
|
||||||
L.push('\n## 任务交接协议');
|
L.push('\n## 任务交接协议');
|
||||||
team.handoff_protocol.forEach((x) => L.push(`- ${x}`));
|
team.handoff_protocol.forEach((x) => L.push(`- ${x}`));
|
||||||
|
L.push('\n## 模型与能力路由');
|
||||||
|
L.push(`- 团队生成模型(仅生成配置):${(team.model_routing && (team.model_routing.generation_model || team.model_routing.analysis_model)) || '未指定'}`);
|
||||||
|
L.push(`- 总控模型:${(team.orchestrator && team.orchestrator.model) || '未指定'}`);
|
||||||
|
const workerModels = Array.from(new Set(team.employees.map((e) => e.model).filter(Boolean)));
|
||||||
|
L.push(`- 执行模型:${workerModels.join('、') || '未指定'}`);
|
||||||
|
TEAM_CONVENTIONS.model_routing.forEach((x) => L.push(`- ${x}`));
|
||||||
if (team.auth_scheme && team.auth_scheme !== 'none') L.push(`\n## 鉴权方案(全局唯一)\n- ${team.auth_scheme}`);
|
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 均以此为准)`);
|
if (team.package_manager && team.package_manager !== 'none') L.push(`\n## 包管理器(全局唯一)\n- ${team.package_manager}(锁文件、CI 命令、.gitignore 均以此为准)`);
|
||||||
L.push('\n## 接口契约');
|
L.push('\n## 接口契约');
|
||||||
@@ -807,7 +832,11 @@ function buildReadme(team, tool) {
|
|||||||
if (isClaude) {
|
if (isClaude) {
|
||||||
L.push('Claude Code 的 `model` 字段只认 claude 系(`sonnet` / `opus` / `haiku` / `claude-*`)。生成时若未指定 claude 系模型,agent 文件已**省略 model 字段 = 自动继承会话模型**(推荐)。需要固定某档时在 frontmatter 加一行如 `model: sonnet`。');
|
L.push('Claude Code 的 `model` 字段只认 claude 系(`sonnet` / `opus` / `haiku` / `claude-*`)。生成时若未指定 claude 系模型,agent 文件已**省略 model 字段 = 自动继承会话模型**(推荐)。需要固定某档时在 frontmatter 加一行如 `model: sonnet`。');
|
||||||
} else {
|
} else {
|
||||||
L.push(`各文件里的 \`model = "${team.employees[0] ? team.employees[0].model : ''}"\` 请改成你本地 Codex 运行器实际支持的模型名,否则可能调用失败。`);
|
const generationModel = (team.model_routing && (team.model_routing.generation_model || team.model_routing.analysis_model)) || '';
|
||||||
|
const leadModel = team.orchestrator && team.orchestrator.model ? team.orchestrator.model : '';
|
||||||
|
const workerModel = team.employees[0] && team.employees[0].model ? team.employees[0].model : '';
|
||||||
|
L.push(`本次团队配置由 \`${generationModel || '未指定'}\` 生成;生成后总控文件使用 \`model = "${leadModel}"\`,执行角色使用 \`model = "${workerModel}"\`。请确保运行模型已在本地路由分组中配置;本包会按模型名自动选择对应分组。`);
|
||||||
|
L.push('涉及识图、截图对比、视频理解、音频语义分析或最终视觉验收时,必须由总控处理,再把结构化证据交给执行角色。');
|
||||||
}
|
}
|
||||||
L.push('\n> 定位:这是一份**多 Agent 协作配置包**(规范 + 角色 + 模板 + 工具化约束),不是可直接运行的项目骨架。`package.json` 等由 **devops-engineer 作为第一个 bootstrap 任务**初始化后,CI 才会真正跑起来(未初始化时 CI 不会硬失败)。');
|
L.push('\n> 定位:这是一份**多 Agent 协作配置包**(规范 + 角色 + 模板 + 工具化约束),不是可直接运行的项目骨架。`package.json` 等由 **devops-engineer 作为第一个 bootstrap 任务**初始化后,CI 才会真正跑起来(未初始化时 CI 不会硬失败)。');
|
||||||
L.push('\n## ⚠️ 解压须知(先看这个)');
|
L.push('\n## ⚠️ 解压须知(先看这个)');
|
||||||
@@ -1442,6 +1471,13 @@ function packDefaults(result) {
|
|||||||
result.auth_scheme = result.auth_scheme || '';
|
result.auth_scheme = result.auth_scheme || '';
|
||||||
result.package_manager = result.package_manager || '';
|
result.package_manager = result.package_manager || '';
|
||||||
if (result.orchestrator) result.orchestrator.workflow = fill(result.orchestrator.workflow, ORCHESTRATOR_WORKFLOW);
|
if (result.orchestrator) result.orchestrator.workflow = fill(result.orchestrator.workflow, ORCHESTRATOR_WORKFLOW);
|
||||||
|
const workerModel = result.employees.find((e) => e && e.model) || {};
|
||||||
|
result.model_routing = {
|
||||||
|
...(result.model_routing || {}),
|
||||||
|
generation_model: (result.model_routing && (result.model_routing.generation_model || result.model_routing.analysis_model)) || '',
|
||||||
|
orchestrator_model: (result.orchestrator && result.orchestrator.model) || '',
|
||||||
|
worker_model: workerModel.model || '',
|
||||||
|
};
|
||||||
// pack 复用完整结构性归一(id 清洗/路径互斥/引用收敛/总控只读,含 orchestrator.id 对齐;不覆写模型)+ 口径强制
|
// pack 复用完整结构性归一(id 清洗/路径互斥/引用收敛/总控只读,含 orchestrator.id 对齐;不覆写模型)+ 口径强制
|
||||||
reconcileTeam(result);
|
reconcileTeam(result);
|
||||||
enforceConsistency(result);
|
enforceConsistency(result);
|
||||||
@@ -1473,21 +1509,36 @@ function resolveGroup(groupId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 生成员工团队:需求 → 结构化团队
|
// 生成员工团队:需求 → 结构化团队
|
||||||
async function generate({ requirement, tool, groupId, model, agentModel } = {}) {
|
async function generate({ requirement, tool, groupId, orchestratorGroupId, agentGroupId, model, agentModel, orchestratorModel } = {}) {
|
||||||
if (!requirement || !String(requirement).trim()) throw new Error("请先输入项目需求");
|
if (!requirement || !String(requirement).trim()) throw new Error("请先输入项目需求");
|
||||||
const g = resolveGroup(groupId);
|
if (tool !== "claude" && (!String(orchestratorModel || "").trim() || !String(agentModel || "").trim())) {
|
||||||
const useModel = (model && String(model).trim()) || g.model;
|
throw new Error("Codex 团队必须分别选择总控执行模型和子 Agent 执行模型;团队生成模型只用于生成配置");
|
||||||
|
}
|
||||||
|
const analysisGroup = resolveGroup(groupId);
|
||||||
|
const orchestratorGroup = resolveGroup(orchestratorGroupId || groupId);
|
||||||
|
const workerGroup = resolveGroup(agentGroupId || groupId);
|
||||||
|
const useModel = (model && String(model).trim()) || analysisGroup.model;
|
||||||
if (!useModel) throw new Error("未选择模型");
|
if (!useModel) throw new Error("未选择模型");
|
||||||
const gwBase = chatBase(g.baseUrl);
|
const gwBase = chatBase(analysisGroup.baseUrl);
|
||||||
|
|
||||||
const content = await callChat(gwBase, g.key, useModel, [
|
const content = await callChat(gwBase, analysisGroup.key, useModel, [
|
||||||
{ role: "system", content: EMPLOYEE_SYSTEM_PROMPT },
|
{ role: "system", content: EMPLOYEE_SYSTEM_PROMPT },
|
||||||
{ role: "user", content: buildEmployeeInstruction(requirement, tool) },
|
{ role: "user", content: buildEmployeeInstruction(requirement, tool) },
|
||||||
], 5000);
|
], 5000);
|
||||||
|
|
||||||
// 员工运行模型:用户可指定,否则用分析模型
|
// 生成模型只负责本次生成;总控/执行模型是生成后的运行时配置,三者彼此独立。
|
||||||
const runModel = (agentModel && String(agentModel).trim()) || useModel;
|
const runModel = (agentModel && String(agentModel).trim()) || workerGroup.model || useModel;
|
||||||
const result = normalizeTeam(extractJson(content), runModel, requirement);
|
const leadModel = (orchestratorModel && String(orchestratorModel).trim()) || orchestratorGroup.model || useModel;
|
||||||
|
if (!runModel) throw new Error("未选择子 Agent 模型");
|
||||||
|
if (typeof router.registerModelRoute === "function") {
|
||||||
|
router.registerModelRoute(orchestratorGroupId || groupId || orchestratorGroup.id, leadModel);
|
||||||
|
router.registerModelRoute(agentGroupId || groupId || workerGroup.id, runModel);
|
||||||
|
}
|
||||||
|
const result = normalizeTeam(extractJson(content), {
|
||||||
|
analysisModel: useModel,
|
||||||
|
orchestratorModel: leadModel,
|
||||||
|
agentModel: runModel,
|
||||||
|
}, requirement);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1566,6 +1617,12 @@ function packFiles(result, tool) {
|
|||||||
package_manager: result.package_manager || "",
|
package_manager: result.package_manager || "",
|
||||||
auth_scheme: result.auth_scheme || "",
|
auth_scheme: result.auth_scheme || "",
|
||||||
interface_kind: result.interface_kind || "",
|
interface_kind: result.interface_kind || "",
|
||||||
|
model_routing: {
|
||||||
|
generation: (result.model_routing && (result.model_routing.generation_model || result.model_routing.analysis_model)) || "",
|
||||||
|
orchestrator: (result.model_routing && result.model_routing.orchestrator_model) || (result.orchestrator && result.orchestrator.model) || "",
|
||||||
|
workers: Array.from(new Set(result.employees.map((e) => e && e.model).filter(Boolean))),
|
||||||
|
capability_policy: (result.model_routing && result.model_routing.capability_policy) || "",
|
||||||
|
},
|
||||||
required_env: asArr(result.env_vars).map((v) => v && v.name).filter(Boolean),
|
required_env: asArr(result.env_vars).map((v) => v && v.name).filter(Boolean),
|
||||||
roles: result.employees.map((e) => e.id).concat(["orchestrator"]),
|
roles: result.employees.map((e) => e.id).concat(["orchestrator"]),
|
||||||
files: [],
|
files: [],
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
const SKILL_LINK_RE = /\[\$([^\]\n]+)\]\(([^)\n]*)\)/g;
|
||||||
|
const LEADING_SKILL_LINK_RE = /^\s*\[\$([^\]\n]+)\]\(([^)\n]*)\)\s*/;
|
||||||
|
const TRUNCATED_LEADING_SKILL_LINK_RE = /^\s*\[\$([^\]\n]+)\]\([^)\n]*$/;
|
||||||
|
const LEADING_SKILL_TOKEN_RE = /^\s*\$[A-Za-z][\w:.-]*(?:\s+|$)/;
|
||||||
|
|
||||||
|
function normalizeInlineText(text) {
|
||||||
|
return String(text || "")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanSkillLinks(text) {
|
||||||
|
return String(text || "").replace(SKILL_LINK_RE, (_m, name) => `$${String(name || "").trim()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripLeadingSkillMentions(text) {
|
||||||
|
let out = cleanSkillLinks(text);
|
||||||
|
let changed = true;
|
||||||
|
while (changed) {
|
||||||
|
changed = false;
|
||||||
|
const before = out;
|
||||||
|
out = out.replace(LEADING_SKILL_LINK_RE, "");
|
||||||
|
out = out.replace(TRUNCATED_LEADING_SKILL_LINK_RE, "");
|
||||||
|
out = out.replace(LEADING_SKILL_TOKEN_RE, "");
|
||||||
|
out = out.replace(/^\s*[::,,;;||\-–—]+\s*/, "");
|
||||||
|
changed = out !== before;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanUserVisibleText(text) {
|
||||||
|
return cleanSkillLinks(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 某些中转模型会把内部思考误当成普通正文,用 XML 风格标签直接输出。
|
||||||
|
// 只清理明确的内部块,不改动普通文本中的尖括号内容。
|
||||||
|
function stripInternalThoughtBlocks(text) {
|
||||||
|
let out = String(text || "");
|
||||||
|
for (const name of ["thinking", "analysis", "reasoning"]) {
|
||||||
|
const re = new RegExp("<" + name + "\\b[^>]*>[\\s\\S]*?</" + name + "\\s*>", "gi");
|
||||||
|
out = out.replace(re, "");
|
||||||
|
}
|
||||||
|
return out.replace(/<\/?(?:thinking|analysis|reasoning)\b[^>]*>/gi, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 流式内容可能把标签拆在多个 chunk 中:未闭合的内部块整体暂不输出。
|
||||||
|
function stripInternalThoughtStreaming(text) {
|
||||||
|
const source = String(text || "");
|
||||||
|
let out = "";
|
||||||
|
let cursor = 0;
|
||||||
|
const openRe = /<(thinking|analysis|reasoning)\b[^>]*>/gi;
|
||||||
|
while (true) {
|
||||||
|
openRe.lastIndex = cursor;
|
||||||
|
const open = openRe.exec(source);
|
||||||
|
if (!open) {
|
||||||
|
out += source.slice(cursor);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
out += source.slice(cursor, open.index);
|
||||||
|
const closeRe = new RegExp("</" + open[1] + "\\s*>", "i");
|
||||||
|
const tail = source.slice(openRe.lastIndex);
|
||||||
|
const close = closeRe.exec(tail);
|
||||||
|
if (!close) break;
|
||||||
|
cursor = openRe.lastIndex + close.index + close[0].length;
|
||||||
|
}
|
||||||
|
return stripInternalThoughtBlocks(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanThreadTitle(title, fallback = "", maxLen = 80) {
|
||||||
|
const primary = normalizeInlineText(stripLeadingSkillMentions(title));
|
||||||
|
const secondary = normalizeInlineText(stripLeadingSkillMentions(fallback));
|
||||||
|
const cleaned = primary || secondary || normalizeInlineText(cleanSkillLinks(title)) || normalizeInlineText(cleanSkillLinks(fallback));
|
||||||
|
return cleaned ? cleaned.slice(0, maxLen) : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function looksLikeSkillMentionTitle(title) {
|
||||||
|
const text = normalizeInlineText(title);
|
||||||
|
return /^\[\$[^\]]+\]\(/.test(text) || /^\$[A-Za-z][\w:.-]*(?:\s|$)/.test(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
cleanSkillLinks,
|
||||||
|
cleanThreadTitle,
|
||||||
|
cleanUserVisibleText,
|
||||||
|
stripInternalThoughtBlocks,
|
||||||
|
stripInternalThoughtStreaming,
|
||||||
|
looksLikeSkillMentionTitle,
|
||||||
|
};
|
||||||
@@ -15,14 +15,14 @@
|
|||||||
|
|
||||||
const TOKEN_INJECT_SCRIPT = `
|
const TOKEN_INJECT_SCRIPT = `
|
||||||
(() => {
|
(() => {
|
||||||
const VER = "td-token-v17";
|
const VER = "td-token-v18";
|
||||||
if (window.__tdTokenInstalled === VER) return;
|
if (window.__tdTokenInstalled === VER) return;
|
||||||
if (window.__tdTokenObserver) { try { window.__tdTokenObserver.disconnect(); } catch {} }
|
if (window.__tdTokenObserver) { try { window.__tdTokenObserver.disconnect(); } catch {} }
|
||||||
const oldBar = document.getElementById("td-token-bar");
|
const oldBar = document.getElementById("td-token-bar");
|
||||||
if (oldBar) oldBar.remove();
|
if (oldBar) oldBar.remove();
|
||||||
const oldRow = document.getElementById("td-token-center-row");
|
const oldRow = document.getElementById("td-token-center-row");
|
||||||
if (oldRow) oldRow.remove();
|
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) => {
|
["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", "td-token-style-v18"].forEach((id) => {
|
||||||
const el = document.getElementById(id);
|
const el = document.getElementById(id);
|
||||||
if (el) el.remove();
|
if (el) el.remove();
|
||||||
});
|
});
|
||||||
@@ -78,7 +78,7 @@ const TOKEN_INJECT_SCRIPT = `
|
|||||||
// 2. 创建 UI
|
// 2. 创建 UI
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
const BAR_ID = "td-token-bar";
|
const BAR_ID = "td-token-bar";
|
||||||
const STYLE_ID = "td-token-style-v17";
|
const STYLE_ID = "td-token-style-v18";
|
||||||
const TOKEN_CAP = 5000000000;
|
const TOKEN_CAP = 5000000000;
|
||||||
|
|
||||||
if (!document.getElementById(STYLE_ID)) {
|
if (!document.getElementById(STYLE_ID)) {
|
||||||
@@ -537,18 +537,57 @@ const TOKEN_INJECT_SCRIPT = `
|
|||||||
}, 700);
|
}, 700);
|
||||||
}
|
}
|
||||||
|
|
||||||
function findToolbar() {
|
const EDITOR_SELECTOR = "DIV.ProseMirror, [contenteditable='true'], [contenteditable=''], textarea";
|
||||||
const rich = document.querySelector("DIV.ProseMirror, [contenteditable], textarea");
|
const TOOLBAR_SELECTOR = "[class*='_footer_'], [class*='composer-footer'], footer.composer, [class*='footer'][class*='composer']";
|
||||||
if (!rich) return null;
|
|
||||||
let p = rich;
|
function isVisible(el) {
|
||||||
for (let i = 0; i < 7 && p; i++) {
|
if (!el || !el.isConnected) return false;
|
||||||
const foot = p.querySelector("[class*='_footer_'], [class*='composer-footer'], footer.composer, [class*='footer'][class*='composer']");
|
const rect = el.getBoundingClientRect();
|
||||||
if (foot && foot.querySelectorAll("button, [role='button']").length >= 2) return foot;
|
if (!rect.width || !rect.height) return false;
|
||||||
p = p.parentElement;
|
const style = window.getComputedStyle(el);
|
||||||
|
return style.display !== "none" && style.visibility !== "hidden" && Number(style.opacity || 1) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toolbarForEditor(editor) {
|
||||||
|
let parent = editor;
|
||||||
|
for (let i = 0; i < 8 && parent && parent !== document.body && parent !== document.documentElement; i++) {
|
||||||
|
const toolbars = Array.from(parent.querySelectorAll(TOOLBAR_SELECTOR)).filter((toolbar) => (
|
||||||
|
isVisible(toolbar) && toolbar.querySelectorAll("button, [role='button']").length >= 2
|
||||||
|
));
|
||||||
|
if (toolbars.length) {
|
||||||
|
const editorRect = editor.getBoundingClientRect();
|
||||||
|
toolbars.sort((a, b) => {
|
||||||
|
const aRect = a.getBoundingClientRect();
|
||||||
|
const bRect = b.getBoundingClientRect();
|
||||||
|
return Math.abs(aRect.top - editorRect.bottom) - Math.abs(bRect.top - editorRect.bottom);
|
||||||
|
});
|
||||||
|
return toolbars[0];
|
||||||
|
}
|
||||||
|
parent = parent.parentElement;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toolbarScore(editor, toolbar) {
|
||||||
|
const editorRect = editor.getBoundingClientRect();
|
||||||
|
const toolbarRect = toolbar.getBoundingClientRect();
|
||||||
|
const model = findModelControl(toolbar);
|
||||||
|
// 主会话 composer 通常位于可视区更靠下的位置,并且具备模型选择控件。
|
||||||
|
return (model ? 1000000 : 0) + Math.round(toolbarRect.bottom * 1000) + Math.round(editorRect.bottom);
|
||||||
|
}
|
||||||
|
|
||||||
|
function findToolbar() {
|
||||||
|
const candidates = [];
|
||||||
|
for (const editor of document.querySelectorAll(EDITOR_SELECTOR)) {
|
||||||
|
if (!isVisible(editor)) continue;
|
||||||
|
const toolbar = toolbarForEditor(editor);
|
||||||
|
if (toolbar) candidates.push({ editor, toolbar });
|
||||||
|
}
|
||||||
|
if (!candidates.length) return null;
|
||||||
|
candidates.sort((a, b) => toolbarScore(b.editor, b.toolbar) - toolbarScore(a.editor, a.toolbar));
|
||||||
|
return candidates[0].toolbar;
|
||||||
|
}
|
||||||
|
|
||||||
function visibleText(el) {
|
function visibleText(el) {
|
||||||
return String((el && (el.getAttribute("aria-label") || el.title || el.textContent)) || "").trim();
|
return String((el && (el.getAttribute("aria-label") || el.title || el.textContent)) || "").trim();
|
||||||
}
|
}
|
||||||
@@ -561,25 +600,8 @@ const TOKEN_INJECT_SCRIPT = `
|
|||||||
return buttons.length > 1 ? buttons[buttons.length - 2] : buttons[0] || null;
|
return buttons.length > 1 ? buttons[buttons.length - 2] : buttons[0] || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function centerUnderEditor(bar) {
|
function placeInToolbar(bar, toolbar) {
|
||||||
const rich = document.querySelector("DIV.ProseMirror, [contenteditable], textarea");
|
toolbar = toolbar || findToolbar();
|
||||||
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;
|
if (!toolbar) return false;
|
||||||
const model = findModelControl(toolbar);
|
const model = findModelControl(toolbar);
|
||||||
if (model && model.parentElement && toolbar.contains(model.parentElement)) {
|
if (model && model.parentElement && toolbar.contains(model.parentElement)) {
|
||||||
@@ -589,44 +611,53 @@ const TOKEN_INJECT_SCRIPT = `
|
|||||||
} else {
|
} else {
|
||||||
toolbar.appendChild(bar);
|
toolbar.appendChild(bar);
|
||||||
}
|
}
|
||||||
cleanupCenterRow();
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function mountBar() {
|
function mountBar() {
|
||||||
const existing = document.getElementById(BAR_ID);
|
const toolbar = findToolbar();
|
||||||
if (existing) return;
|
if (!toolbar) return false;
|
||||||
const bar = buildBar();
|
const bar = document.getElementById(BAR_ID) || buildBar();
|
||||||
if (!placeInToolbar(bar) && !centerUnderEditor(bar)) {
|
placeInToolbar(bar, toolbar);
|
||||||
(document.body || document.documentElement).appendChild(bar);
|
|
||||||
}
|
|
||||||
render();
|
render();
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function maybeRelocate() {
|
function maybeRelocate() {
|
||||||
const bar = document.getElementById(BAR_ID);
|
const bar = document.getElementById(BAR_ID);
|
||||||
if (!bar) return false;
|
const toolbar = findToolbar();
|
||||||
const row = document.getElementById("td-token-center-row");
|
if (!toolbar) return false;
|
||||||
if (row && row.contains(bar)) return placeInToolbar(bar);
|
if (!bar) return mountBar();
|
||||||
if (bar.parentElement === document.body || bar.parentElement === document.documentElement) return placeInToolbar(bar);
|
if (!toolbar.contains(bar)) return placeInToolbar(bar, toolbar);
|
||||||
return false;
|
const model = findModelControl(toolbar);
|
||||||
|
const expectedParent = model && model.parentElement && toolbar.contains(model.parentElement) ? model.parentElement : toolbar;
|
||||||
|
if (bar.parentElement !== expectedParent || (model && bar.nextElementSibling !== model)) {
|
||||||
|
return placeInToolbar(bar, toolbar);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// 5. MutationObserver:页面重绘后补回悬浮条
|
// 5. MutationObserver:页面重绘后补回悬浮条
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
function tryMount() {
|
function tryMount() {
|
||||||
if (document.getElementById(BAR_ID)) return;
|
maybeRelocate();
|
||||||
mountBar();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let mo = null;
|
let mo = null;
|
||||||
|
let relocateTimer = null;
|
||||||
|
function scheduleRelocate() {
|
||||||
|
if (relocateTimer) return;
|
||||||
|
relocateTimer = setTimeout(() => {
|
||||||
|
relocateTimer = null;
|
||||||
|
tryMount();
|
||||||
|
}, 40);
|
||||||
|
}
|
||||||
function startObserver() {
|
function startObserver() {
|
||||||
if (mo) return;
|
if (mo) return;
|
||||||
installNetworkFallback();
|
installNetworkFallback();
|
||||||
mo = new MutationObserver((mutations) => {
|
mo = new MutationObserver((mutations) => {
|
||||||
maybeRelocate();
|
scheduleRelocate();
|
||||||
if (!document.getElementById(BAR_ID)) tryMount();
|
|
||||||
scheduleDomScan();
|
scheduleDomScan();
|
||||||
});
|
});
|
||||||
window.__tdTokenObserver = mo;
|
window.__tdTokenObserver = mo;
|
||||||
|
|||||||
+20
-11
@@ -9,6 +9,10 @@ const CODEX_HOME = process.env.CODEX_HOME || path.join(os.homedir(), ".codex");
|
|||||||
const SESSIONS_ROOT = path.join(CODEX_HOME, "sessions");
|
const SESSIONS_ROOT = path.join(CODEX_HOME, "sessions");
|
||||||
const RESET_PATH = path.join(CODEX_HOME, ".tokendun-token-reset.json");
|
const RESET_PATH = path.join(CODEX_HOME, ".tokendun-token-reset.json");
|
||||||
const fileCache = new Map();
|
const fileCache = new Map();
|
||||||
|
const fsp = fs.promises;
|
||||||
|
// 正在增长(流式生成中)的会话文件每次变化都会导致全量重读,阻塞主进程事件循环。
|
||||||
|
// 给同一文件加最小重读间隔:5 秒内即使变了也复用上次结果,token 统计允许秒级延迟。
|
||||||
|
const MIN_REREAD_MS = 5000;
|
||||||
|
|
||||||
function clean(n) {
|
function clean(n) {
|
||||||
n = Number(n);
|
n = Number(n);
|
||||||
@@ -61,19 +65,24 @@ function snapshotAt(line) {
|
|||||||
return usage.total > 0 ? { timestamp, usage } : null;
|
return usage.total > 0 ? { timestamp, usage } : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseSessionFile(file) {
|
async function parseSessionFile(file) {
|
||||||
try {
|
try {
|
||||||
const stat = fs.statSync(file);
|
const stat = await fsp.stat(file);
|
||||||
const cached = fileCache.get(file);
|
const cached = fileCache.get(file);
|
||||||
if (cached && cached.size === stat.size && cached.mtimeMs === stat.mtimeMs) return cached.value;
|
if (cached) {
|
||||||
|
// 未变化 → 直接用缓存
|
||||||
|
if (cached.size === stat.size && cached.mtimeMs === stat.mtimeMs) return cached.value;
|
||||||
|
// 变化了但距上次读太近 → 复用缓存,避免流式生成期间反复全量读盘阻塞主进程
|
||||||
|
if (Date.now() - cached.readAt < MIN_REREAD_MS) return cached.value;
|
||||||
|
}
|
||||||
|
const text = await fsp.readFile(file, "utf8");
|
||||||
const snapshots = [];
|
const snapshots = [];
|
||||||
const text = fs.readFileSync(file, "utf8");
|
|
||||||
for (const line of text.split("\n")) {
|
for (const line of text.split("\n")) {
|
||||||
const snapshot = snapshotAt(line);
|
const snapshot = snapshotAt(line);
|
||||||
if (snapshot) snapshots.push(snapshot);
|
if (snapshot) snapshots.push(snapshot);
|
||||||
}
|
}
|
||||||
const value = { snapshots };
|
const value = { snapshots };
|
||||||
fileCache.set(file, { size: stat.size, mtimeMs: stat.mtimeMs, value });
|
fileCache.set(file, { size: stat.size, mtimeMs: stat.mtimeMs, value, readAt: Date.now() });
|
||||||
return value;
|
return value;
|
||||||
} catch {
|
} catch {
|
||||||
return { snapshots: [] };
|
return { snapshots: [] };
|
||||||
@@ -132,7 +141,7 @@ function applyReset(value) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function readDailyRaw(now = new Date()) {
|
async function readDailyRaw(now = new Date()) {
|
||||||
const date = dayKey(now);
|
const date = dayKey(now);
|
||||||
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||||
const end = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);
|
const end = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);
|
||||||
@@ -140,7 +149,7 @@ function readDailyRaw(now = new Date()) {
|
|||||||
result.available = fs.existsSync(SESSIONS_ROOT);
|
result.available = fs.existsSync(SESSIONS_ROOT);
|
||||||
|
|
||||||
for (const file of sessionFilesForDay(start, end)) {
|
for (const file of sessionFilesForDay(start, end)) {
|
||||||
const snapshots = parseSessionFile(file).snapshots;
|
const snapshots = (await parseSessionFile(file)).snapshots;
|
||||||
let before = null;
|
let before = null;
|
||||||
let current = null;
|
let current = null;
|
||||||
for (const snapshot of snapshots) {
|
for (const snapshot of snapshots) {
|
||||||
@@ -159,12 +168,12 @@ function readDailyRaw(now = new Date()) {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
function readDaily(now = new Date()) {
|
async function readDaily(now = new Date()) {
|
||||||
return applyReset(readDailyRaw(now));
|
return applyReset(await readDailyRaw(now));
|
||||||
}
|
}
|
||||||
|
|
||||||
function reset(now = new Date()) {
|
async function reset(now = new Date()) {
|
||||||
const value = readDailyRaw(now);
|
const value = await readDailyRaw(now);
|
||||||
fs.mkdirSync(path.dirname(RESET_PATH), { recursive: true });
|
fs.mkdirSync(path.dirname(RESET_PATH), { recursive: true });
|
||||||
fs.writeFileSync(RESET_PATH, JSON.stringify({ ...value, date: dayKey(now) }), "utf8");
|
fs.writeFileSync(RESET_PATH, JSON.stringify({ ...value, date: dayKey(now) }), "utf8");
|
||||||
return blank(dayKey(now));
|
return blank(dayKey(now));
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ async function tokenStatus() {
|
|||||||
try {
|
try {
|
||||||
const snap = voice.status();
|
const snap = voice.status();
|
||||||
const page = await voice.tokenStatus();
|
const page = await voice.tokenStatus();
|
||||||
const local = tokenUsage.readDaily();
|
const local = await tokenUsage.readDaily();
|
||||||
if (local && local.available) {
|
if (local && local.available) {
|
||||||
await voice.tokenSet(local);
|
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", ...local, network: page.network };
|
||||||
@@ -31,7 +31,7 @@ async function tokenStatus() {
|
|||||||
|
|
||||||
async function tokenReset() {
|
async function tokenReset() {
|
||||||
try {
|
try {
|
||||||
const local = tokenUsage.reset();
|
const local = await tokenUsage.reset();
|
||||||
const ok = await voice.tokenSet(local);
|
const ok = await voice.tokenSet(local);
|
||||||
await voice.tokenReset();
|
await voice.tokenReset();
|
||||||
return { ok: ok || true };
|
return { ok: ok || true };
|
||||||
|
|||||||
+123
-27
@@ -2,6 +2,7 @@
|
|||||||
// 让只支持 chat/completions 的中转(DeepSeek/GLM/Kimi/通义等)也能给 Codex(用 responses)用。
|
// 让只支持 chat/completions 的中转(DeepSeek/GLM/Kimi/通义等)也能给 Codex(用 responses)用。
|
||||||
// 参考实现:sybil-solutions/codex-shim、BerriAI/litellm。
|
// 参考实现:sybil-solutions/codex-shim、BerriAI/litellm。
|
||||||
const crypto = require("crypto");
|
const crypto = require("crypto");
|
||||||
|
const { stripInternalThoughtStreaming } = require("./title_clean");
|
||||||
|
|
||||||
function genId(prefix) {
|
function genId(prefix) {
|
||||||
return prefix + Date.now().toString(36) + crypto.randomBytes(4).toString("hex");
|
return prefix + Date.now().toString(36) + crypto.randomBytes(4).toString("hex");
|
||||||
@@ -22,17 +23,69 @@ function partsToText(content) {
|
|||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function compactText(value, limit = 12000) {
|
||||||
|
const text = String(value || "").trim();
|
||||||
|
return text.length > limit ? `${text.slice(0, limit)}\n[内容已截断]` : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mediaPlaceholder(value) {
|
||||||
|
if (!value || typeof value !== "object") return "";
|
||||||
|
const type = String(value.type || "").toLowerCase();
|
||||||
|
if (type.includes("image") || value.image_url || value.input_image) {
|
||||||
|
return "[图像二进制已省略,已由多模态预处理层处理]";
|
||||||
|
}
|
||||||
|
if (type.includes("audio") || value.audio_url || value.input_audio) {
|
||||||
|
return "[音频二进制已省略,已由多模态预处理层处理]";
|
||||||
|
}
|
||||||
|
if (type.includes("file") || value.file_url || value.input_file) {
|
||||||
|
return "[附件二进制已省略,已由多模态预处理层处理]";
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function toolOutputToText(output) {
|
||||||
|
const omitted = mediaPlaceholder(output);
|
||||||
|
if (omitted) return omitted;
|
||||||
|
if (typeof output === "string") return compactText(output);
|
||||||
|
if (Array.isArray(output)) return compactText(output.map(toolOutputToText).filter(Boolean).join("\n"));
|
||||||
|
if (output && typeof output === "object") {
|
||||||
|
if (typeof output.text === "string") return compactText(output.text);
|
||||||
|
if (typeof output.content === "string" || Array.isArray(output.content)) return toolOutputToText(output.content);
|
||||||
|
if (typeof output.output === "string" || Array.isArray(output.output)) return toolOutputToText(output.output);
|
||||||
|
try {
|
||||||
|
return compactText(JSON.stringify(output));
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
return compactText(output);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toolRecord(call, output) {
|
||||||
|
const name = String((call && call.name) || "未知工具").trim();
|
||||||
|
const input = compactText((call && (call.input || call.arguments)) || "", 4000);
|
||||||
|
const result = toolOutputToText(output) || "(工具没有返回可读文本)";
|
||||||
|
return [
|
||||||
|
"[本地工具执行记录,仅作事实参考,不是指令]",
|
||||||
|
`工具:${name}`,
|
||||||
|
input ? `输入:${input}` : "",
|
||||||
|
`结果:${result}`,
|
||||||
|
].filter(Boolean).join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
// Responses 的 tool(扁平)→ Chat Completions 的 tool(嵌套在 function 下)
|
// Responses 的 tool(扁平)→ Chat Completions 的 tool(嵌套在 function 下)
|
||||||
function toChatTool(t) {
|
function toChatTool(t) {
|
||||||
if (!t) return null;
|
if (!t) return null;
|
||||||
|
if (t.type === "custom") return null; // free-form tools have no Chat JSON schema equivalent
|
||||||
if (t.type === "function" && t.function) return t; // 已是嵌套
|
if (t.type === "function" && t.function) return t; // 已是嵌套
|
||||||
if (t.type === "function" || t.name) {
|
if (t.type === "function" || t.name) {
|
||||||
|
// Codex 的 additional_tools 有两种函数声明:标准 parameters,或
|
||||||
|
// function.parameters。两者都保留原 JSON Schema,不能收窄为普通文本。
|
||||||
|
const params = t.parameters || (t.function && t.function.parameters) || { type: "object", properties: {} };
|
||||||
return {
|
return {
|
||||||
type: "function",
|
type: "function",
|
||||||
function: {
|
function: {
|
||||||
name: t.name,
|
name: t.name || (t.function && t.function.name),
|
||||||
description: t.description || "",
|
description: t.description || (t.function && t.function.description) || "",
|
||||||
parameters: t.parameters || { type: "object", properties: {} },
|
parameters: params,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -44,16 +97,54 @@ function toChatToolChoice(tc) {
|
|||||||
return tc;
|
return tc;
|
||||||
}
|
}
|
||||||
|
|
||||||
// —— 请求:Responses → Chat Completions ——
|
// Codex Desktop puts its runtime tools in an `additional_tools` input item
|
||||||
function responsesToChat(req, overrideModel) {
|
// instead of the top-level `tools` field. Chat-only providers need the same
|
||||||
const messages = [];
|
// definitions at the Chat Completions top level; otherwise they can only emit
|
||||||
if (req.instructions) messages.push({ role: "system", content: req.instructions });
|
// prose, which makes a coding request stop after a short acknowledgement.
|
||||||
const pending = [];
|
function requestTools(req) {
|
||||||
const flush = () => {
|
const out = [];
|
||||||
if (pending.length) {
|
const seen = new Set();
|
||||||
messages.push({ role: "assistant", content: null, tool_calls: pending.splice(0) });
|
const add = (tool, allowImplicit = true) => {
|
||||||
}
|
// `additional_tools` can also carry free-form/custom tools. They cannot
|
||||||
|
// be represented by Chat Completions' JSON-schema function protocol, so
|
||||||
|
// leave them as historical context instead of advertising a broken tool.
|
||||||
|
if (!allowImplicit && (!tool || (tool.type !== "function" && !tool.function))) return;
|
||||||
|
const mapped = toChatTool(tool);
|
||||||
|
const name = String(mapped && mapped.function && mapped.function.name || "").trim();
|
||||||
|
if (!name || seen.has(name)) return;
|
||||||
|
seen.add(name);
|
||||||
|
out.push(mapped);
|
||||||
};
|
};
|
||||||
|
if (Array.isArray(req.tools)) req.tools.forEach((tool) => add(tool));
|
||||||
|
if (Array.isArray(req.input)) {
|
||||||
|
for (const item of req.input) {
|
||||||
|
if (item && item.type === "additional_tools" && Array.isArray(item.tools)) {
|
||||||
|
item.tools.forEach((tool) => add(tool, false));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// —— 请求:Responses → Chat Completions ——
|
||||||
|
function responsesToChat(req, overrideModel, options = {}) {
|
||||||
|
// Codex 的 custom tool 不能由 OpenAI Chat 协议安全复现。将历史动作投影为
|
||||||
|
// 只读结果,避免模型在没有工具契约时输出 DSML 等伪调用标记。
|
||||||
|
const mappedTools = requestTools(req)
|
||||||
|
.filter((tool) => {
|
||||||
|
const name = String(tool && tool.function && tool.function.name || "").toLowerCase();
|
||||||
|
return !(options.omitVisualTools && name === "view_image");
|
||||||
|
});
|
||||||
|
const tools = options.omitTools ? [] : mappedTools;
|
||||||
|
const messages = [];
|
||||||
|
if (!tools.length) {
|
||||||
|
messages.push({
|
||||||
|
role: "system",
|
||||||
|
content: "兼容模式:当前模型只能读取文本上下文,不能调用本地工具。工具执行记录仅代表已完成的事实;不得请求、模拟或输出任何工具调用标记。请直接根据用户问题、附件识别证据和已完成记录回答。",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (req.instructions) messages.push({ role: "system", content: req.instructions });
|
||||||
|
const calls = new Map();
|
||||||
const input = req.input;
|
const input = req.input;
|
||||||
if (typeof input === "string") {
|
if (typeof input === "string") {
|
||||||
messages.push({ role: "user", content: input });
|
messages.push({ role: "user", content: input });
|
||||||
@@ -61,28 +152,27 @@ function responsesToChat(req, overrideModel) {
|
|||||||
for (const item of input) {
|
for (const item of input) {
|
||||||
const t = item.type;
|
const t = item.type;
|
||||||
if (t === "message" || (item.role && !t)) {
|
if (t === "message" || (item.role && !t)) {
|
||||||
flush();
|
|
||||||
const role = item.role === "developer" ? "system" : item.role;
|
const role = item.role === "developer" ? "system" : item.role;
|
||||||
|
if (role === "tool" || role === "function") {
|
||||||
|
// Codex 有时把 view_image 的原图作为 role=tool 消息塞入历史;不能转发 Base64。
|
||||||
|
const result = toolOutputToText(item.content);
|
||||||
|
if (result) messages.push({ role: "user", content: `[已完成的本地工具结果]\n${result}` });
|
||||||
|
} else {
|
||||||
messages.push({ role, content: partsToText(item.content) });
|
messages.push({ role, content: partsToText(item.content) });
|
||||||
} else if (t === "function_call") {
|
}
|
||||||
const callId = item.call_id || item.id || "call_0";
|
} else if (t === "function_call" || t === "custom_tool_call") {
|
||||||
pending.push({ id: callId, type: "function", function: { name: item.name || "", arguments: item.arguments || "" } });
|
calls.set(item.call_id || item.id || `call_${calls.size}`, item);
|
||||||
} else if (t === "function_call_output") {
|
} else if (t === "function_call_output" || t === "custom_tool_call_output") {
|
||||||
flush();
|
const call = calls.get(item.call_id) || null;
|
||||||
const out = typeof item.output === "string" ? item.output : JSON.stringify(item.output);
|
messages.push({ role: "user", content: toolRecord(call, item.output) });
|
||||||
messages.push({ role: "tool", tool_call_id: item.call_id, content: out });
|
|
||||||
}
|
}
|
||||||
// reasoning item → 丢弃(chat 上游用不到)
|
// reasoning item → 丢弃(chat 上游用不到)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
flush();
|
|
||||||
|
|
||||||
const out = { model: overrideModel || req.model, messages, stream: req.stream !== false };
|
const out = { model: overrideModel || req.model, messages, stream: req.stream !== false };
|
||||||
if (Array.isArray(req.tools)) {
|
|
||||||
const tools = req.tools.map(toChatTool).filter(Boolean);
|
|
||||||
if (tools.length) out.tools = tools;
|
if (tools.length) out.tools = tools;
|
||||||
}
|
if (tools.length && req.tool_choice) out.tool_choice = toChatToolChoice(req.tool_choice);
|
||||||
if (req.tool_choice) out.tool_choice = toChatToolChoice(req.tool_choice);
|
|
||||||
if (req.max_output_tokens) out.max_tokens = req.max_output_tokens;
|
if (req.max_output_tokens) out.max_tokens = req.max_output_tokens;
|
||||||
if (req.temperature != null) out.temperature = req.temperature;
|
if (req.temperature != null) out.temperature = req.temperature;
|
||||||
if (req.top_p != null) out.top_p = req.top_p;
|
if (req.top_p != null) out.top_p = req.top_p;
|
||||||
@@ -114,6 +204,7 @@ class ChatToResponses {
|
|||||||
this.outIndex = 0;
|
this.outIndex = 0;
|
||||||
this.msgOpen = false;
|
this.msgOpen = false;
|
||||||
this.msgClosed = false;
|
this.msgClosed = false;
|
||||||
|
this.msgRawText = "";
|
||||||
this.msgText = "";
|
this.msgText = "";
|
||||||
this.msgOutputIndex = null;
|
this.msgOutputIndex = null;
|
||||||
this.tools = {}; // index → {id,name,arguments,outputIndex,closed}
|
this.tools = {}; // index → {id,name,arguments,outputIndex,closed}
|
||||||
@@ -307,17 +398,22 @@ class ChatToResponses {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (delta.content) {
|
if (delta.content) {
|
||||||
|
this.msgRawText += String(delta.content);
|
||||||
|
const visible = stripInternalThoughtStreaming(this.msgRawText);
|
||||||
|
const visibleDelta = visible.slice(this.msgText.length);
|
||||||
|
if (visibleDelta) {
|
||||||
out += this.closeReason(); // 有正文了,先收尾推理
|
out += this.closeReason(); // 有正文了,先收尾推理
|
||||||
out += this.openMsg();
|
out += this.openMsg();
|
||||||
this.msgText += delta.content;
|
this.msgText = visible;
|
||||||
out += this.ev("response.output_text.delta", {
|
out += this.ev("response.output_text.delta", {
|
||||||
item_id: this.msgId,
|
item_id: this.msgId,
|
||||||
output_index: this.msgOutputIndex,
|
output_index: this.msgOutputIndex,
|
||||||
content_index: 0,
|
content_index: 0,
|
||||||
delta: delta.content,
|
delta: visibleDelta,
|
||||||
logprobs: [],
|
logprobs: [],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if (Array.isArray(delta.tool_calls)) {
|
if (Array.isArray(delta.tool_calls)) {
|
||||||
for (const tc of delta.tool_calls) out += this.onToolDelta(tc);
|
for (const tc of delta.tool_calls) out += this.onToolDelta(tc);
|
||||||
}
|
}
|
||||||
|
|||||||
+422
-85
@@ -1,20 +1,21 @@
|
|||||||
// voice_cdp.js —— Codex 语音输入注入:主进程侧。
|
// voice_cdp.js —— ChatGPT/Codex 语音输入注入:主进程侧。
|
||||||
// 用 CDP(Chrome DevTools Protocol)连接带 --remote-debugging-port 的 Codex 桌面版,
|
// 用 CDP(Chrome DevTools Protocol)连接带 --remote-debugging-port 的 ChatGPT/Codex 桌面版,
|
||||||
// 往其页面注入麦克风按钮脚本,录音经 binding 回传 → 调中转站 Whisper 转写 → 文字回填 composer。
|
// 往其页面注入麦克风按钮脚本,录音经 binding 回传 → 调中转站 Whisper 转写 → 文字回填 composer。
|
||||||
// 不改 Codex 源码、不写补丁文件。无第三方依赖:CDP over WebSocket 用 Node 内置 net 手写最小 WS 客户端
|
// 不改 Codex 源码、不写补丁文件。无第三方依赖:CDP over WebSocket 用 Node 内置 net 手写最小 WS 客户端
|
||||||
// (Electron 34 主进程的 Node 无全局 WebSocket);HTTP 用全局 fetch;multipart 用全局 FormData/Blob。
|
// (Electron 34 主进程的 Node 无全局 WebSocket);HTTP 用全局 fetch;multipart 用全局 FormData/Blob。
|
||||||
//
|
//
|
||||||
// 机制参考 CodexPlusPlus 的 bridge.rs:Runtime.evaluate / Page.addScriptToEvaluateOnNewDocument /
|
// 机制参考 CodexPlusPlus 的 bridge.rs:Runtime.evaluate / Page.addScriptToEvaluateOnNewDocument /
|
||||||
// Runtime.addBinding + Runtime.bindingCalled。
|
// Runtime.addBinding + Runtime.bindingCalled。
|
||||||
const { spawn, execFile } = require("child_process");
|
const { execFile, spawn } = require("child_process");
|
||||||
const os = require("os");
|
const fs = require("fs");
|
||||||
const path = require("path");
|
|
||||||
const net = require("net");
|
const net = require("net");
|
||||||
const crypto = require("crypto");
|
const crypto = require("crypto");
|
||||||
|
const os = require("os");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
function dlog(...a) { try { console.log("[voice]", ...a); } catch {} }
|
function dlog(...a) { try { console.log("[voice]", ...a); } catch {} }
|
||||||
const router = require("./router");
|
const router = require("./router");
|
||||||
const providerSync = require("./provider_sync");
|
const codexDesktop = require("./codex_desktop");
|
||||||
const { INJECT_SCRIPT } = require("./voice_inject");
|
const { INJECT_SCRIPT } = require("./voice_inject");
|
||||||
const { TOKEN_INJECT_SCRIPT } = require("./token_inject");
|
const { TOKEN_INJECT_SCRIPT } = require("./token_inject");
|
||||||
const tokenUsage = require("./token_usage");
|
const tokenUsage = require("./token_usage");
|
||||||
@@ -25,28 +26,39 @@ const CDP_HTTP_TIMEOUT = 3000; // GET /json 超时
|
|||||||
const CDP_WS_TIMEOUT = 5000; // WebSocket 握手超时
|
const CDP_WS_TIMEOUT = 5000; // WebSocket 握手超时
|
||||||
const CDP_CMD_TIMEOUT = 5000; // 单条 CDP 命令超时
|
const CDP_CMD_TIMEOUT = 5000; // 单条 CDP 命令超时
|
||||||
const READY_POLL_INTERVAL = 500; // 轮询就绪间隔
|
const READY_POLL_INTERVAL = 500; // 轮询就绪间隔
|
||||||
const READY_POLL_MAX_MS = 20000; // 轮询就绪最长等待
|
const READY_POLL_MAX_MS = 180000; // 轮询就绪最长等待:Windows 冷启动可能要 2~3 分钟
|
||||||
|
const PACKAGED_READY_POLL_MAX_MS = 180000; // Store/MSIX 版 AppID 启动后也要给足白屏加载时间
|
||||||
|
const WINDOWS_LAUNCH_TIMEOUT_MS = 4500;
|
||||||
|
const WINDOWS_APPID_ACTIVATE_TIMEOUT_MS = 10000;
|
||||||
const RECORD_MAX_MS = 60000; // 单次录音上限(注入脚本侧也限)
|
const RECORD_MAX_MS = 60000; // 单次录音上限(注入脚本侧也限)
|
||||||
|
|
||||||
// —— 状态机 ——
|
// —— 状态机 ——
|
||||||
// unknown / checking / not_running / running_no_port / restarting / connecting /
|
// unknown / checking / not_running / running_no_port / restarting / waiting_debug_port /
|
||||||
// injecting / connected / disconnected / error
|
// unsupported_packaged / connecting / injecting / connected / disconnected / error
|
||||||
let state = {
|
let state = {
|
||||||
phase: "unknown",
|
phase: "unknown",
|
||||||
port: DEFAULT_PORT,
|
port: DEFAULT_PORT,
|
||||||
codexPath: "",
|
codexPath: "",
|
||||||
|
codexPackaged: false,
|
||||||
|
codexLaunchType: "exe",
|
||||||
|
codexAppId: "",
|
||||||
|
launchWarning: "",
|
||||||
|
portDiagnostic: "",
|
||||||
lastError: "",
|
lastError: "",
|
||||||
|
discovery: null,
|
||||||
target: null, // { id, url, title }
|
target: null, // { id, url, title }
|
||||||
};
|
};
|
||||||
let session = null; // 当前 CdpSession
|
let session = null; // 当前 CdpSession
|
||||||
|
let voiceScriptIdentifier = ""; // 用于断开时撤销后续页面的语音自动注入
|
||||||
let networkSessions = []; // worker 等附加 target 的 Network 监听 session
|
let networkSessions = []; // worker 等附加 target 的 Network 监听 session
|
||||||
let networkAttachGeneration = 0;
|
let networkAttachGeneration = 0;
|
||||||
let onStatusCb = null;
|
let onStatusCb = null;
|
||||||
|
let onSessionDeleteCb = null;
|
||||||
let tokenSyncTimer = null;
|
let tokenSyncTimer = null;
|
||||||
|
|
||||||
function snapshot() {
|
function snapshot() {
|
||||||
const { phase, port, codexPath, lastError, model, target } = state;
|
const { phase, port, codexPath, codexPackaged, codexLaunchType, codexAppId, launchWarning, portDiagnostic, lastError, model, target, discovery } = state;
|
||||||
return { phase, port, codexPath, lastError, model, target };
|
return { phase, port, codexPath, codexPackaged, codexLaunchType, codexAppId, launchWarning, portDiagnostic, lastError, model, target, discovery };
|
||||||
}
|
}
|
||||||
function setPhase(phase, extra = {}) {
|
function setPhase(phase, extra = {}) {
|
||||||
state = { ...state, phase, ...extra };
|
state = { ...state, phase, ...extra };
|
||||||
@@ -59,7 +71,7 @@ function setPhase(phase, extra = {}) {
|
|||||||
|
|
||||||
async function syncTokenUsage() {
|
async function syncTokenUsage() {
|
||||||
if (!session) return;
|
if (!session) return;
|
||||||
const usage = tokenUsage.readDaily();
|
const usage = await tokenUsage.readDaily();
|
||||||
if (!usage || !usage.available) return;
|
if (!usage || !usage.available) return;
|
||||||
try {
|
try {
|
||||||
await session.send("Runtime.evaluate", {
|
await session.send("Runtime.evaluate", {
|
||||||
@@ -81,36 +93,290 @@ function stopTokenSync() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// —— 探测 Codex 可执行路径 ——
|
// —— 探测 Codex 可执行路径 ——
|
||||||
function codexExecutable() {
|
function scanCodexExecutable() {
|
||||||
const home = os.homedir();
|
const candidates = codexDesktop.desktopExecutableCandidates();
|
||||||
if (process.platform === "darwin") {
|
const candidate = candidates.find((item) => item && item.path && !item.packaged) ||
|
||||||
const cands = [
|
candidates.find((item) => item && item.path && item.packaged);
|
||||||
path.join(home, "Applications/ChatGPT.app/Contents/MacOS/ChatGPT"),
|
const exe = candidate && candidate.path ? candidate.path : "";
|
||||||
"/Applications/ChatGPT.app/Contents/MacOS/ChatGPT",
|
state.codexPath = exe;
|
||||||
"/Applications/Codex.app/Contents/MacOS/Codex",
|
state.codexPackaged = !!(candidate && candidate.packaged);
|
||||||
];
|
state.codexLaunchType = (candidate && candidate.launchType) || "exe";
|
||||||
for (const p of cands) {
|
state.codexAppId = (candidate && candidate.appId) || "";
|
||||||
try {
|
state.discovery = codexDesktop.discoveryReport ? codexDesktop.discoveryReport() : null;
|
||||||
if (require("fs").existsSync(p)) return p;
|
return exe;
|
||||||
} catch {}
|
}
|
||||||
}
|
|
||||||
return "";
|
function desktopNotFoundError() {
|
||||||
}
|
const summary = codexDesktop.discoverySummary ? codexDesktop.discoverySummary() : "";
|
||||||
|
const discovery = state.discovery || {};
|
||||||
|
const hasPackaged = Array.isArray(discovery.candidates) && discovery.candidates.some((item) => item && item.packaged);
|
||||||
if (process.platform === "win32") {
|
if (process.platform === "win32") {
|
||||||
const local = process.env.LOCALAPPDATA || "";
|
return "未找到可直接启动的 ChatGPT/Codex 桌面程序。" +
|
||||||
const pf = process.env["ProgramFiles"] || "C:\\Program Files";
|
(summary ? "扫描结果:" + summary + "。" : "") +
|
||||||
const cands = [
|
(hasPackaged ? "检测到 WindowsApps/Store 受保护入口,但它不能直接附加调试端口。" : "") +
|
||||||
local && path.join(local, "Programs\\ChatGPT\\ChatGPT.exe"),
|
"请安装官网下载的桌面版后重试。";
|
||||||
path.join(pf, "ChatGPT\\ChatGPT.exe"),
|
}
|
||||||
].filter(Boolean);
|
return "未找到 ChatGPT/Codex 桌面程序。" + (summary ? "扫描结果:" + summary + "。" : "");
|
||||||
for (const p of cands) {
|
}
|
||||||
|
|
||||||
|
function desktopLaunchError(exePath, err) {
|
||||||
|
const msg = String((err && err.message) || err || "");
|
||||||
|
if (process.platform === "win32" && /eperm/i.test(msg)) {
|
||||||
|
return "Windows 拒绝启动 ChatGPT/Codex 调试模式(EPERM)。路径:" + exePath +
|
||||||
|
"。如果这是 Microsoft Store/WindowsApps 版本,请改装官网下载的桌面版;如果是普通 exe,请用管理员权限启动工具箱后再试。";
|
||||||
|
}
|
||||||
|
return "启动 ChatGPT/Codex 失败:" + msg + (exePath ? "。路径:" + exePath : "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function compactError(err, fallback = "未知错误") {
|
||||||
|
const parts = [
|
||||||
|
err && err.stderr,
|
||||||
|
err && err.stdout,
|
||||||
|
err && err.message,
|
||||||
|
err,
|
||||||
|
].filter(Boolean).map((v) => String(v));
|
||||||
|
const raw = parts.find((v) => v.trim()) || fallback;
|
||||||
|
return raw
|
||||||
|
.replace(/Command failed:[\s\S]*?(?=(\r?\n|$))/i, "")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim()
|
||||||
|
.slice(0, 220) || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function compactCommandLine(cmd) {
|
||||||
|
const text = String(cmd || "").replace(/\s+/g, " ").trim();
|
||||||
|
if (!text) return "";
|
||||||
|
const hasWindowsApps = /WindowsApps/i.test(text);
|
||||||
|
const hasDebugArg = /--remote-debugging-port/i.test(text);
|
||||||
|
const exe = (text.match(/(?:^|[ "])([A-Z]:\\[^"]*?(?:ChatGPT|Codex)[^"\\]*\.exe)/i) || [])[1] || "";
|
||||||
|
const pidPart = exe ? exe.replace(/^.*[\\/]/, "") : "";
|
||||||
|
const tags = [];
|
||||||
|
if (hasWindowsApps) tags.push("WindowsApps");
|
||||||
|
tags.push(hasDebugArg ? "含调试参数" : "无调试参数");
|
||||||
|
return [pidPart, tags.join("/")].filter(Boolean).join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateWindowsProcessDiagnostic(port) {
|
||||||
|
if (process.platform !== "win32" || !codexDesktop.windowsProcessDiagnostics) return null;
|
||||||
try {
|
try {
|
||||||
if (require("fs").existsSync(p)) return p;
|
const d = codexDesktop.windowsProcessDiagnostics(port);
|
||||||
|
if (!d || !Array.isArray(d.rows) || !d.rows.length) {
|
||||||
|
state.portDiagnostic = "未检测到 ChatGPT/Codex 进程";
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
state.portDiagnostic = d.hasDebugArg
|
||||||
|
? "进程命令行已包含调试端口参数"
|
||||||
|
: "进程命令行未包含 --remote-debugging-port=" + port + ",调试端口不会打开";
|
||||||
|
const rowsSummary = d.rows.map((row) => {
|
||||||
|
const name = (row && row.Name) || "process";
|
||||||
|
const pid = (row && row.ProcessId) || "?";
|
||||||
|
const cmd = compactCommandLine((row && row.CommandLine) || "");
|
||||||
|
return `${name}#${pid}${cmd ? " " + cmd : ""}`;
|
||||||
|
}).join(" | ");
|
||||||
|
if (rowsSummary) state.portDiagnostic += ";" + rowsSummary;
|
||||||
|
state.portDiagnostic = state.portDiagnostic.slice(0, 260);
|
||||||
|
return d;
|
||||||
|
} catch (e) {
|
||||||
|
state.portDiagnostic = "读取进程命令行失败:" + compactError(e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function debugPortFailureMessage(action, port) {
|
||||||
|
const diag = updateWindowsProcessDiagnostic(port);
|
||||||
|
if (state.codexPackaged) {
|
||||||
|
const reason = diag && diag.rows && diag.rows.length && !diag.hasDebugArg
|
||||||
|
? "当前 Store/MSIX 启动参数没有进入 Codex 进程,无法打开调试端口。"
|
||||||
|
: "当前 Store/MSIX 启动方式未开放调试端口。";
|
||||||
|
return "已尝试通过 Windows AppID " + action + " ChatGPT/Codex,但未检测到调试端口。" +
|
||||||
|
reason + "建议改装官网下载桌面版。";
|
||||||
|
}
|
||||||
|
return action === "重启" ? "重启 ChatGPT 后未开放调试端口" : "Codex 启动后未开放调试端口,请重试";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPackagedDebugArgMissing(port) {
|
||||||
|
if (!state.codexPackaged || process.platform !== "win32") return false;
|
||||||
|
const d = updateWindowsProcessDiagnostic(port);
|
||||||
|
return !!(d && Array.isArray(d.rows) && d.rows.length && !d.hasDebugArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setDebugPortFailure(action, port) {
|
||||||
|
const unsupported = isPackagedDebugArgMissing(port);
|
||||||
|
setPhase(unsupported ? "unsupported_packaged" : "error", {
|
||||||
|
lastError: debugPortFailureMessage(action, port) + (state.launchWarning ? "(" + state.launchWarning + ")" : ""),
|
||||||
|
portDiagnostic: state.portDiagnostic,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function windowsLaunchArgs(port) {
|
||||||
|
return [
|
||||||
|
`--remote-debugging-port=${port}`,
|
||||||
|
`--remote-allow-origins=http://127.0.0.1:${port}`,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function quoteWindowsArgument(arg) {
|
||||||
|
const text = String(arg || "");
|
||||||
|
if (text && !/[ \t"]/.test(text)) return text;
|
||||||
|
let out = "\"";
|
||||||
|
let backslashes = 0;
|
||||||
|
for (const ch of text) {
|
||||||
|
if (ch === "\\") {
|
||||||
|
backslashes += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === "\"") {
|
||||||
|
out += "\\".repeat(backslashes * 2 + 1) + "\"";
|
||||||
|
backslashes = 0;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out += "\\".repeat(backslashes) + ch;
|
||||||
|
backslashes = 0;
|
||||||
|
}
|
||||||
|
out += "\\".repeat(backslashes * 2) + "\"";
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function windowsCommandLineArguments(args) {
|
||||||
|
return args.map(quoteWindowsArgument).join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function windowsActivateAppId(appId, port) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (process.platform !== "win32") {
|
||||||
|
reject(new Error("AppID activation only supports Windows"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const script = [
|
||||||
|
"$ErrorActionPreference='Stop'",
|
||||||
|
"[Console]::OutputEncoding=[System.Text.Encoding]::UTF8",
|
||||||
|
"$OutputEncoding=[System.Text.Encoding]::UTF8",
|
||||||
|
"$code = @'",
|
||||||
|
"using System;",
|
||||||
|
"using System.Runtime.InteropServices;",
|
||||||
|
"namespace TdCodexAppActivation {",
|
||||||
|
"[ComImport, Guid(\"45BA127D-10A8-46EA-8AB7-56EA9078943C\")]",
|
||||||
|
"public class ApplicationActivationManager {}",
|
||||||
|
"[ComImport, Guid(\"2e941141-7f97-4756-ba1d-9decde894a3d\"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]",
|
||||||
|
"public interface IApplicationActivationManager {",
|
||||||
|
" [PreserveSig]",
|
||||||
|
" int ActivateApplication([MarshalAs(UnmanagedType.LPWStr)] string appUserModelId, [MarshalAs(UnmanagedType.LPWStr)] string arguments, int options, out uint processId);",
|
||||||
|
" [PreserveSig]",
|
||||||
|
" int ActivateForFile([MarshalAs(UnmanagedType.LPWStr)] string appUserModelId, IntPtr itemArray, [MarshalAs(UnmanagedType.LPWStr)] string verb, out uint processId);",
|
||||||
|
" [PreserveSig]",
|
||||||
|
" int ActivateForProtocol([MarshalAs(UnmanagedType.LPWStr)] string appUserModelId, IntPtr itemArray, out uint processId);",
|
||||||
|
"}",
|
||||||
|
"public static class Launcher {",
|
||||||
|
" const int COINIT_APARTMENTTHREADED = 0x2;",
|
||||||
|
" const int RPC_E_CHANGED_MODE = unchecked((int)0x80010106);",
|
||||||
|
" [DllImport(\"ole32.dll\")] static extern int CoInitializeEx(IntPtr pvReserved, int dwCoInit);",
|
||||||
|
" [DllImport(\"ole32.dll\")] static extern void CoUninitialize();",
|
||||||
|
" public static uint Activate(string appUserModelId, string arguments) {",
|
||||||
|
" int coinit = CoInitializeEx(IntPtr.Zero, COINIT_APARTMENTTHREADED);",
|
||||||
|
" bool shouldUninitialize = coinit >= 0;",
|
||||||
|
" if (coinit < 0 && coinit != RPC_E_CHANGED_MODE) Marshal.ThrowExceptionForHR(coinit);",
|
||||||
|
" try {",
|
||||||
|
" var manager = (IApplicationActivationManager)new ApplicationActivationManager();",
|
||||||
|
" uint processId = 0;",
|
||||||
|
" int hr = manager.ActivateApplication(appUserModelId, arguments, 0, out processId);",
|
||||||
|
" if (hr != 0) throw new COMException(\"ActivateApplication failed\", hr);",
|
||||||
|
" return processId;",
|
||||||
|
" } finally {",
|
||||||
|
" if (shouldUninitialize) CoUninitialize();",
|
||||||
|
" }",
|
||||||
|
" }",
|
||||||
|
"}",
|
||||||
|
"}",
|
||||||
|
"'@",
|
||||||
|
"Add-Type -TypeDefinition $code -ErrorAction Stop",
|
||||||
|
"$activatedProcessId=[TdCodexAppActivation.Launcher]::Activate($env:TD_CODEX_APPID, $env:TD_CODEX_LAUNCH_ARGS)",
|
||||||
|
"[pscustomobject]@{ok=$true;pid=$activatedProcessId;args=$env:TD_CODEX_LAUNCH_ARGS}|ConvertTo-Json -Compress",
|
||||||
|
].join("\n");
|
||||||
|
const scriptPath = path.join(os.tmpdir(), "td-codex-appid-activate-" + process.pid + "-" + Date.now() + ".ps1");
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(scriptPath, script, "utf8");
|
||||||
|
} catch (e) {
|
||||||
|
reject(e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
execFile("powershell.exe", ["-NoProfile", "-Sta", "-ExecutionPolicy", "Bypass", "-File", scriptPath], {
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
TD_CODEX_APPID: String(appId || ""),
|
||||||
|
TD_CODEX_LAUNCH_PORT: String(port),
|
||||||
|
TD_CODEX_LAUNCH_ARGS: windowsCommandLineArguments(windowsLaunchArgs(port)),
|
||||||
|
},
|
||||||
|
windowsHide: true,
|
||||||
|
timeout: WINDOWS_APPID_ACTIVATE_TIMEOUT_MS,
|
||||||
|
}, (err, stdout) => {
|
||||||
|
try { fs.rmSync(scriptPath, { force: true }); } catch {}
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let data = null;
|
||||||
|
try {
|
||||||
|
data = JSON.parse(String(stdout || "").trim());
|
||||||
} catch {}
|
} catch {}
|
||||||
|
resolve(data || { ok: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function windowsShellStart(exePath, port) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (process.platform !== "win32") {
|
||||||
|
reject(new Error("shell start only supports Windows"));
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
return "";
|
const launchType = state.codexLaunchType || "exe";
|
||||||
|
const target = launchType === "appId"
|
||||||
|
? "shell:AppsFolder\\" + String(state.codexAppId || exePath || "").trim()
|
||||||
|
: String(exePath || "").replace(/^\\\\\?\\/, "");
|
||||||
|
if (launchType === "appId") {
|
||||||
|
windowsActivateAppId(state.codexAppId || exePath, port).then((data) => {
|
||||||
|
const pid = data && data.pid ? `,PID ${data.pid}` : "";
|
||||||
|
state.launchWarning = "已通过 Windows AppID 激活 Store/MSIX 版 Codex" + pid;
|
||||||
|
resolve(true);
|
||||||
|
}).catch((activateErr) => {
|
||||||
|
execFile("explorer.exe", [target], { windowsHide: true, timeout: WINDOWS_LAUNCH_TIMEOUT_MS }, (err) => {
|
||||||
|
const first = "COM 激活提示:" + compactError(activateErr, "AppID 激活失败");
|
||||||
|
if (err) {
|
||||||
|
// explorer.exe often returns a failure code for shell:AppsFolder
|
||||||
|
// even after Windows has already handed off and launched the app.
|
||||||
|
const second = "explorer 提示:" + compactError(err, "explorer 启动失败");
|
||||||
|
state.launchWarning = (first + ";" + second).slice(0, 220);
|
||||||
|
resolve(false);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
return "";
|
state.launchWarning = (first + ";已通过 explorer 兜底拉起 Store/MSIX 版 Codex").slice(0, 220);
|
||||||
|
resolve(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const script = [
|
||||||
|
"$target=$env:TD_CODEX_LAUNCH_TARGET",
|
||||||
|
"$args=@('--remote-debugging-port=' + $env:TD_CODEX_LAUNCH_PORT, '--remote-allow-origins=http://127.0.0.1:' + $env:TD_CODEX_LAUNCH_PORT)",
|
||||||
|
"Start-Process -FilePath $target -ArgumentList $args",
|
||||||
|
].join(";");
|
||||||
|
execFile("powershell.exe", ["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script], {
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
TD_CODEX_LAUNCH_TARGET: target,
|
||||||
|
TD_CODEX_LAUNCH_PORT: String(port),
|
||||||
|
},
|
||||||
|
windowsHide: true,
|
||||||
|
timeout: WINDOWS_LAUNCH_TIMEOUT_MS,
|
||||||
|
}, (err) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.launchWarning = "";
|
||||||
|
resolve(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// —— HTTP:GET /json 取 targets ——
|
// —— HTTP:GET /json 取 targets ——
|
||||||
@@ -427,7 +693,22 @@ async function handleBinding(params) {
|
|||||||
} catch {
|
} catch {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!payload || payload.kind !== "transcribe") return;
|
if (!payload) return;
|
||||||
|
if (
|
||||||
|
payload.kind === "delete-session" ||
|
||||||
|
payload.kind === "delete-session-clipboard-prepare" ||
|
||||||
|
payload.kind === "delete-session-clipboard-read"
|
||||||
|
) {
|
||||||
|
if (onSessionDeleteCb) {
|
||||||
|
try { await onSessionDeleteCb(payload); } catch (e) { dlog("永久删除会话请求失败:", e && e.message); }
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (payload.kind === "debug-sidebar-dump") {
|
||||||
|
dlog("侧边栏诊断:", JSON.stringify(payload.info || {}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (payload.kind !== "transcribe") return;
|
||||||
const { id, audioB64, mime } = payload;
|
const { id, audioB64, mime } = payload;
|
||||||
dlog("binding 收到转写请求 id=" + id, "mime=" + mime, "audioB64长度=" + (audioB64?audioB64.length:0));
|
dlog("binding 收到转写请求 id=" + id, "mime=" + mime, "audioB64长度=" + (audioB64?audioB64.length:0));
|
||||||
setPhase("transcribing");
|
setPhase("transcribing");
|
||||||
@@ -456,6 +737,11 @@ function connectedPhase() {
|
|||||||
|
|
||||||
// —— 注入:addBinding + addScriptToEvaluateOnNewDocument + 立即 evaluate + Network 监听 ——
|
// —— 注入:addBinding + addScriptToEvaluateOnNewDocument + 立即 evaluate + Network 监听 ——
|
||||||
async function inject(sessionObj) {
|
async function inject(sessionObj) {
|
||||||
|
// 清除 require 缓存,使 voice_inject.js 的修改能在重连时生效,无需重启整个应用
|
||||||
|
delete require.cache[require.resolve("./voice_inject")];
|
||||||
|
delete require.cache[require.resolve("./token_inject")];
|
||||||
|
const { INJECT_SCRIPT: FRESH_INJECT } = require("./voice_inject");
|
||||||
|
const { TOKEN_INJECT_SCRIPT: FRESH_TOKEN } = require("./token_inject");
|
||||||
await sessionObj.send("Runtime.enable");
|
await sessionObj.send("Runtime.enable");
|
||||||
try {
|
try {
|
||||||
await sessionObj.send("Runtime.removeBinding", { name: BINDING_NAME });
|
await sessionObj.send("Runtime.removeBinding", { name: BINDING_NAME });
|
||||||
@@ -463,11 +749,12 @@ async function inject(sessionObj) {
|
|||||||
await sessionObj.send("Runtime.addBinding", { name: BINDING_NAME });
|
await sessionObj.send("Runtime.addBinding", { name: BINDING_NAME });
|
||||||
await sessionObj.send("Page.enable");
|
await sessionObj.send("Page.enable");
|
||||||
// 语音注入脚本
|
// 语音注入脚本
|
||||||
await sessionObj.send("Page.addScriptToEvaluateOnNewDocument", { source: INJECT_SCRIPT });
|
const voiceScript = await sessionObj.send("Page.addScriptToEvaluateOnNewDocument", { source: FRESH_INJECT });
|
||||||
await sessionObj.send("Runtime.evaluate", { expression: INJECT_SCRIPT, awaitPromise: false });
|
voiceScriptIdentifier = (voiceScript && voiceScript.identifier) || "";
|
||||||
|
await sessionObj.send("Runtime.evaluate", { expression: FRESH_INJECT, awaitPromise: false });
|
||||||
// Token 用量 UI 注入脚本(只负责 UI + localStorage,不管网络抓取)
|
// Token 用量 UI 注入脚本(只负责 UI + localStorage,不管网络抓取)
|
||||||
await sessionObj.send("Page.addScriptToEvaluateOnNewDocument", { source: TOKEN_INJECT_SCRIPT });
|
await sessionObj.send("Page.addScriptToEvaluateOnNewDocument", { source: FRESH_TOKEN });
|
||||||
await sessionObj.send("Runtime.evaluate", { expression: TOKEN_INJECT_SCRIPT, awaitPromise: false });
|
await sessionObj.send("Runtime.evaluate", { expression: FRESH_TOKEN, awaitPromise: false });
|
||||||
// 启用 Network 域,在 CDP 层面拦截 LLM 响应抓 token 用量
|
// 启用 Network 域,在 CDP 层面拦截 LLM 响应抓 token 用量
|
||||||
await sessionObj.send("Network.enable");
|
await sessionObj.send("Network.enable");
|
||||||
}
|
}
|
||||||
@@ -732,18 +1019,19 @@ async function connectToTarget(port) {
|
|||||||
closeNetworkSessions();
|
closeNetworkSessions();
|
||||||
state.target = { id: tgt.id, url: tgt.url, title: tgt.title };
|
state.target = { id: tgt.id, url: tgt.url, title: tgt.title };
|
||||||
setPhase("connecting");
|
setPhase("connecting");
|
||||||
session = await openSession(tgt.webSocketDebuggerUrl, handleBinding);
|
const ownedSession = await openSession(tgt.webSocketDebuggerUrl, handleBinding);
|
||||||
session.on("Runtime.bindingCalled", handleBinding);
|
session = ownedSession;
|
||||||
// Token 用量:Network 层拦截 LLM 响应
|
// Token 用量:Network 层拦截 LLM 响应
|
||||||
bindNetworkHandlers(session);
|
bindNetworkHandlers(ownedSession);
|
||||||
session.ws.on("close", () => {
|
ownedSession.ws.on("close", () => {
|
||||||
|
if (session !== ownedSession) return;
|
||||||
stopTokenSync();
|
stopTokenSync();
|
||||||
closeNetworkSessions();
|
closeNetworkSessions();
|
||||||
session = null;
|
session = null;
|
||||||
setPhase("disconnected");
|
setPhase("disconnected");
|
||||||
});
|
});
|
||||||
setPhase("injecting");
|
setPhase("injecting");
|
||||||
await inject(session);
|
await inject(ownedSession);
|
||||||
startTokenSync();
|
startTokenSync();
|
||||||
setPhase("connected", { target: state.target });
|
setPhase("connected", { target: state.target });
|
||||||
attachWorkerNetworkTargets(port, tgt.id).catch((e) => {
|
attachWorkerNetworkTargets(port, tgt.id).catch((e) => {
|
||||||
@@ -755,44 +1043,66 @@ async function connectToTarget(port) {
|
|||||||
// —— 轮询直到出现 page target(重启后用)——
|
// —— 轮询直到出现 page target(重启后用)——
|
||||||
async function waitForReady(port) {
|
async function waitForReady(port) {
|
||||||
const t0 = Date.now();
|
const t0 = Date.now();
|
||||||
while (Date.now() - t0 < READY_POLL_MAX_MS) {
|
const maxMs = state.codexPackaged ? PACKAGED_READY_POLL_MAX_MS : READY_POLL_MAX_MS;
|
||||||
|
let diagnosed = false;
|
||||||
|
while (Date.now() - t0 < maxMs) {
|
||||||
const targets = await listTargets(port);
|
const targets = await listTargets(port);
|
||||||
const tgt = pickTarget(targets);
|
const tgt = pickTarget(targets);
|
||||||
if (tgt) return tgt;
|
if (tgt) return tgt;
|
||||||
|
if (!diagnosed && process.platform === "win32" && Date.now() - t0 >= 5000) {
|
||||||
|
diagnosed = true;
|
||||||
|
const d = updateWindowsProcessDiagnostic(port);
|
||||||
|
setPhase("waiting_debug_port", { portDiagnostic: state.portDiagnostic });
|
||||||
|
if (state.codexPackaged && d && Array.isArray(d.rows) && d.rows.length && !d.hasDebugArg) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
await new Promise((r) => setTimeout(r, READY_POLL_INTERVAL));
|
await new Promise((r) => setTimeout(r, READY_POLL_INTERVAL));
|
||||||
}
|
}
|
||||||
|
updateWindowsProcessDiagnostic(port);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// —— 启动接管:杀当前 Codex → 带端口重启 ——
|
|
||||||
function killCodex() {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
if (process.platform === "darwin") {
|
|
||||||
execFile("osascript", ["-e", 'tell application "ChatGPT" to quit'], () => {
|
|
||||||
// 兜底:osascript 没杀掉就 pkill
|
|
||||||
execFile("pgrep", ["-f", "ChatGPT.app/Contents/MacOS/ChatGPT"], (_e, out) => {
|
|
||||||
if (out && out.trim()) {
|
|
||||||
execFile("pkill", ["-f", "ChatGPT.app/Contents/MacOS/ChatGPT"], () =>
|
|
||||||
setTimeout(resolve, 800)
|
|
||||||
);
|
|
||||||
} else resolve();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
} else if (process.platform === "win32") {
|
|
||||||
execFile("taskkill", ["/IM", "ChatGPT.exe", "/F"], () =>
|
|
||||||
setTimeout(resolve, 800)
|
|
||||||
);
|
|
||||||
} else resolve();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
function spawnCodex(exePath, port) {
|
function spawnCodex(exePath, port) {
|
||||||
const child = spawn(exePath, [`--remote-debugging-port=${port}`], {
|
if (state.codexPackaged && process.platform === "win32") {
|
||||||
|
return windowsShellStart(exePath, port);
|
||||||
|
}
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let child = null;
|
||||||
|
try {
|
||||||
|
const args = process.platform === "win32" ? windowsLaunchArgs(port) : [`--remote-debugging-port=${port}`];
|
||||||
|
child = spawn(exePath, args, {
|
||||||
detached: true,
|
detached: true,
|
||||||
stdio: "ignore",
|
stdio: "ignore",
|
||||||
|
windowsHide: false,
|
||||||
});
|
});
|
||||||
|
} catch (e) {
|
||||||
|
reject(e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.launchWarning = "";
|
||||||
|
let settled = false;
|
||||||
|
const finish = (err) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
child.unref();
|
child.unref();
|
||||||
} catch {}
|
} catch {}
|
||||||
|
resolve(true);
|
||||||
|
};
|
||||||
|
child.once("error", (err) => {
|
||||||
|
if (process.platform === "win32" && /eperm/i.test(String((err && err.message) || err || ""))) {
|
||||||
|
windowsShellStart(exePath, port).then(() => finish(null)).catch(finish);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
finish(err);
|
||||||
|
});
|
||||||
|
child.once("spawn", () => finish(null));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// —— 对外:status / connect / disconnect ——
|
// —— 对外:status / connect / disconnect ——
|
||||||
@@ -802,31 +1112,39 @@ function status() {
|
|||||||
function onStatus(cb) {
|
function onStatus(cb) {
|
||||||
onStatusCb = cb;
|
onStatusCb = cb;
|
||||||
}
|
}
|
||||||
|
function onSessionDelete(cb) {
|
||||||
|
onSessionDeleteCb = cb;
|
||||||
|
}
|
||||||
|
|
||||||
// connect(onStatusCb):流程见计划。forceRestart=true 时直接重启(前端已确认)。
|
// connect(onStatusCb):流程见计划。forceRestart=true 时直接重启(前端已确认)。
|
||||||
async function connect(cb, opts = {}) {
|
async function connect(cb, opts = {}) {
|
||||||
if (cb) onStatusCb = cb;
|
if (cb) onStatusCb = cb;
|
||||||
const port = opts.port || DEFAULT_PORT;
|
const port = opts.port || DEFAULT_PORT;
|
||||||
state.port = port;
|
state.port = port;
|
||||||
setPhase("checking");
|
setPhase("checking", { lastError: "", launchWarning: "", portDiagnostic: "" });
|
||||||
|
const exe = scanCodexExecutable();
|
||||||
// 1. 端口已开?
|
// 1. 端口已开?
|
||||||
const ok = await connectToTarget(port);
|
const ok = await connectToTarget(port);
|
||||||
if (ok) return snapshot();
|
if (ok) return snapshot();
|
||||||
// 2. 判断 Codex 是否在跑
|
// 2. 判断 Codex 是否在跑
|
||||||
const running = providerSync.codexRunning ? providerSync.codexRunning() : false;
|
const running = codexDesktop.codexRunning();
|
||||||
if (!running && !opts.forceRestart) {
|
if (!running && !opts.forceRestart) {
|
||||||
// 没在跑也没开端口:直接尝试拉起带端口的实例(无需杀进程)
|
// 没在跑也没开端口:直接尝试拉起带端口的实例(无需杀进程)
|
||||||
const exe = codexExecutable();
|
|
||||||
if (!exe) {
|
if (!exe) {
|
||||||
setPhase("error", { lastError: "未找到 Codex 可执行文件" });
|
setPhase("error", { lastError: desktopNotFoundError(), discovery: state.discovery });
|
||||||
return snapshot();
|
return snapshot();
|
||||||
}
|
}
|
||||||
state.codexPath = exe;
|
|
||||||
setPhase("restarting", { codexPath: exe });
|
setPhase("restarting", { codexPath: exe });
|
||||||
spawnCodex(exe, port);
|
try {
|
||||||
|
await spawnCodex(exe, port);
|
||||||
|
} catch (e) {
|
||||||
|
setPhase("error", { lastError: desktopLaunchError(exe, e), discovery: state.discovery });
|
||||||
|
return snapshot();
|
||||||
|
}
|
||||||
|
setPhase("waiting_debug_port", { codexPath: exe, lastError: "" });
|
||||||
const tgt = await waitForReady(port);
|
const tgt = await waitForReady(port);
|
||||||
if (!tgt) {
|
if (!tgt) {
|
||||||
setPhase("error", { lastError: "Codex 启动后未开放调试端口,请重试" });
|
setDebugPortFailure("启动", port);
|
||||||
return snapshot();
|
return snapshot();
|
||||||
}
|
}
|
||||||
const ok2 = await connectToTarget(port);
|
const ok2 = await connectToTarget(port);
|
||||||
@@ -835,23 +1153,27 @@ async function connect(cb, opts = {}) {
|
|||||||
}
|
}
|
||||||
// 3. 在跑但没端口:需用户在前端确认后 forceRestart 重启
|
// 3. 在跑但没端口:需用户在前端确认后 forceRestart 重启
|
||||||
if (running && !opts.forceRestart) {
|
if (running && !opts.forceRestart) {
|
||||||
state.codexPath = codexExecutable();
|
|
||||||
setPhase("running_no_port", { codexPath: state.codexPath });
|
setPhase("running_no_port", { codexPath: state.codexPath });
|
||||||
return snapshot(); // 前端据此弹"重启确认"
|
return snapshot(); // 前端据此弹"重启确认"
|
||||||
}
|
}
|
||||||
// 4. forceRestart:杀进程 → 重启
|
// 4. forceRestart:杀进程 → 重启
|
||||||
const exe = codexExecutable();
|
|
||||||
if (!exe) {
|
if (!exe) {
|
||||||
setPhase("error", { lastError: "未找到 Codex 可执行文件" });
|
setPhase("error", { lastError: desktopNotFoundError(), discovery: state.discovery });
|
||||||
return snapshot();
|
return snapshot();
|
||||||
}
|
}
|
||||||
state.codexPath = exe;
|
state.codexPath = exe;
|
||||||
setPhase("restarting");
|
setPhase("restarting");
|
||||||
await killCodex();
|
await codexDesktop.killCodex();
|
||||||
spawnCodex(exe, port);
|
try {
|
||||||
|
await spawnCodex(exe, port);
|
||||||
|
} catch (e) {
|
||||||
|
setPhase("error", { lastError: desktopLaunchError(exe, e), discovery: state.discovery });
|
||||||
|
return snapshot();
|
||||||
|
}
|
||||||
|
setPhase("waiting_debug_port", { codexPath: exe, lastError: "" });
|
||||||
const tgt = await waitForReady(port);
|
const tgt = await waitForReady(port);
|
||||||
if (!tgt) {
|
if (!tgt) {
|
||||||
setPhase("error", { lastError: "重启 Codex 后未开放调试端口" });
|
setDebugPortFailure("重启", port);
|
||||||
return snapshot();
|
return snapshot();
|
||||||
}
|
}
|
||||||
const ok3 = await connectToTarget(port);
|
const ok3 = await connectToTarget(port);
|
||||||
@@ -859,14 +1181,28 @@ async function connect(cb, opts = {}) {
|
|||||||
return snapshot();
|
return snapshot();
|
||||||
}
|
}
|
||||||
|
|
||||||
function disconnect() {
|
async function disconnect() {
|
||||||
stopTokenSync();
|
stopTokenSync();
|
||||||
closeNetworkSessions();
|
closeNetworkSessions();
|
||||||
if (session) {
|
if (session) {
|
||||||
|
const ownedSession = session;
|
||||||
|
// 先让页面停止录音并删除已挂载按钮,再关闭 CDP;关闭后无法清理前台 DOM。
|
||||||
try {
|
try {
|
||||||
session.close();
|
await ownedSession.send("Runtime.evaluate", {
|
||||||
|
expression: "window.__voiceInjectDispose && window.__voiceInjectDispose()",
|
||||||
|
awaitPromise: false,
|
||||||
|
});
|
||||||
} catch {}
|
} catch {}
|
||||||
|
if (voiceScriptIdentifier) {
|
||||||
|
try {
|
||||||
|
await ownedSession.send("Page.removeScriptToEvaluateOnNewDocument", { identifier: voiceScriptIdentifier });
|
||||||
|
} catch {}
|
||||||
|
voiceScriptIdentifier = "";
|
||||||
|
}
|
||||||
session = null;
|
session = null;
|
||||||
|
try {
|
||||||
|
ownedSession.close();
|
||||||
|
} catch {}
|
||||||
}
|
}
|
||||||
setPhase("disconnected");
|
setPhase("disconnected");
|
||||||
return snapshot();
|
return snapshot();
|
||||||
@@ -973,6 +1309,7 @@ module.exports = {
|
|||||||
connect,
|
connect,
|
||||||
disconnect,
|
disconnect,
|
||||||
onStatus,
|
onStatus,
|
||||||
|
onSessionDelete,
|
||||||
sttInfo,
|
sttInfo,
|
||||||
probeStt,
|
probeStt,
|
||||||
tokenStatus,
|
tokenStatus,
|
||||||
|
|||||||
@@ -7,17 +7,23 @@
|
|||||||
// 注意:脚本体用普通模板字符串包裹;内部避免出现 ${(已无)与裸反引号(内部的模板字符串反引号需转义为 \`)。
|
// 注意:脚本体用普通模板字符串包裹;内部避免出现 ${(已无)与裸反引号(内部的模板字符串反引号需转义为 \`)。
|
||||||
const INJECT_SCRIPT = `
|
const INJECT_SCRIPT = `
|
||||||
(() => {
|
(() => {
|
||||||
const VER = "voice-inject-v6";
|
const VER = "voice-inject-v13";
|
||||||
if (window.__voiceInjectInstalled === VER) return;
|
if (window.__voiceInjectInstalled === VER) return;
|
||||||
// 版本变了或首次:清理旧按钮/旧 observer,重新安装
|
// 版本变了或首次:完整卸载旧实例,避免旧 observer 在新会话继续挂回按钮。
|
||||||
if (window.__voiceInjectObserver) { try { window.__voiceInjectObserver.disconnect(); } catch {} }
|
if (typeof window.__voiceInjectDispose === "function") {
|
||||||
|
try { window.__voiceInjectDispose(); } catch {}
|
||||||
|
} else if (window.__voiceInjectObserver) {
|
||||||
|
try { window.__voiceInjectObserver.disconnect(); } catch {}
|
||||||
|
}
|
||||||
const oldBtn = document.getElementById("codex-voice-input-btn");
|
const oldBtn = document.getElementById("codex-voice-input-btn");
|
||||||
if (oldBtn) oldBtn.remove();
|
if (oldBtn) oldBtn.remove();
|
||||||
|
document.querySelectorAll("[data-td-sidebar-delete]").forEach((el) => el.remove());
|
||||||
window.__voiceInjectInstalled = VER;
|
window.__voiceInjectInstalled = VER;
|
||||||
|
|
||||||
const TAG = "[VoiceInput]";
|
const TAG = "[VoiceInput]";
|
||||||
const BTN_ID = "codex-voice-input-btn";
|
const BTN_ID = "codex-voice-input-btn";
|
||||||
const STYLE_ID = "codex-voice-input-style-v6";
|
const STYLE_ID = "codex-voice-input-style-v13";
|
||||||
|
let disposed = false;
|
||||||
|
|
||||||
// —— 样式 ——
|
// —— 样式 ——
|
||||||
if (!document.getElementById(STYLE_ID)) {
|
if (!document.getElementById(STYLE_ID)) {
|
||||||
@@ -58,11 +64,18 @@ const INJECT_SCRIPT = `
|
|||||||
font:13px system-ui,sans-serif;z-index:999999;box-shadow:0 8px 30px rgba(0,0,0,.5);
|
font:13px system-ui,sans-serif;z-index:999999;box-shadow:0 8px 30px rgba(0,0,0,.5);
|
||||||
max-width:80vw;
|
max-width:80vw;
|
||||||
}
|
}
|
||||||
|
.td-sidebar-delete-btn{color:rgba(113,113,122,.72)!important;}
|
||||||
|
.td-sidebar-delete-btn:hover,.td-sidebar-delete-btn:focus-visible{color:#dc2626!important;}
|
||||||
|
@media (prefers-color-scheme:dark){
|
||||||
|
.td-sidebar-delete-btn{color:rgba(161,161,170,.72)!important;}
|
||||||
|
.td-sidebar-delete-btn:hover,.td-sidebar-delete-btn:focus-visible{color:#f87171!important;}
|
||||||
|
}
|
||||||
\`;
|
\`;
|
||||||
(document.head || document.documentElement).appendChild(st);
|
(document.head || document.documentElement).appendChild(st);
|
||||||
}
|
}
|
||||||
|
|
||||||
function toast(msg, ms = 2400) {
|
function toast(msg, ms = 2400) {
|
||||||
|
if (disposed) return;
|
||||||
const t = document.createElement("div");
|
const t = document.createElement("div");
|
||||||
t.className = "codex-voice-toast";
|
t.className = "codex-voice-toast";
|
||||||
t.textContent = msg;
|
t.textContent = msg;
|
||||||
@@ -70,6 +83,410 @@ const INJECT_SCRIPT = `
|
|||||||
setTimeout(() => t.remove(), ms);
|
setTimeout(() => t.remove(), ms);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 某些中转会把内部思考块作为普通正文推给页面。只处理已明确成对的
|
||||||
|
// 内部标签,保留用户输入框、代码编辑器和工具箱自己注入的节点。
|
||||||
|
function stripVisibleThoughtText(text) {
|
||||||
|
let out = String(text || "");
|
||||||
|
for (const name of ["thinking", "analysis", "reasoning"]) {
|
||||||
|
out = out.replace(new RegExp("<" + name + "\\b[^>]*>[\\s\\S]*?</" + name + "\\s*>", "gi"), "");
|
||||||
|
}
|
||||||
|
return out.replace(/<\\/?(?:thinking|analysis|reasoning)\\b[^>]*>/gi, "");
|
||||||
|
}
|
||||||
|
function sanitizeVisibleThoughts(root) {
|
||||||
|
if (!root || !document.createTreeWalker) return;
|
||||||
|
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
||||||
|
const nodes = [];
|
||||||
|
let node;
|
||||||
|
while ((node = walker.nextNode())) nodes.push(node);
|
||||||
|
for (const textNode of nodes) {
|
||||||
|
const parent = textNode.parentElement;
|
||||||
|
if (!parent || parent.closest("#codex-voice-input-btn, textarea, input, [contenteditable='true'], script, style")) continue;
|
||||||
|
const next = stripVisibleThoughtText(textNode.nodeValue);
|
||||||
|
if (next !== textNode.nodeValue) textNode.nodeValue = next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 在 Codex 任务右键菜单中增加真正删除入口。只从当前菜单关联的
|
||||||
|
// DOM 数据/链接提取 thread id,拿不到 id 时不显示,避免误删。
|
||||||
|
let currentMenuThreadId = "";
|
||||||
|
const THREAD_ID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
|
||||||
|
function readReactThreadId(node) {
|
||||||
|
for (const key of Object.keys(node || {})) {
|
||||||
|
if (!key.startsWith("__reactProps$") && !key.startsWith("__reactFiber$")) continue;
|
||||||
|
const seen = new Set();
|
||||||
|
const walk = (value, depth, field) => {
|
||||||
|
if (depth > 4 || value == null) return "";
|
||||||
|
if (typeof value === "string") {
|
||||||
|
const hit = value.match(THREAD_ID_RE);
|
||||||
|
return hit && /thread|conversation|session|href|id/i.test(field || "") ? hit[0] : "";
|
||||||
|
}
|
||||||
|
if (typeof value !== "object" || seen.has(value) || value instanceof Node) return "";
|
||||||
|
seen.add(value);
|
||||||
|
for (const [name, child] of Object.entries(value)) {
|
||||||
|
if (/^(children|return|sibling|child|stateNode|alternate|_owner)$/i.test(name)) continue;
|
||||||
|
const hit = walk(child, depth + 1, name);
|
||||||
|
if (hit) return hit;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
};
|
||||||
|
const hit = walk(node[key], 0, key);
|
||||||
|
if (hit) return hit;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
function readThreadId(node) {
|
||||||
|
let cur = node;
|
||||||
|
for (let depth = 0; cur && depth < 8; depth++, cur = cur.parentElement) {
|
||||||
|
for (const key of ["threadId", "conversationId", "sessionId", "id"]) {
|
||||||
|
const value = cur.dataset && cur.dataset[key];
|
||||||
|
if (value && THREAD_ID_RE.test(value)) return value.match(THREAD_ID_RE)[0];
|
||||||
|
}
|
||||||
|
const href = cur.getAttribute && cur.getAttribute("href");
|
||||||
|
const hit = href && href.match(THREAD_ID_RE);
|
||||||
|
if (hit) return hit[0];
|
||||||
|
const text = cur.getAttribute && (cur.getAttribute("data-state") || cur.getAttribute("aria-controls") || "");
|
||||||
|
const textHit = text.match(THREAD_ID_RE);
|
||||||
|
if (textHit) return textHit[0];
|
||||||
|
const nested = cur.querySelector && cur.querySelector("a[href],[data-thread-id],[data-conversation-id],[data-session-id]");
|
||||||
|
if (nested && nested !== cur) {
|
||||||
|
const nestedValue = (nested.getAttribute("href") || "") + " " +
|
||||||
|
(nested.dataset && (nested.dataset.threadId || nested.dataset.conversationId || nested.dataset.sessionId) || "");
|
||||||
|
const nestedHit = nestedValue.match(THREAD_ID_RE);
|
||||||
|
if (nestedHit) return nestedHit[0];
|
||||||
|
}
|
||||||
|
const reactHit = readReactThreadId(cur);
|
||||||
|
if (reactHit) return reactHit;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
function sidebarThreadId(row) {
|
||||||
|
const raw = row && row.getAttribute && row.getAttribute("data-app-action-sidebar-thread-id");
|
||||||
|
const hit = String(raw || "").match(THREAD_ID_RE);
|
||||||
|
return hit ? hit[0] : readThreadId(row);
|
||||||
|
}
|
||||||
|
function copySessionIdItem(menu) {
|
||||||
|
if (!menu) return null;
|
||||||
|
const candidates = menu.querySelectorAll("button,[role='menuitem'],[role='button'],li,div");
|
||||||
|
for (const item of candidates) {
|
||||||
|
if (item.dataset && item.dataset.tdPermanentDelete) continue;
|
||||||
|
if (String(item.textContent || "").trim() === "复制会话 ID") return item;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
function menuLooksLikeSession(menu) {
|
||||||
|
const text = String(menu && menu.textContent || "");
|
||||||
|
return !!copySessionIdItem(menu) && /归档任务|复制深度链接|重命名任务/.test(text);
|
||||||
|
}
|
||||||
|
function findMenuThreadId(menu) {
|
||||||
|
const direct = readThreadId(menu);
|
||||||
|
if (direct) return direct;
|
||||||
|
const links = menu ? menu.querySelectorAll("a,button,[role='menuitem'],[data-thread-id],[data-conversation-id]") : [];
|
||||||
|
for (const el of links) {
|
||||||
|
const hit = readThreadId(el);
|
||||||
|
if (hit) return hit;
|
||||||
|
}
|
||||||
|
return currentMenuThreadId;
|
||||||
|
}
|
||||||
|
function sendDeleteRequest(threadId, menu) {
|
||||||
|
if (!threadId || typeof window.__voiceBridge !== "function") {
|
||||||
|
const nativeCopy = copySessionIdItem(menu);
|
||||||
|
if (!nativeCopy || typeof window.__voiceBridge !== "function") {
|
||||||
|
toast("未找到会话 ID,请从工具箱会话记录页删除");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const requestId = "td-del-" + Date.now() + "-" + Math.random().toString(16).slice(2);
|
||||||
|
try {
|
||||||
|
// 先让主进程保存剪贴板,随后复用 Codex 原生“复制会话 ID”菜单动作。
|
||||||
|
window.__voiceBridge(JSON.stringify({ kind: "delete-session-clipboard-prepare", requestId }));
|
||||||
|
setTimeout(() => {
|
||||||
|
try { nativeCopy.click(); } catch {}
|
||||||
|
setTimeout(() => {
|
||||||
|
try { window.__voiceBridge(JSON.stringify({ kind: "delete-session-clipboard-read", requestId })); } catch {}
|
||||||
|
}, 90);
|
||||||
|
}, 110);
|
||||||
|
toast("正在读取会话 ID…", 2200);
|
||||||
|
} catch (e) {
|
||||||
|
toast("删除请求发送失败");
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
window.__voiceBridge(JSON.stringify({ kind: "delete-session", threadId }));
|
||||||
|
toast("正在执行永久删除,请稍候…", 3200);
|
||||||
|
return false;
|
||||||
|
} catch (e) {
|
||||||
|
toast("删除请求发送失败");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function addDeleteMenuItem(menu) {
|
||||||
|
if (!menu || menu.querySelector("[data-td-permanent-delete]")) return;
|
||||||
|
if (!menuLooksLikeSession(menu)) return;
|
||||||
|
const threadId = findMenuThreadId(menu);
|
||||||
|
const nativeCopy = copySessionIdItem(menu);
|
||||||
|
if (!nativeCopy) return;
|
||||||
|
const item = document.createElement("button");
|
||||||
|
item.type = "button";
|
||||||
|
item.dataset.tdPermanentDelete = "1";
|
||||||
|
item.textContent = "彻底删除本地会话";
|
||||||
|
item.title = "退出 Codex 后删除本地会话、SQLite 索引和工具箱记忆";
|
||||||
|
item.style.cssText = "display:block;width:100%;margin-top:6px;padding:8px 12px;border:0;border-top:1px solid rgba(127,127,127,.2);background:transparent;color:#dc2626;text-align:left;font:inherit;cursor:pointer;";
|
||||||
|
item.addEventListener("mouseenter", () => { item.style.background = "rgba(220,38,38,.10)"; });
|
||||||
|
item.addEventListener("mouseleave", () => { item.style.background = "transparent"; });
|
||||||
|
item.addEventListener("click", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
const needsNativeCopy = sendDeleteRequest(threadId, menu);
|
||||||
|
const root = item.closest("[role='menu'],[data-radix-menu-content]") || item.parentElement;
|
||||||
|
if (root) setTimeout(() => root.remove(), needsNativeCopy ? 280 : 0);
|
||||||
|
});
|
||||||
|
(nativeCopy.parentElement || menu).appendChild(item);
|
||||||
|
}
|
||||||
|
function scanSessionMenus() {
|
||||||
|
document.querySelectorAll("[role='menu'],[data-radix-menu-content],div[class*='menu'],div[class*='popover']").forEach((el) => addDeleteMenuItem(el));
|
||||||
|
}
|
||||||
|
function captureMenuTarget(event) {
|
||||||
|
currentMenuThreadId = readThreadId(event.target);
|
||||||
|
setTimeout(scanSessionMenus, 30);
|
||||||
|
setTimeout(scanSessionMenus, 180);
|
||||||
|
}
|
||||||
|
if (window.__tdPermanentDeleteContextHandler) {
|
||||||
|
document.removeEventListener("contextmenu", window.__tdPermanentDeleteContextHandler, true);
|
||||||
|
document.removeEventListener("pointerdown", window.__tdPermanentDeleteContextHandler, true);
|
||||||
|
}
|
||||||
|
// 原生菜单不经过网页 DOM,不再对全局 pointerdown 做昂贵扫描。保留手动
|
||||||
|
// scanner 仅用于未来 Codex 切回网页菜单时诊断。
|
||||||
|
window.__tdPermanentDeleteContextHandler = null;
|
||||||
|
window.__tdScanPermanentDeleteMenu = scanSessionMenus;
|
||||||
|
|
||||||
|
// Codex 的右键菜单是 Electron 原生 Menu,网页脚本无法向其中追加 DOM。
|
||||||
|
// 因此把彻底删除入口挂到任务行已有的“置顶/归档”操作组,macOS/Windows 共用。
|
||||||
|
function makeSidebarDeleteButton(row, actionBox) {
|
||||||
|
const archive = actionBox.querySelector("button[aria-label='归档任务'],button[aria-label='Archive task']");
|
||||||
|
const btn = document.createElement("button");
|
||||||
|
btn.type = "button";
|
||||||
|
btn.dataset.tdSidebarDelete = "1";
|
||||||
|
btn.className = ((archive && archive.className) || "") + " td-sidebar-delete-btn";
|
||||||
|
btn.setAttribute("aria-label", "彻底删除本地会话");
|
||||||
|
btn.title = "彻底删除本地会话";
|
||||||
|
btn.innerHTML = '<svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true"><path d="M6.25 5.75h7.5m-6.5 0 .45 9h4.6l.45-9M8.1 5.75V4.5h3.8v1.25M8.65 8v4.6M11.35 8v4.6" stroke="currentColor" stroke-width="1.35" stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
||||||
|
btn.addEventListener("pointerdown", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
});
|
||||||
|
btn.addEventListener("click", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
const threadId = sidebarThreadId(row);
|
||||||
|
if (!threadId) {
|
||||||
|
toast("未找到会话 ID,请从工具箱会话记录页删除");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sendDeleteRequest(threadId, null);
|
||||||
|
});
|
||||||
|
return btn;
|
||||||
|
}
|
||||||
|
// —— 侧栏行/归档按钮/操作容器 查找:多策略 + 调试日志 ——
|
||||||
|
// 行查找:优先 Codex 内部属性,退化到 aria-label/href/testid 关键字匹配。
|
||||||
|
function findSidebarRows(root) {
|
||||||
|
const scope = root || document;
|
||||||
|
const out = [];
|
||||||
|
const seen = new Set();
|
||||||
|
const push = (el) => { if (el && !seen.has(el)) { seen.add(el); out.push(el); } };
|
||||||
|
// 1) Codex 内部属性(最准)
|
||||||
|
try {
|
||||||
|
if (scope.nodeType === 1 && scope.matches && scope.matches("[data-app-action-sidebar-thread-row]")) push(scope);
|
||||||
|
if (scope.querySelectorAll) scope.querySelectorAll("[data-app-action-sidebar-thread-row]").forEach(push);
|
||||||
|
} catch (e) { console.log("[TD] findSidebarRows: attr strategy failed", e && e.message); }
|
||||||
|
if (out.length) { console.log("[TD] findSidebarRows: attr strategy hit", out.length); return out; }
|
||||||
|
// 2) aria-label 含 thread/conversation/任务/会话 的按钮或链接
|
||||||
|
try {
|
||||||
|
const ariaEls = scope.querySelectorAll("button[aria-label],a[aria-label],[role='button'][aria-label]");
|
||||||
|
const kwRe = /(thread|conversation|任务|会话)/i;
|
||||||
|
for (const el of ariaEls) {
|
||||||
|
const label = el.getAttribute("aria-label") || "";
|
||||||
|
if (kwRe.test(label)) push(el.closest("li,[data-app-action-sidebar-thread-row],div,section") || el.parentElement || el);
|
||||||
|
}
|
||||||
|
} catch (e) { console.log("[TD] findSidebarRows: aria strategy failed", e && e.message); }
|
||||||
|
if (out.length) { console.log("[TD] findSidebarRows: aria strategy hit", out.length); return out; }
|
||||||
|
// 3) href 含 UUID 的链接
|
||||||
|
try {
|
||||||
|
const hrefEls = scope.querySelectorAll("a[href]");
|
||||||
|
for (const el of hrefEls) {
|
||||||
|
const href = el.getAttribute("href") || "";
|
||||||
|
if (THREAD_ID_RE.test(href)) push(el.closest("li,[data-app-action-sidebar-thread-row],div,section") || el.parentElement || el);
|
||||||
|
}
|
||||||
|
} catch (e) { console.log("[TD] findSidebarRows: href strategy failed", e && e.message); }
|
||||||
|
if (out.length) { console.log("[TD] findSidebarRows: href strategy hit", out.length); return out; }
|
||||||
|
// 4) data-testid 含 thread/conversation
|
||||||
|
try {
|
||||||
|
const testEls = scope.querySelectorAll("[data-testid]");
|
||||||
|
const testRe = /(thread|conversation)/i;
|
||||||
|
for (const el of testEls) {
|
||||||
|
const tid = el.getAttribute("data-testid") || "";
|
||||||
|
if (testRe.test(tid)) push(el.closest("li,[data-app-action-sidebar-thread-row],div,section") || el.parentElement || el);
|
||||||
|
}
|
||||||
|
} catch (e) { console.log("[TD] findSidebarRows: testid strategy failed", e && e.message); }
|
||||||
|
if (out.length) console.log("[TD] findSidebarRows: testid strategy hit", out.length);
|
||||||
|
else console.log("[TD] findSidebarRows: no rows found via any strategy");
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
// 归档按钮查找:aria-label/title/textContent/SVG 多策略,大小写不敏感、部分匹配。
|
||||||
|
function findArchiveButton(row) {
|
||||||
|
if (!row) return null;
|
||||||
|
const kwRe = /(归档|archive)/i;
|
||||||
|
// 1) aria-label(大小写不敏感 + 部分匹配)
|
||||||
|
try {
|
||||||
|
const byAria = row.querySelector("button[aria-label],a[aria-label],[role='button'][aria-label]");
|
||||||
|
const list = byAria ? [byAria, ...row.querySelectorAll("button[aria-label],a[aria-label],[role='button'][aria-label]")] : row.querySelectorAll("button[aria-label],a[aria-label],[role='button'][aria-label]");
|
||||||
|
for (const el of list) {
|
||||||
|
const label = (el.getAttribute && el.getAttribute("aria-label")) || "";
|
||||||
|
if (kwRe.test(label)) { console.log("[TD] findArchiveButton: aria-label hit", label); return el; }
|
||||||
|
}
|
||||||
|
} catch (e) { console.log("[TD] findArchiveButton: aria strategy failed", e && e.message); }
|
||||||
|
// 2) title 属性
|
||||||
|
try {
|
||||||
|
for (const el of row.querySelectorAll("button[title],a[title],[role='button'][title]")) {
|
||||||
|
const title = (el.getAttribute && el.getAttribute("title")) || "";
|
||||||
|
if (kwRe.test(title)) { console.log("[TD] findArchiveButton: title hit", title); return el; }
|
||||||
|
}
|
||||||
|
} catch (e) { console.log("[TD] findArchiveButton: title strategy failed", e && e.message); }
|
||||||
|
// 3) textContent 匹配
|
||||||
|
try {
|
||||||
|
for (const el of row.querySelectorAll("button,a,[role='button']")) {
|
||||||
|
const text = String(el.textContent || "").trim();
|
||||||
|
if (text && kwRe.test(text)) { console.log("[TD] findArchiveButton: textContent hit", text); return el; }
|
||||||
|
}
|
||||||
|
} catch (e) { console.log("[TD] findArchiveButton: text strategy failed", e && e.message); }
|
||||||
|
// 4) SVG 图标启发:含 archive/pin 风格 path 的按钮
|
||||||
|
try {
|
||||||
|
const svgBtns = row.querySelectorAll("button:has(svg),a:has(svg),[role='button']:has(svg)");
|
||||||
|
for (const el of svgBtns) {
|
||||||
|
const svg = el.querySelector("svg");
|
||||||
|
if (!svg) continue;
|
||||||
|
const d = (svg.querySelector("path") && svg.querySelector("path").getAttribute("d")) || "";
|
||||||
|
// 归档盒常见:横向顶条 + 下方矩形
|
||||||
|
if (/m.*h.*v.*h.*z/i.test(d) && d.length > 30) {
|
||||||
|
console.log("[TD] findArchiveButton: svg heuristics hit");
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) { console.log("[TD] findArchiveButton: svg strategy failed", e && e.message); }
|
||||||
|
console.log("[TD] findArchiveButton: no archive button found");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// 操作容器查找:从归档按钮向上试多种深度,或直接定位行内操作组。
|
||||||
|
function findSidebarActionBox(row) {
|
||||||
|
const archive = findArchiveButton(row);
|
||||||
|
if (archive) {
|
||||||
|
// 试 parent.depth 1..4,找到含多个按钮的容器即视为 actionBox
|
||||||
|
let cur = archive.parentElement;
|
||||||
|
for (let depth = 1; cur && depth <= 4; depth++, cur = cur.parentElement) {
|
||||||
|
const btns = cur.querySelectorAll("button,[role='button']");
|
||||||
|
if (btns.length >= 2) {
|
||||||
|
console.log("[TD] findSidebarActionBox: via archive, depth", depth, "btns", btns.length);
|
||||||
|
return { actionBox: cur, anchor: archive.parentElement || archive };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 退而求其次:归档按钮的直接父级
|
||||||
|
console.log("[TD] findSidebarActionBox: fallback to archive.parentElement");
|
||||||
|
return { actionBox: archive.parentElement, anchor: archive };
|
||||||
|
}
|
||||||
|
// 不经归档按钮:直接找行内按钮组
|
||||||
|
try {
|
||||||
|
const groups = row.querySelectorAll("div:has(> button),span:has(> button)");
|
||||||
|
for (const g of groups) {
|
||||||
|
if (g.querySelectorAll("button,[role='button']").length >= 2) {
|
||||||
|
console.log("[TD] findSidebarActionBox: direct group hit");
|
||||||
|
return { actionBox: g, anchor: null };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) { console.log("[TD] findSidebarActionBox: direct group strategy failed", e && e.message); }
|
||||||
|
console.log("[TD] findSidebarActionBox: not found");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
function mountSidebarDeleteButtons(root = document) {
|
||||||
|
const rows = findSidebarRows(root);
|
||||||
|
console.log("[TD] mountSidebarDeleteButtons: rows", rows.length);
|
||||||
|
let mounted = 0, skipped = 0, failed = 0;
|
||||||
|
for (const row of rows) {
|
||||||
|
if (row.querySelector("[data-td-sidebar-delete]")) { skipped++; continue; }
|
||||||
|
const found = findSidebarActionBox(row);
|
||||||
|
if (!found || !found.actionBox) { console.log("[TD] mountSidebarDeleteButtons: no actionBox for row", row); failed++; continue; }
|
||||||
|
const { actionBox, anchor } = found;
|
||||||
|
try {
|
||||||
|
actionBox.style.width = "76px";
|
||||||
|
actionBox.insertBefore(makeSidebarDeleteButton(row, actionBox), anchor || actionBox.firstChild);
|
||||||
|
mounted++;
|
||||||
|
console.log("[TD] mountSidebarDeleteButtons: mounted on row", row);
|
||||||
|
} catch (e) {
|
||||||
|
console.log("[TD] mountSidebarDeleteButtons: insert failed", e && e.message);
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(\`[TD] mountSidebarDeleteButtons: done mounted=\${mounted} skipped=\${skipped} failed=\${failed}\`);
|
||||||
|
}
|
||||||
|
// —— 诊断:把侧边栏 DOM 结构通过 binding 回传主进程(无需 DevTools)——
|
||||||
|
function dumpSidebarInfo() {
|
||||||
|
if (typeof window.__voiceBridge !== "function") return;
|
||||||
|
const info = { sidebar: null, rows: [], archive: null, actionBox: null };
|
||||||
|
// 1) 侧边栏容器
|
||||||
|
const sidebarCandidates = document.querySelectorAll("[class*='sidebar'],[class*='thread-list'],[class*='conversation-list'],[class*='nav-list'],[class*='task-list']");
|
||||||
|
info.sidebar = sidebarCandidates.length;
|
||||||
|
// 2) 每行采样:取前 3 行看 HTML 结构
|
||||||
|
const allRows = document.querySelectorAll("a[href*='thread'],a[href*='conversation'],a[href*='/c/'],a[href*='/chat/'],[data-app-action-sidebar-thread-row],[class*='thread-row'],[class*='conversation-row'],[class*='session-row']");
|
||||||
|
if (allRows.length) {
|
||||||
|
for (let i = 0; i < Math.min(3, allRows.length); i++) {
|
||||||
|
const el = allRows[i];
|
||||||
|
info.rows.push({
|
||||||
|
tag: el.tagName,
|
||||||
|
id: el.id,
|
||||||
|
classes: (el.className || "").slice(0, 120),
|
||||||
|
attrs: [].map.call(el.attributes || [], function(a) { return a.name + '=\\"' + a.value.slice(0, 60) + '\\"'; }).join(" ").slice(0, 300),
|
||||||
|
inner: el.innerHTML.replace(/\\s+/g, " ").slice(0, 400),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 3) 归档按钮
|
||||||
|
const archiveBtn = document.querySelector("button[aria-label*='归档'],button[aria-label*='Archive'],button[aria-label*='archive'],button[aria-label*='archiv'],button[title*='归档'],button[title*='Archive'],button[title*='archive']");
|
||||||
|
if (archiveBtn) {
|
||||||
|
info.archive = {
|
||||||
|
tag: archiveBtn.tagName,
|
||||||
|
id: archiveBtn.id,
|
||||||
|
ariaLabel: archiveBtn.getAttribute("aria-label") || "",
|
||||||
|
title: archiveBtn.title,
|
||||||
|
classes: (archiveBtn.className || "").slice(0, 120),
|
||||||
|
outer: archiveBtn.outerHTML.slice(0, 300),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// 4) 侧边栏内所有按钮
|
||||||
|
info.allButtons = [].map.call(document.querySelectorAll("[class*='sidebar'] button,[class*='thread-list'] button"), function(b) {
|
||||||
|
return { ariaLabel: b.getAttribute("aria-label") || "", title: b.title, classes: (b.className || "").slice(0, 60) };
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
window.__voiceBridge(JSON.stringify({ kind: "debug-sidebar-dump", info: info }));
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
window.__tdDumpSidebarInfo = dumpSidebarInfo;
|
||||||
|
// 周期性重试:侧边栏可能是懒加载的,首次注入时 DOM 可能还没渲染出来
|
||||||
|
let retryCount = 0;
|
||||||
|
const MAX_RETRIES = 10;
|
||||||
|
function retryMountSidebar() {
|
||||||
|
if (retryCount >= MAX_RETRIES) return;
|
||||||
|
retryCount++;
|
||||||
|
setTimeout(function() {
|
||||||
|
const rows = findSidebarRows(document);
|
||||||
|
if (rows.length > 0) {
|
||||||
|
mountSidebarDeleteButtons(document);
|
||||||
|
dumpSidebarInfo();
|
||||||
|
} else {
|
||||||
|
console.log("[TD] retryMountSidebar: attempt " + retryCount + " still no rows, retrying...");
|
||||||
|
retryMountSidebar();
|
||||||
|
}
|
||||||
|
}, 1500);
|
||||||
|
}
|
||||||
|
setTimeout(retryMountSidebar, 2000);
|
||||||
|
|
||||||
// —— binding 通道(window.__voiceBridge 由主进程 CDP addBinding 注入)——
|
// —— binding 通道(window.__voiceBridge 由主进程 CDP addBinding 注入)——
|
||||||
const pending = new Map();
|
const pending = new Map();
|
||||||
let seq = 0;
|
let seq = 0;
|
||||||
@@ -126,6 +543,7 @@ const INJECT_SCRIPT = `
|
|||||||
|
|
||||||
// —— 录音 ——
|
// —— 录音 ——
|
||||||
let mediaRecorder = null;
|
let mediaRecorder = null;
|
||||||
|
let activeStream = null;
|
||||||
let chunks = [];
|
let chunks = [];
|
||||||
let curBtn = null;
|
let curBtn = null;
|
||||||
let stopTimer = null;
|
let stopTimer = null;
|
||||||
@@ -150,6 +568,7 @@ const INJECT_SCRIPT = `
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function startRecording(btn) {
|
async function startRecording(btn) {
|
||||||
|
if (disposed) return;
|
||||||
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
||||||
toast("此环境不支持麦克风");
|
toast("此环境不支持麦克风");
|
||||||
setBtnState(btn, "error");
|
setBtnState(btn, "error");
|
||||||
@@ -169,6 +588,7 @@ const INJECT_SCRIPT = `
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
curBtn = btn;
|
curBtn = btn;
|
||||||
|
activeStream = stream;
|
||||||
chunks = [];
|
chunks = [];
|
||||||
const mime = MediaRecorder.isTypeSupported("audio/webm")
|
const mime = MediaRecorder.isTypeSupported("audio/webm")
|
||||||
? "audio/webm"
|
? "audio/webm"
|
||||||
@@ -188,6 +608,8 @@ const INJECT_SCRIPT = `
|
|||||||
};
|
};
|
||||||
mediaRecorder.onstop = () => {
|
mediaRecorder.onstop = () => {
|
||||||
stream.getTracks().forEach((t) => t.stop());
|
stream.getTracks().forEach((t) => t.stop());
|
||||||
|
activeStream = null;
|
||||||
|
if (disposed) return;
|
||||||
const blob = new Blob(chunks, { type: mime || "audio/webm" });
|
const blob = new Blob(chunks, { type: mime || "audio/webm" });
|
||||||
if (blob.size < 800) {
|
if (blob.size < 800) {
|
||||||
toast("未录到声音");
|
toast("未录到声音");
|
||||||
@@ -197,6 +619,7 @@ const INJECT_SCRIPT = `
|
|||||||
setBtnState(btn, "transcribing");
|
setBtnState(btn, "transcribing");
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onload = async () => {
|
reader.onload = async () => {
|
||||||
|
if (disposed) return;
|
||||||
const dataUrl = String(reader.result || "");
|
const dataUrl = String(reader.result || "");
|
||||||
const comma = dataUrl.indexOf(",");
|
const comma = dataUrl.indexOf(",");
|
||||||
const b64 = comma >= 0 ? dataUrl.slice(comma + 1) : dataUrl;
|
const b64 = comma >= 0 ? dataUrl.slice(comma + 1) : dataUrl;
|
||||||
@@ -212,6 +635,7 @@ const INJECT_SCRIPT = `
|
|||||||
setBtnState(btn, "");
|
setBtnState(btn, "");
|
||||||
};
|
};
|
||||||
reader.onerror = () => {
|
reader.onerror = () => {
|
||||||
|
if (disposed) return;
|
||||||
toast("音频读取失败");
|
toast("音频读取失败");
|
||||||
setBtnState(btn, "error");
|
setBtnState(btn, "error");
|
||||||
};
|
};
|
||||||
@@ -270,6 +694,7 @@ const INJECT_SCRIPT = `
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onBtnClick(btn) {
|
function onBtnClick(btn) {
|
||||||
|
if (disposed) return;
|
||||||
if (mediaRecorder && mediaRecorder.state === "recording") {
|
if (mediaRecorder && mediaRecorder.state === "recording") {
|
||||||
stopRecording();
|
stopRecording();
|
||||||
return;
|
return;
|
||||||
@@ -307,6 +732,7 @@ const INJECT_SCRIPT = `
|
|||||||
|
|
||||||
let firstMount = true;
|
let firstMount = true;
|
||||||
function tryMount() {
|
function tryMount() {
|
||||||
|
if (disposed) return;
|
||||||
if (!firstMount && document.getElementById(BTN_ID)) return; // 已在,避免频繁重建丢焦点
|
if (!firstMount && document.getElementById(BTN_ID)) return; // 已在,避免频繁重建丢焦点
|
||||||
firstMount = false;
|
firstMount = false;
|
||||||
const existing = document.getElementById(BTN_ID);
|
const existing = document.getElementById(BTN_ID);
|
||||||
@@ -326,23 +752,66 @@ const INJECT_SCRIPT = `
|
|||||||
// —— MutationObserver:composer/页面重建后重挂 ——
|
// —— MutationObserver:composer/页面重建后重挂 ——
|
||||||
let mo = null;
|
let mo = null;
|
||||||
function startObserver() {
|
function startObserver() {
|
||||||
if (mo) return;
|
if (disposed || mo) return;
|
||||||
mo = new MutationObserver(() => {
|
mo = new MutationObserver((records) => {
|
||||||
|
if (disposed) return;
|
||||||
if (!document.getElementById(BTN_ID)) { firstMount = true; tryMount(); }
|
if (!document.getElementById(BTN_ID)) { firstMount = true; tryMount(); }
|
||||||
|
for (const record of records) {
|
||||||
|
if (record.target && record.target.nodeType === 1) sanitizeVisibleThoughts(record.target);
|
||||||
|
for (const added of record.addedNodes || []) {
|
||||||
|
if (added.nodeType !== 1) continue;
|
||||||
|
sanitizeVisibleThoughts(added);
|
||||||
|
mountSidebarDeleteButtons(added);
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
window.__voiceInjectObserver = mo; // 供重新安装时 disconnect
|
window.__voiceInjectObserver = mo; // 供重新安装时 disconnect
|
||||||
const root = document.body || document.documentElement;
|
const root = document.body || document.documentElement;
|
||||||
if (root) mo.observe(root, { childList: true, subtree: true });
|
if (root) mo.observe(root, { childList: true, subtree: true });
|
||||||
// 首次尝试
|
// 首次尝试
|
||||||
tryMount();
|
tryMount();
|
||||||
|
mountSidebarDeleteButtons(root);
|
||||||
|
sanitizeVisibleThoughts(root);
|
||||||
}
|
}
|
||||||
function ensureStarted() {
|
function ensureStarted() {
|
||||||
|
if (disposed) return;
|
||||||
if (document.readyState === "loading") {
|
if (document.readyState === "loading") {
|
||||||
document.addEventListener("DOMContentLoaded", startObserver, { once: true });
|
document.addEventListener("DOMContentLoaded", startObserver, { once: true });
|
||||||
} else {
|
} else {
|
||||||
startObserver();
|
startObserver();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 由主进程在“关闭语音输入”时调用。必须在断开 CDP 前执行,
|
||||||
|
// 否则页面里的 observer 仍会在输入框重建时重新挂出麦克风按钮。
|
||||||
|
window.__voiceInjectDispose = () => {
|
||||||
|
if (disposed) return;
|
||||||
|
disposed = true;
|
||||||
|
if (stopTimer) {
|
||||||
|
clearTimeout(stopTimer);
|
||||||
|
stopTimer = null;
|
||||||
|
}
|
||||||
|
if (mediaRecorder && mediaRecorder.state !== "inactive") {
|
||||||
|
try { mediaRecorder.stop(); } catch {}
|
||||||
|
}
|
||||||
|
if (activeStream) {
|
||||||
|
try { activeStream.getTracks().forEach((track) => track.stop()); } catch {}
|
||||||
|
activeStream = null;
|
||||||
|
}
|
||||||
|
for (const entry of pending.values()) {
|
||||||
|
try { entry.reject(new Error("语音输入已关闭")); } catch {}
|
||||||
|
}
|
||||||
|
pending.clear();
|
||||||
|
if (mo) {
|
||||||
|
try { mo.disconnect(); } catch {}
|
||||||
|
mo = null;
|
||||||
|
}
|
||||||
|
if (window.__voiceInjectObserver) delete window.__voiceInjectObserver;
|
||||||
|
document.getElementById(BTN_ID)?.remove();
|
||||||
|
document.getElementById(STYLE_ID)?.remove();
|
||||||
|
document.querySelectorAll(".codex-voice-toast").forEach((el) => el.remove());
|
||||||
|
if (window.__voiceInjectHandle) delete window.__voiceInjectHandle;
|
||||||
|
if (window.__voiceInjectInstalled) delete window.__voiceInjectInstalled;
|
||||||
|
};
|
||||||
ensureStarted();
|
ensureStarted();
|
||||||
})();
|
})();
|
||||||
`;
|
`;
|
||||||
|
|||||||
+514
-34
@@ -5,6 +5,8 @@ const { autoUpdater } = require("electron-updater");
|
|||||||
const path = require("path");
|
const path = require("path");
|
||||||
const os = require("os");
|
const os = require("os");
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
|
const crypto = require("crypto");
|
||||||
|
const { execFile } = require("child_process");
|
||||||
const git = require("isomorphic-git");
|
const git = require("isomorphic-git");
|
||||||
const gitHttp = require("isomorphic-git/http/node");
|
const gitHttp = require("isomorphic-git/http/node");
|
||||||
|
|
||||||
@@ -12,13 +14,16 @@ const codex = require("./lib/codex");
|
|||||||
const purge = require("./lib/purge");
|
const purge = require("./lib/purge");
|
||||||
const secrets = require("./lib/secrets");
|
const secrets = require("./lib/secrets");
|
||||||
const sessions = require("./lib/sessions");
|
const sessions = require("./lib/sessions");
|
||||||
|
const codexDataDir = require("./lib/codex_data_dir");
|
||||||
const providerSync = require("./lib/provider_sync");
|
const providerSync = require("./lib/provider_sync");
|
||||||
const router = require("./lib/router");
|
const router = require("./lib/router");
|
||||||
const canvas = require("./lib/canvas");
|
const canvas = require("./lib/canvas");
|
||||||
|
const canvasStore = require("./lib/canvas_store");
|
||||||
const team = require("./lib/team");
|
const team = require("./lib/team");
|
||||||
const promptAnalysis = require("./lib/prompt-analysis");
|
const promptAnalysis = require("./lib/prompt-analysis");
|
||||||
const claudedesk = require("./lib/claudedesk");
|
const claudedesk = require("./lib/claudedesk");
|
||||||
const voice = require("./lib/voice_cdp");
|
const voice = require("./lib/voice_cdp");
|
||||||
|
const codexDesktop = require("./lib/codex_desktop");
|
||||||
const tokenWatch = require("./lib/token_watch");
|
const tokenWatch = require("./lib/token_watch");
|
||||||
const proxy = require("./lib/proxy");
|
const proxy = require("./lib/proxy");
|
||||||
const memory = require("./lib/memory");
|
const memory = require("./lib/memory");
|
||||||
@@ -87,6 +92,15 @@ let tray = null;
|
|||||||
let isQuitting = false;
|
let isQuitting = false;
|
||||||
let trayHinted = false; // Windows:首次最小化到托盘时弹一次气泡提示
|
let trayHinted = false; // Windows:首次最小化到托盘时弹一次气泡提示
|
||||||
let lastPull = null; // { base, items } —— gh-restore 用上次 pull 的结果
|
let lastPull = null; // { base, items } —— gh-restore 用上次 pull 的结果
|
||||||
|
let voiceSessionDeleteBusy = false;
|
||||||
|
let stateWriteOperation = "";
|
||||||
|
const sessionDeleteClipboard = new Map();
|
||||||
|
|
||||||
|
async function withStateWriteLock(label, fn) {
|
||||||
|
if (stateWriteOperation) throw new Error(`正在${stateWriteOperation},请完成后再${label}`);
|
||||||
|
stateWriteOperation = label;
|
||||||
|
try { return await fn(); } finally { stateWriteOperation = ""; }
|
||||||
|
}
|
||||||
|
|
||||||
// —— 日志:主进程 → 前端 api.onLog ——
|
// —— 日志:主进程 → 前端 api.onLog ——
|
||||||
function log(msg) {
|
function log(msg) {
|
||||||
@@ -95,6 +109,24 @@ function log(msg) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function deferStartupWork(label, delayMs, fn) {
|
||||||
|
setTimeout(() => {
|
||||||
|
const t0 = Date.now();
|
||||||
|
try {
|
||||||
|
const r = fn();
|
||||||
|
const done = (out) => {
|
||||||
|
const ms = Date.now() - t0;
|
||||||
|
if (ms > 300) log(`${label}完成:${ms}ms`);
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
if (r && typeof r.then === "function") r.then(done).catch((e) => log(`${label}失败:${e.message || e}`));
|
||||||
|
else done(r);
|
||||||
|
} catch (e) {
|
||||||
|
log(`${label}异常:${e.message || e}`);
|
||||||
|
}
|
||||||
|
}, delayMs);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// 窗口
|
// 窗口
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -149,14 +181,19 @@ function showWindow() {
|
|||||||
function rebuildTrayMenu() {
|
function rebuildTrayMenu() {
|
||||||
if (!tray) return;
|
if (!tray) return;
|
||||||
const st = router.status();
|
const st = router.status();
|
||||||
const groupItems = st.groups.map((g) => ({
|
const groupItems = st.groups.map((g) => {
|
||||||
label: (g.id === st.activeId ? "● " : "○ ") + g.name,
|
const toolOnly = !g.isDefault && (g.codexImage || g.codexVideo || g.stt);
|
||||||
|
return {
|
||||||
|
label: (g.id === st.activeId ? "● " : "○ ") + g.name + (toolOnly ? "(插件专用)" : ""),
|
||||||
|
enabled: !toolOnly,
|
||||||
click: () => {
|
click: () => {
|
||||||
|
if (toolOnly) return;
|
||||||
router.setActive(g.id);
|
router.setActive(g.id);
|
||||||
rebuildTrayMenu();
|
rebuildTrayMenu();
|
||||||
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send("router-changed", router.status());
|
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send("router-changed", router.status());
|
||||||
},
|
},
|
||||||
}));
|
};
|
||||||
|
});
|
||||||
const px = proxy.status();
|
const px = proxy.status();
|
||||||
const pxLabel =
|
const pxLabel =
|
||||||
px.mode === "global" ? "全局代理:运行中(整机 TUN)" : px.mode === "system" ? "系统代理:运行中" : "代理:未开启";
|
px.mode === "global" ? "全局代理:运行中(整机 TUN)" : px.mode === "system" ? "系统代理:运行中" : "代理:未开启";
|
||||||
@@ -200,6 +237,8 @@ if (!app.requestSingleInstanceLock()) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
app.whenReady().then(async () => {
|
app.whenReady().then(async () => {
|
||||||
|
providerSync.setCodexDataDir(codexDataDir.get());
|
||||||
|
router.setCodexDataDir(codexDataDir.get());
|
||||||
createWindow();
|
createWindow();
|
||||||
setupTray();
|
setupTray();
|
||||||
app.on("activate", () => showWindow());
|
app.on("activate", () => showWindow());
|
||||||
@@ -209,13 +248,14 @@ app.whenReady().then(async () => {
|
|||||||
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send("proxy-changed", st);
|
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send("proxy-changed", st);
|
||||||
rebuildTrayMenu();
|
rebuildTrayMenu();
|
||||||
});
|
});
|
||||||
proxy.cleanupStale().catch(() => {});
|
deferStartupWork("启动清理代理残留", 15000, () => proxy.cleanupStale());
|
||||||
// 会话记忆:后台监视 ~/.codex/sessions,会话静默后自动摘要入库 + 写 HANDOFF.md
|
// 会话记忆:后台监视 ~/.codex/sessions,会话静默后自动摘要入库 + 写 HANDOFF.md
|
||||||
memory.start({
|
memory.start({
|
||||||
log,
|
log,
|
||||||
notify: () => {
|
notify: () => {
|
||||||
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send("memory-changed");
|
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send("memory-changed");
|
||||||
},
|
},
|
||||||
|
startupScan: false,
|
||||||
});
|
});
|
||||||
// 启动时不再自动接管/启动本地路由:用户可能只想用其它功能。
|
// 启动时不再自动接管/启动本地路由:用户可能只想用其它功能。
|
||||||
// 保存分组已直接写 Codex 直连配置;只有用户显式「启用本地路由」才 start() 路由进程。
|
// 保存分组已直接写 Codex 直连配置;只有用户显式「启用本地路由」才 start() 路由进程。
|
||||||
@@ -225,34 +265,26 @@ app.whenReady().then(async () => {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
log(`刷新托盘失败:${e.message || e}`);
|
log(`刷新托盘失败:${e.message || e}`);
|
||||||
}
|
}
|
||||||
try {
|
deferStartupWork("启动迁移 skill", 60000, () => {
|
||||||
const r = migrateLegacySkills();
|
const r = migrateLegacySkills();
|
||||||
if (r.migrated.length) log(`启动迁移 skill: ${r.migrated.length} 个转为软链`);
|
if (r.migrated.length) log(`启动迁移 skill: ${r.migrated.length} 个转为软链`);
|
||||||
if (r.errors.length) log(`启动迁移 skill 错误: ${JSON.stringify(r.errors)}`);
|
if (r.errors.length) log(`启动迁移 skill 错误: ${JSON.stringify(r.errors)}`);
|
||||||
} catch (e) {
|
});
|
||||||
log(`启动迁移 skill 异常: ${e.message || e}`);
|
deferStartupWork("启动迁移 plugin", 75000, () => {
|
||||||
}
|
|
||||||
try {
|
|
||||||
const r = migrateLegacyPlugins();
|
const r = migrateLegacyPlugins();
|
||||||
if (r.migrated.length) log(`启动迁移 plugin: ${r.migrated.length} 个转为软链`);
|
if (r.migrated.length) log(`启动迁移 plugin: ${r.migrated.length} 个转为软链`);
|
||||||
if (r.errors.length) log(`启动迁移 plugin 错误: ${JSON.stringify(r.errors)}`);
|
if (r.errors.length) log(`启动迁移 plugin 错误: ${JSON.stringify(r.errors)}`);
|
||||||
} catch (e) {
|
});
|
||||||
log(`启动迁移 plugin 异常: ${e.message || e}`);
|
|
||||||
}
|
|
||||||
// 启动自愈:把当前激活分组的全部文本模型先回填进 Codex 模型目录。
|
// 启动自愈:把当前激活分组的全部文本模型先回填进 Codex 模型目录。
|
||||||
// 目的——用户在 Codex 本体的模型选择器里切模型(不经本 App)时,若目标模型不在目录里,
|
// 目的——用户在 Codex 本体的模型选择器里切模型(不经本 App)时,若目标模型不在目录里,
|
||||||
// Codex 会以未知模型启动、侧栏历史还原失败 = "切模型后会话记录消失"。先回填即根治,
|
// Codex 会以未知模型启动、侧栏历史还原失败 = "切模型后会话记录消失"。先回填即根治,
|
||||||
// 且既有安装无需重新保存分组即可自动修复。异步、失败不动目录(离线安全)。
|
// 且既有安装无需重新保存分组即可自动修复。异步、失败不动目录(离线安全)。
|
||||||
try {
|
deferStartupWork("启动同步模型目录", 30000, async () => {
|
||||||
const active = router.llmGroup(); // 当前激活且配齐 baseUrl+key+model 的中转分组(排除默认直连)
|
const active = router.llmGroup(); // 当前激活且配齐 baseUrl+key+model 的中转分组(排除默认直连)
|
||||||
if (active) {
|
if (!active) return;
|
||||||
router.syncCatalogFromProvider(active)
|
const r = await router.syncCatalogFromProvider(active);
|
||||||
.then((r) => { if (r && r.ok && (r.added || r.revealed)) log(`启动同步模型目录:新增 ${r.added} · 显示 ${r.revealed} · 共 ${r.total}`); })
|
if (r && r.ok && (r.added || r.revealed)) log(`启动同步模型目录:新增 ${r.added} · 显示 ${r.revealed} · 共 ${r.total}`);
|
||||||
.catch((e) => log(`启动同步模型目录失败:${e.message || e}`));
|
});
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
log(`启动同步模型目录异常:${e.message || e}`);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 统一退出:清理代理(杀 sing-box + 还原网络) → 停本地路由 → 销毁托盘 → 退出。
|
// 统一退出:清理代理(杀 sing-box + 还原网络) → 停本地路由 → 销毁托盘 → 退出。
|
||||||
@@ -303,6 +335,274 @@ const UPDATE_REPO = "codex-market-app";
|
|||||||
// up 域名给 API/直连,update 域名给用户浏览下载页(管理登录会话在此)。
|
// up 域名给 API/直连,update 域名给用户浏览下载页(管理登录会话在此)。
|
||||||
const GITEA_RELEASES_API = `https://update.tokendun.cc/api/v1/repos/${UPDATE_OWNER}/${UPDATE_REPO}/releases/latest`;
|
const GITEA_RELEASES_API = `https://update.tokendun.cc/api/v1/repos/${UPDATE_OWNER}/${UPDATE_REPO}/releases/latest`;
|
||||||
const GITEA_RELEASES_PAGE = `https://update.tokendun.cc/${UPDATE_OWNER}/${UPDATE_REPO}/releases`;
|
const GITEA_RELEASES_PAGE = `https://update.tokendun.cc/${UPDATE_OWNER}/${UPDATE_REPO}/releases`;
|
||||||
|
const CODEX_INSTALLER_RELEASE_TAG = "codex-installers";
|
||||||
|
const CODEX_WINDOWS_MSIX_NAME = "Codex-Windows-x64.msix";
|
||||||
|
const CODEX_MAC_DMG_NAME = "ChatGPT.dmg";
|
||||||
|
const CODEX_WINDOWS_MSIX_URL =
|
||||||
|
`https://update.tokendun.cc/${UPDATE_OWNER}/${UPDATE_REPO}/releases/download/${CODEX_INSTALLER_RELEASE_TAG}/${CODEX_WINDOWS_MSIX_NAME}`;
|
||||||
|
const CODEX_WINDOWS_MSIX_SHA256_URL = `${CODEX_WINDOWS_MSIX_URL}.sha256`;
|
||||||
|
const CODEX_MAC_DMG_URL =
|
||||||
|
`https://update.tokendun.cc/${UPDATE_OWNER}/${UPDATE_REPO}/releases/download/${CODEX_INSTALLER_RELEASE_TAG}/${CODEX_MAC_DMG_NAME}`;
|
||||||
|
const CODEX_MAC_DMG_SHA256_URL = `${CODEX_MAC_DMG_URL}.sha256`;
|
||||||
|
const codexInstallerDownloadControllers = new Map();
|
||||||
|
|
||||||
|
function codexInstallerTarget(platform) {
|
||||||
|
if (platform === "windows") {
|
||||||
|
return {
|
||||||
|
platform,
|
||||||
|
fileName: CODEX_WINDOWS_MSIX_NAME,
|
||||||
|
url: CODEX_WINDOWS_MSIX_URL,
|
||||||
|
sha256Url: CODEX_WINDOWS_MSIX_SHA256_URL,
|
||||||
|
label: "Codex Windows 版",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (platform === "mac") {
|
||||||
|
return {
|
||||||
|
platform,
|
||||||
|
fileName: CODEX_MAC_DMG_NAME,
|
||||||
|
url: CODEX_MAC_DMG_URL,
|
||||||
|
sha256Url: CODEX_MAC_DMG_SHA256_URL,
|
||||||
|
label: "Codex Mac 版",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
throw new Error("未知安装平台");
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendCodexInstallerProgress(data) {
|
||||||
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||||
|
mainWindow.webContents.send("codex-installer-progress", data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function codexInstallerCachePath(fileName) {
|
||||||
|
return path.join(app.getPath("userData"), "installers", fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAbortError(e) {
|
||||||
|
return (e && e.name === "AbortError") || /aborted|abort/i.test(String(e && e.message ? e.message : e));
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertCodexInstallerNotCanceled(signal) {
|
||||||
|
if (signal && signal.aborted) {
|
||||||
|
const err = new Error("已取消下载");
|
||||||
|
err.name = "AbortError";
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSha256(text) {
|
||||||
|
const m = String(text || "").match(/\b[a-fA-F0-9]{64}\b/);
|
||||||
|
return m ? m[0].toLowerCase() : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function hashFile(file) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const h = crypto.createHash("sha256");
|
||||||
|
fs.createReadStream(file)
|
||||||
|
.on("data", (chunk) => h.update(chunk))
|
||||||
|
.on("error", reject)
|
||||||
|
.on("end", () => resolve(h.digest("hex")));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchInstallerSha256(sha256Url, signal) {
|
||||||
|
const res = await fetch(sha256Url, {
|
||||||
|
headers: { "User-Agent": "codex-market", "Cache-Control": "no-cache" },
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`安装包校验文件未发布或不可访问:HTTP ${res.status}`);
|
||||||
|
const sha256 = parseSha256(await res.text());
|
||||||
|
if (!sha256) throw new Error("安装包校验文件格式不正确");
|
||||||
|
return sha256;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadCodexInstaller({ fileName, url, expectedSha256, platform, signal }) {
|
||||||
|
const installerPath = codexInstallerCachePath(fileName);
|
||||||
|
const tmpPath = `${installerPath}.download`;
|
||||||
|
fs.mkdirSync(path.dirname(installerPath), { recursive: true });
|
||||||
|
assertCodexInstallerNotCanceled(signal);
|
||||||
|
|
||||||
|
if (fs.existsSync(installerPath)) {
|
||||||
|
sendCodexInstallerProgress({ platform, phase: "verify", message: "校验本地缓存…", percent: 60 });
|
||||||
|
assertCodexInstallerNotCanceled(signal);
|
||||||
|
const cachedSha256 = await hashFile(installerPath);
|
||||||
|
assertCodexInstallerNotCanceled(signal);
|
||||||
|
if (cachedSha256 === expectedSha256) {
|
||||||
|
sendCodexInstallerProgress({ platform, phase: "cached", message: "已使用本地缓存", percent: 62 });
|
||||||
|
return installerPath;
|
||||||
|
}
|
||||||
|
try { fs.rmSync(installerPath, { force: true }); } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
sendCodexInstallerProgress({ platform, phase: "download", message: "开始下载安装包…", downloaded: 0, total: 0, percent: 0 });
|
||||||
|
const res = await fetch(url, { headers: { "User-Agent": "codex-market" }, signal });
|
||||||
|
if (!res.ok) throw new Error(`安装包下载失败:HTTP ${res.status}`);
|
||||||
|
if (!res.body || typeof res.body.getReader !== "function") throw new Error("当前运行环境不支持流式下载");
|
||||||
|
|
||||||
|
const total = Number(res.headers.get("content-length") || 0);
|
||||||
|
const reader = res.body.getReader();
|
||||||
|
const out = fs.createWriteStream(tmpPath);
|
||||||
|
const streamError = new Promise((_, reject) => out.once("error", reject));
|
||||||
|
let downloaded = 0;
|
||||||
|
let lastEmit = 0;
|
||||||
|
try {
|
||||||
|
for (;;) {
|
||||||
|
assertCodexInstallerNotCanceled(signal);
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
const chunk = Buffer.from(value);
|
||||||
|
downloaded += chunk.length;
|
||||||
|
if (!out.write(chunk)) await Promise.race([streamError, new Promise((resolve) => out.once("drain", resolve))]);
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastEmit > 300) {
|
||||||
|
lastEmit = now;
|
||||||
|
sendCodexInstallerProgress({
|
||||||
|
platform,
|
||||||
|
phase: "download",
|
||||||
|
downloaded,
|
||||||
|
total,
|
||||||
|
percent: total ? Math.round((downloaded / total) * 100) : 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await Promise.race([streamError, new Promise((resolve) => out.end(resolve))]);
|
||||||
|
} catch (e) {
|
||||||
|
try { out.destroy(); } catch {}
|
||||||
|
try { fs.rmSync(tmpPath, { force: true }); } catch {}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
sendCodexInstallerProgress({ platform, phase: "verify", message: "校验安装包…", percent: 60 });
|
||||||
|
assertCodexInstallerNotCanceled(signal);
|
||||||
|
const sha256 = await hashFile(tmpPath);
|
||||||
|
assertCodexInstallerNotCanceled(signal);
|
||||||
|
if (sha256 !== expectedSha256) {
|
||||||
|
try { fs.rmSync(tmpPath, { force: true }); } catch {}
|
||||||
|
throw new Error("安装包 SHA256 校验失败,已停止安装");
|
||||||
|
}
|
||||||
|
fs.renameSync(tmpPath, installerPath);
|
||||||
|
return installerPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadCodexInstallerForPlatform(platform) {
|
||||||
|
const target = codexInstallerTarget(platform);
|
||||||
|
if (codexInstallerDownloadControllers.has(platform)) throw new Error(`${target.label}安装包正在下载中`);
|
||||||
|
const controller = new AbortController();
|
||||||
|
codexInstallerDownloadControllers.set(platform, controller);
|
||||||
|
try {
|
||||||
|
sendCodexInstallerProgress({ platform, phase: "checking", message: "读取安装包校验信息…", percent: 2 });
|
||||||
|
const expectedSha256 = await fetchInstallerSha256(target.sha256Url, controller.signal);
|
||||||
|
const installerPath = await downloadCodexInstaller({
|
||||||
|
fileName: target.fileName,
|
||||||
|
url: target.url,
|
||||||
|
expectedSha256,
|
||||||
|
platform,
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
sendCodexInstallerProgress({ platform, phase: "downloaded", message: `${target.label}安装包已下载并校验完成`, percent: 100 });
|
||||||
|
return { ok: true, platform, path: installerPath };
|
||||||
|
} catch (e) {
|
||||||
|
if (isAbortError(e)) {
|
||||||
|
try { fs.rmSync(`${codexInstallerCachePath(target.fileName)}.download`, { force: true }); } catch {}
|
||||||
|
sendCodexInstallerProgress({ platform, phase: "canceled", message: `${target.label}安装包下载已取消`, percent: 0 });
|
||||||
|
throw new Error("已取消下载");
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
if (codexInstallerDownloadControllers.get(platform) === controller) {
|
||||||
|
codexInstallerDownloadControllers.delete(platform);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelCodexInstallerDownload(platform) {
|
||||||
|
const target = codexInstallerTarget(platform);
|
||||||
|
const controller = codexInstallerDownloadControllers.get(platform);
|
||||||
|
if (!controller) return { ok: true, canceled: false };
|
||||||
|
controller.abort();
|
||||||
|
sendCodexInstallerProgress({ platform, phase: "canceling", message: `正在取消${target.label}安装包下载…`, percent: 0 });
|
||||||
|
return { ok: true, canceled: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
function execFileAsync(file, args, opts = {}) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
execFile(file, args, opts, (err, stdout, stderr) => {
|
||||||
|
if (!err) return resolve({ stdout, stderr });
|
||||||
|
const msg = String(stderr || stdout || err.message || err).trim();
|
||||||
|
reject(new Error(msg || `${path.basename(file)} 执行失败`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function runCodexWindowsMsix(installerPath) {
|
||||||
|
const systemRoot = process.env.SystemRoot || "C:\\Windows";
|
||||||
|
const bundledPowerShell = path.join(systemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
|
||||||
|
const powerShell = fs.existsSync(bundledPowerShell) ? bundledPowerShell : "powershell.exe";
|
||||||
|
const args = [
|
||||||
|
"-NoProfile",
|
||||||
|
"-ExecutionPolicy", "Bypass",
|
||||||
|
"-Command",
|
||||||
|
"& { param([string]$pkg) Add-AppxPackage -Path $pkg }",
|
||||||
|
installerPath,
|
||||||
|
];
|
||||||
|
return execFileAsync(powerShell, args, { windowsHide: false, timeout: 15 * 60 * 1000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function installCodexWindows() {
|
||||||
|
if (process.platform !== "win32") {
|
||||||
|
throw new Error("Windows 一键安装只能在 Windows 上运行;可先在软件内下载 MSIX,再发给 Windows 用户安装。");
|
||||||
|
}
|
||||||
|
const { path: installerPath } = await downloadCodexInstallerForPlatform("windows");
|
||||||
|
sendCodexInstallerProgress({ platform: "windows", phase: "install", message: "正在调用 PowerShell 安装…", percent: 85 });
|
||||||
|
await runCodexWindowsMsix(installerPath);
|
||||||
|
sendCodexInstallerProgress({ platform: "windows", phase: "done", message: "Codex Windows 版安装完成", percent: 100 });
|
||||||
|
return { ok: true, path: installerPath };
|
||||||
|
}
|
||||||
|
|
||||||
|
function findMountedAppBundle(mountDir) {
|
||||||
|
const items = fs.readdirSync(mountDir, { withFileTypes: true });
|
||||||
|
const appItem = items.find((ent) => ent.isDirectory() && ent.name.endsWith(".app"));
|
||||||
|
if (!appItem) throw new Error("DMG 中没有找到 .app 应用");
|
||||||
|
return path.join(mountDir, appItem.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runCodexMacDmg(dmgPath) {
|
||||||
|
const mountDir = path.join(app.getPath("temp"), `codex-installer-${Date.now()}`);
|
||||||
|
fs.rmSync(mountDir, { recursive: true, force: true });
|
||||||
|
fs.mkdirSync(mountDir, { recursive: true });
|
||||||
|
let mounted = false;
|
||||||
|
try {
|
||||||
|
sendCodexInstallerProgress({ platform: "mac", phase: "install", message: "正在挂载 DMG…", percent: 68 });
|
||||||
|
await execFileAsync("/usr/bin/hdiutil", ["attach", dmgPath, "-nobrowse", "-readonly", "-mountpoint", mountDir], { timeout: 5 * 60 * 1000 });
|
||||||
|
mounted = true;
|
||||||
|
|
||||||
|
const appPath = findMountedAppBundle(mountDir);
|
||||||
|
const targetPath = path.join("/Applications", path.basename(appPath));
|
||||||
|
sendCodexInstallerProgress({ platform: "mac", phase: "install", message: `正在复制到 /Applications/${path.basename(appPath)}…`, percent: 82 });
|
||||||
|
fs.rmSync(targetPath, { recursive: true, force: true });
|
||||||
|
await execFileAsync("/usr/bin/ditto", [appPath, targetPath], { timeout: 15 * 60 * 1000 });
|
||||||
|
|
||||||
|
sendCodexInstallerProgress({ platform: "mac", phase: "install", message: "正在卸载 DMG…", percent: 94 });
|
||||||
|
await execFileAsync("/usr/bin/hdiutil", ["detach", mountDir], { timeout: 2 * 60 * 1000 });
|
||||||
|
mounted = false;
|
||||||
|
return { appPath: targetPath };
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
try { await execFileAsync("/usr/bin/hdiutil", ["detach", mountDir], { timeout: 2 * 60 * 1000 }); } catch {}
|
||||||
|
}
|
||||||
|
try { fs.rmSync(mountDir, { recursive: true, force: true }); } catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function installCodexMac() {
|
||||||
|
if (process.platform !== "darwin") {
|
||||||
|
throw new Error("Mac 一键安装只能在 macOS 上运行;可先在软件内下载 DMG,再发给 Mac 用户安装。");
|
||||||
|
}
|
||||||
|
const { path: installerPath } = await downloadCodexInstallerForPlatform("mac");
|
||||||
|
const result = await runCodexMacDmg(installerPath);
|
||||||
|
sendCodexInstallerProgress({ platform: "mac", phase: "done", message: "Codex Mac 版安装完成", percent: 100 });
|
||||||
|
return { ok: true, path: installerPath, appPath: result.appPath };
|
||||||
|
}
|
||||||
|
|
||||||
// 比较版本号:a>b 返回 1,a<b 返回 -1,相等 0
|
// 比较版本号:a>b 返回 1,a<b 返回 -1,相等 0
|
||||||
function cmpVersion(a, b) {
|
function cmpVersion(a, b) {
|
||||||
@@ -874,11 +1174,14 @@ function safeSkillDir(name) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 列出已安装技能:扫描 ~/.codex/skills/<name>/
|
// 列出已安装技能:扫描 ~/.codex/skills/<name>/
|
||||||
function skillInstalledList() {
|
function skillInstalledList(opts = {}) {
|
||||||
const base = path.join(codex.CODEX_HOME, "skills");
|
const base = path.join(codex.CODEX_HOME, "skills");
|
||||||
if (!fs.existsSync(base)) return [];
|
if (!fs.existsSync(base)) return [];
|
||||||
|
const includeSize = !!opts.includeSize;
|
||||||
|
const includeMeta = opts.includeMeta !== false;
|
||||||
// 建索引:磁盘目录名 -> catalog 条目(中文名/中文简介)。读不到 catalog 就空索引,回退 SKILL.md。
|
// 建索引:磁盘目录名 -> catalog 条目(中文名/中文简介)。读不到 catalog 就空索引,回退 SKILL.md。
|
||||||
let byDir = null;
|
let byDir = null;
|
||||||
|
if (includeMeta) {
|
||||||
try {
|
try {
|
||||||
const list = (skillCatalog() || []);
|
const list = (skillCatalog() || []);
|
||||||
const items = Array.isArray(list) ? list : (list.skills || list.items || list.data || []);
|
const items = Array.isArray(list) ? list : (list.skills || list.items || list.data || []);
|
||||||
@@ -887,6 +1190,7 @@ function skillInstalledList() {
|
|||||||
if (s && s.name) byDir.set(safeSkillDir(s.name), s);
|
if (s && s.name) byDir.set(safeSkillDir(s.name), s);
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
|
}
|
||||||
const out = [];
|
const out = [];
|
||||||
for (const ent of fs.readdirSync(base, { withFileTypes: true })) {
|
for (const ent of fs.readdirSync(base, { withFileTypes: true })) {
|
||||||
const isDir = ent.isDirectory();
|
const isDir = ent.isDirectory();
|
||||||
@@ -895,7 +1199,7 @@ function skillInstalledList() {
|
|||||||
const dir = path.join(base, ent.name);
|
const dir = path.join(base, ent.name);
|
||||||
let mtime = 0;
|
let mtime = 0;
|
||||||
try { mtime = fs.statSync(dir).mtimeMs; } catch {}
|
try { mtime = fs.statSync(dir).mtimeMs; } catch {}
|
||||||
const meta = readSkillMeta(dir, ent.name);
|
const meta = includeMeta ? readSkillMeta(dir, ent.name) : { name: ent.name, description: "" };
|
||||||
// 优先用 catalog 的中文名/中文简介覆盖显示(SKILL.md 的 name 多为英文 slug,中文展示名在 catalog)
|
// 优先用 catalog 的中文名/中文简介覆盖显示(SKILL.md 的 name 多为英文 slug,中文展示名在 catalog)
|
||||||
const info = byDir && byDir.get(ent.name);
|
const info = byDir && byDir.get(ent.name);
|
||||||
out.push({
|
out.push({
|
||||||
@@ -903,7 +1207,7 @@ function skillInstalledList() {
|
|||||||
name: (info && info.name) || meta.name,
|
name: (info && info.name) || meta.name,
|
||||||
subtitle: (info && info.subtitle) || "",
|
subtitle: (info && info.subtitle) || "",
|
||||||
description: (info && (info.brief || info.subtitle)) || meta.description,
|
description: (info && (info.brief || info.subtitle)) || meta.description,
|
||||||
size: dirSize(dir),
|
size: includeSize ? dirSize(dir) : 0,
|
||||||
mtime,
|
mtime,
|
||||||
symlink: isLink,
|
symlink: isLink,
|
||||||
});
|
});
|
||||||
@@ -921,9 +1225,11 @@ function builtinPluginInfo(name) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 列出已安装插件:扫描 ~/.codex/plugins/cache/<source>/<name>/
|
// 列出已安装插件:扫描 ~/.codex/plugins/cache/<source>/<name>/
|
||||||
function pluginInstalledList() {
|
function pluginInstalledList(opts = {}) {
|
||||||
const base = path.join(codex.CODEX_HOME, "plugins", "cache");
|
const base = path.join(codex.CODEX_HOME, "plugins", "cache");
|
||||||
if (!fs.existsSync(base)) return [];
|
if (!fs.existsSync(base)) return [];
|
||||||
|
const includeSize = !!opts.includeSize;
|
||||||
|
const includeMeta = opts.includeMeta !== false;
|
||||||
const out = [];
|
const out = [];
|
||||||
for (const src of fs.readdirSync(base, { withFileTypes: true })) {
|
for (const src of fs.readdirSync(base, { withFileTypes: true })) {
|
||||||
const isSrcDir = src.isDirectory();
|
const isSrcDir = src.isDirectory();
|
||||||
@@ -938,14 +1244,14 @@ function pluginInstalledList() {
|
|||||||
let mtime = 0;
|
let mtime = 0;
|
||||||
try { mtime = fs.statSync(dir).mtimeMs; } catch {}
|
try { mtime = fs.statSync(dir).mtimeMs; } catch {}
|
||||||
// 中文标题/中文介绍优先取内置 plugins.json;磁盘 plugin.json 作兜底
|
// 中文标题/中文介绍优先取内置 plugins.json;磁盘 plugin.json 作兜底
|
||||||
const info = builtinPluginInfo(ent.name);
|
const info = includeMeta ? builtinPluginInfo(ent.name) : null;
|
||||||
out.push({
|
out.push({
|
||||||
source: src.name,
|
source: src.name,
|
||||||
name: ent.name,
|
name: ent.name,
|
||||||
displayName: (info && info.displayName) || ent.name, // 中文名,匹配不到回退英文 slug
|
displayName: (info && info.displayName) || ent.name, // 中文名,匹配不到回退英文 slug
|
||||||
description: (info && info.description) || readPluginDescription(dir),
|
description: includeMeta ? ((info && info.description) || readPluginDescription(dir)) : "",
|
||||||
rel: `plugins/cache/${src.name}/${ent.name}`,
|
rel: `plugins/cache/${src.name}/${ent.name}`,
|
||||||
size: dirSize(dir),
|
size: includeSize ? dirSize(dir) : 0,
|
||||||
mtime,
|
mtime,
|
||||||
symlink: isLink,
|
symlink: isLink,
|
||||||
protected: src.name.startsWith("openai-") && !isLink,
|
protected: src.name.startsWith("openai-") && !isLink,
|
||||||
@@ -1087,14 +1393,29 @@ function migrateLegacyPlugins() {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
ipcMain.handle("skill-catalog", async () => skillCatalog());
|
ipcMain.handle("skill-catalog", async () => skillCatalog());
|
||||||
ipcMain.handle("skill-install", async (_e, { skill }) => skillInstall(skill));
|
ipcMain.handle("skill-install", async (_e, { skill }) => skillInstall(skill));
|
||||||
ipcMain.handle("skill-installed", async () => skillInstalledList());
|
ipcMain.handle("skill-installed", async (_e, opts = {}) => skillInstalledList(opts || {}));
|
||||||
ipcMain.handle("skill-uninstall", async (_e, { dir }) => skillUninstall(dir));
|
ipcMain.handle("skill-uninstall", async (_e, { dir }) => skillUninstall(dir));
|
||||||
ipcMain.handle("skill-migrate", async () => migrateLegacySkills());
|
ipcMain.handle("skill-migrate", async () => migrateLegacySkills());
|
||||||
ipcMain.handle("plugin-catalog", async () => pluginCatalog());
|
ipcMain.handle("plugin-catalog", async () => pluginCatalog());
|
||||||
ipcMain.handle("plugin-install", async (_e, { plugin }) => pluginInstall(plugin));
|
ipcMain.handle("plugin-install", async (_e, { plugin }) => pluginInstall(plugin));
|
||||||
ipcMain.handle("plugin-installed", async () => pluginInstalledList());
|
ipcMain.handle("plugin-installed", async (_e, opts = {}) => pluginInstalledList(opts || {}));
|
||||||
ipcMain.handle("plugin-uninstall", async (_e, { rel }) => pluginUninstall(rel));
|
ipcMain.handle("plugin-uninstall", async (_e, { rel }) => pluginUninstall(rel));
|
||||||
ipcMain.handle("plugin-migrate", async () => migrateLegacyPlugins());
|
ipcMain.handle("plugin-migrate", async () => migrateLegacyPlugins());
|
||||||
|
ipcMain.handle("codex-installer-info", async () => ({
|
||||||
|
platform: process.platform,
|
||||||
|
isWindows: process.platform === "win32",
|
||||||
|
isMac: process.platform === "darwin",
|
||||||
|
windowsUrl: CODEX_WINDOWS_MSIX_URL,
|
||||||
|
windowsSha256Url: CODEX_WINDOWS_MSIX_SHA256_URL,
|
||||||
|
macUrl: CODEX_MAC_DMG_URL,
|
||||||
|
macSha256Url: CODEX_MAC_DMG_SHA256_URL,
|
||||||
|
windowsCachePath: codexInstallerCachePath(CODEX_WINDOWS_MSIX_NAME),
|
||||||
|
macCachePath: codexInstallerCachePath(CODEX_MAC_DMG_NAME),
|
||||||
|
}));
|
||||||
|
ipcMain.handle("codex-download-installer", async (_e, { platform }) => downloadCodexInstallerForPlatform(platform));
|
||||||
|
ipcMain.handle("codex-cancel-installer-download", async (_e, { platform }) => cancelCodexInstallerDownload(platform));
|
||||||
|
ipcMain.handle("codex-install-windows", async () => installCodexWindows());
|
||||||
|
ipcMain.handle("codex-install-mac", async () => installCodexMac());
|
||||||
|
|
||||||
ipcMain.handle("scan-local", async () => {
|
ipcMain.handle("scan-local", async () => {
|
||||||
return codex.scanBase(codex.CODEX_HOME);
|
return codex.scanBase(codex.CODEX_HOME);
|
||||||
@@ -1201,6 +1522,30 @@ ipcMain.handle("set-secret", async (_e, { name, value }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// —— 会话记录:浏览 / 读取 / 导出 / 备份 / 导入 ——
|
// —— 会话记录:浏览 / 读取 / 导出 / 备份 / 导入 ——
|
||||||
|
ipcMain.handle("sessions-data-dir-info", async () => codexDataDir.info());
|
||||||
|
ipcMain.handle("sessions-data-dir-select", async () => {
|
||||||
|
const current = codexDataDir.info();
|
||||||
|
const picked = await dialog.showOpenDialog(mainWindow, {
|
||||||
|
title: "选择 Codex 会话数据目录",
|
||||||
|
defaultPath: current.path,
|
||||||
|
properties: ["openDirectory"],
|
||||||
|
});
|
||||||
|
if (picked.canceled || !picked.filePaths.length) return { canceled: true, ...current };
|
||||||
|
const next = codexDataDir.set(picked.filePaths[0]);
|
||||||
|
providerSync.setCodexDataDir(next.path);
|
||||||
|
router.setCodexDataDir(next.path);
|
||||||
|
memory.restartWatching();
|
||||||
|
log(`会话数据目录已切换:${next.path}`);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
ipcMain.handle("sessions-data-dir-reset", async () => {
|
||||||
|
const next = codexDataDir.reset();
|
||||||
|
providerSync.setCodexDataDir(next.path);
|
||||||
|
router.setCodexDataDir(next.path);
|
||||||
|
memory.restartWatching();
|
||||||
|
log(`会话数据目录已恢复默认:${next.path}`);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
ipcMain.handle("sessions-list", async (_e, opts = {}) => {
|
ipcMain.handle("sessions-list", async (_e, opts = {}) => {
|
||||||
return sessions.listConversations(opts || {});
|
return sessions.listConversations(opts || {});
|
||||||
});
|
});
|
||||||
@@ -1209,6 +1554,20 @@ ipcMain.handle("session-read", async (_e, { path: filePath }) => {
|
|||||||
return sessions.readConversation(filePath);
|
return sessions.readConversation(filePath);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("session-delete-permanently", async (_e, { path: filePath } = {}) => {
|
||||||
|
return withStateWriteLock("永久删除会话", async () => {
|
||||||
|
if (providerSync.codexRunning()) throw new Error("请先完全退出 Codex,再永久删除会话");
|
||||||
|
const result = await sessions.deleteConversation(filePath);
|
||||||
|
try {
|
||||||
|
await memory.deleteSession(result.id, result.cwd);
|
||||||
|
return { ok: true, ...result };
|
||||||
|
} catch (e) {
|
||||||
|
log("会话主体已删除,但工具箱记忆清理失败:" + (e.message || e));
|
||||||
|
return { ok: true, cleanupWarning: String(e.message || e), ...result };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
ipcMain.handle("session-export", async (_e, { item }) => {
|
ipcMain.handle("session-export", async (_e, { item }) => {
|
||||||
const conv = sessions.readConversation(item.path);
|
const conv = sessions.readConversation(item.path);
|
||||||
const md = sessions.toMarkdown(item, conv);
|
const md = sessions.toMarkdown(item, conv);
|
||||||
@@ -1239,7 +1598,15 @@ ipcMain.handle("sessions-import", async () => {
|
|||||||
title: "选择要导入的备份目录(含 sessions 的文件夹)",
|
title: "选择要导入的备份目录(含 sessions 的文件夹)",
|
||||||
});
|
});
|
||||||
if (r.canceled || !r.filePaths.length) return { canceled: true };
|
if (r.canceled || !r.filePaths.length) return { canceled: true };
|
||||||
return sessions.importSessions(r.filePaths[0], log);
|
return withStateWriteLock("导入并恢复会话", async () => {
|
||||||
|
if (providerSync.codexRunning()) throw new Error("请先完全退出 Codex,再导入并恢复会话");
|
||||||
|
const imported = sessions.importSessions(r.filePaths[0], log);
|
||||||
|
const onProgress = (p) => {
|
||||||
|
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send("provider-sync-progress", p);
|
||||||
|
};
|
||||||
|
const synced = await providerSync.syncProviderToCurrent(log, onProgress);
|
||||||
|
return { ...imported, ...synced, restored: true };
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// —— 会话记忆:状态 / 检索 / 开关 / 手动摘要 ——
|
// —— 会话记忆:状态 / 检索 / 开关 / 手动摘要 ——
|
||||||
@@ -1275,11 +1642,12 @@ ipcMain.handle("provider-sync", async () => {
|
|||||||
const onProgress = (p) => {
|
const onProgress = (p) => {
|
||||||
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send("provider-sync-progress", p);
|
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send("provider-sync-progress", p);
|
||||||
};
|
};
|
||||||
return providerSync.syncProviderToCurrent(log, onProgress);
|
return withStateWriteLock("同步会话账号", () => providerSync.syncProviderToCurrent(log, onProgress));
|
||||||
});
|
});
|
||||||
|
|
||||||
// —— 本地路由 ——
|
// —— 本地路由 ——
|
||||||
ipcMain.handle("router-status", async () => router.status());
|
ipcMain.handle("router-status", async () => router.status());
|
||||||
|
ipcMain.handle("router-default-config", async () => router.defaultConfigInfo());
|
||||||
ipcMain.handle("router-upsert-group", async (_e, { group }) => {
|
ipcMain.handle("router-upsert-group", async (_e, { group }) => {
|
||||||
const s = router.upsertGroup(group);
|
const s = router.upsertGroup(group);
|
||||||
rebuildTrayMenu();
|
rebuildTrayMenu();
|
||||||
@@ -1309,9 +1677,14 @@ ipcMain.handle("router-start", async () => {
|
|||||||
return router.status();
|
return router.status();
|
||||||
});
|
});
|
||||||
ipcMain.handle("router-stop", async () => {
|
ipcMain.handle("router-stop", async () => {
|
||||||
router.stop();
|
const r = router.stopAndRestoreDirect();
|
||||||
rebuildTrayMenu();
|
rebuildTrayMenu();
|
||||||
return router.status();
|
return { ...r, status: router.status() };
|
||||||
|
});
|
||||||
|
ipcMain.handle("router-disable-codex", async () => {
|
||||||
|
const r = router.disableCodex();
|
||||||
|
rebuildTrayMenu();
|
||||||
|
return { ...r, status: router.status() };
|
||||||
});
|
});
|
||||||
ipcMain.handle("router-apply-codex", async () => {
|
ipcMain.handle("router-apply-codex", async () => {
|
||||||
const r = router.applyToCodex();
|
const r = router.applyToCodex();
|
||||||
@@ -1374,6 +1747,34 @@ ipcMain.handle("canvas-run", (e, { kind, params }) =>
|
|||||||
);
|
);
|
||||||
ipcMain.handle("canvas-groups", () => canvas.groups());
|
ipcMain.handle("canvas-groups", () => canvas.groups());
|
||||||
ipcMain.handle("canvas-models", (_e, { groupId }) => canvas.listModels(groupId));
|
ipcMain.handle("canvas-models", (_e, { groupId }) => canvas.listModels(groupId));
|
||||||
|
ipcMain.handle("canvas-workspaces", () => ({ ok: true, workspaces: canvasStore.list() }));
|
||||||
|
ipcMain.handle("canvas-workspace-create", (_e, { name } = {}) => {
|
||||||
|
try { return { ok: true, workspace: canvasStore.create(name) }; }
|
||||||
|
catch (err) { return { ok: false, error: String((err && err.message) || err) }; }
|
||||||
|
});
|
||||||
|
ipcMain.handle("canvas-workspace-load", (_e, { id } = {}) => {
|
||||||
|
try { return { ok: true, workspace: canvasStore.load(id) }; }
|
||||||
|
catch (err) { return { ok: false, error: String((err && err.message) || err) }; }
|
||||||
|
});
|
||||||
|
ipcMain.handle("canvas-workspace-rename", (_e, { id, name } = {}) => {
|
||||||
|
try { return { ok: true, workspace: canvasStore.rename(id, name) }; }
|
||||||
|
catch (err) { return { ok: false, error: String((err && err.message) || err) }; }
|
||||||
|
});
|
||||||
|
ipcMain.handle("canvas-workspace-save", (_e, { workspace } = {}) => {
|
||||||
|
try { return { ok: true, workspace: canvasStore.save(workspace) }; }
|
||||||
|
catch (err) { return { ok: false, error: String((err && err.message) || err) }; }
|
||||||
|
});
|
||||||
|
ipcMain.on("canvas-workspace-save-now", (_e, workspace) => {
|
||||||
|
try { canvasStore.save(workspace); } catch {}
|
||||||
|
});
|
||||||
|
ipcMain.handle("canvas-media-cache", async (_e, payload = {}) => {
|
||||||
|
try { return { ok: true, ...(await canvasStore.cacheMedia(payload)) }; }
|
||||||
|
catch (err) { return { ok: false, error: String((err && err.message) || err) }; }
|
||||||
|
});
|
||||||
|
ipcMain.handle("canvas-media-read", async (_e, payload = {}) => {
|
||||||
|
try { return { ok: true, ...(await canvasStore.readMedia(payload)) }; }
|
||||||
|
catch (err) { return { ok: false, error: String((err && err.message) || err) }; }
|
||||||
|
});
|
||||||
|
|
||||||
// —— 多 Agent 员工团队生成(需求 → 团队 → 打包 zip)——
|
// —— 多 Agent 员工团队生成(需求 → 团队 → 打包 zip)——
|
||||||
ipcMain.handle("team-generate", async (_e, params) => {
|
ipcMain.handle("team-generate", async (_e, params) => {
|
||||||
@@ -1442,8 +1843,87 @@ ipcMain.handle("voice-connect", (e, opts) =>
|
|||||||
}, opts || {})
|
}, opts || {})
|
||||||
);
|
);
|
||||||
ipcMain.handle("voice-disconnect", () => voice.disconnect());
|
ipcMain.handle("voice-disconnect", () => voice.disconnect());
|
||||||
|
ipcMain.handle("voice-restart-codex", async () => {
|
||||||
|
try {
|
||||||
|
await voice.disconnect();
|
||||||
|
} catch (e) {
|
||||||
|
log("重启 Codex:断开注入失败:" + (e && e.message ? e.message : e));
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const stopped = await codexDesktop.killCodex();
|
||||||
|
if (!stopped || !stopped.exited) {
|
||||||
|
return { ok: false, error: "Codex 未完全退出,已停止重启以保护运行中会话" };
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, error: "退出 Codex 失败:" + (e && e.message ? e.message : e) };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const restarted = await voice.connect(null, {});
|
||||||
|
return { ok: true, phase: restarted.phase, lastError: restarted.lastError };
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, error: "重新启动 Codex 失败:" + (e && e.message ? e.message : e) };
|
||||||
|
}
|
||||||
|
});
|
||||||
ipcMain.handle("voice-stt-info", () => voice.sttInfo());
|
ipcMain.handle("voice-stt-info", () => voice.sttInfo());
|
||||||
ipcMain.handle("voice-probe-stt", async () => voice.probeStt());
|
ipcMain.handle("voice-probe-stt", async () => voice.probeStt());
|
||||||
|
voice.onSessionDelete(async (request) => {
|
||||||
|
const payload = typeof request === "string" ? { kind: "delete-session", threadId: request } : (request || {});
|
||||||
|
const requestId = String(payload.requestId || "");
|
||||||
|
if (payload.kind === "delete-session-clipboard-prepare") {
|
||||||
|
if (requestId) {
|
||||||
|
sessionDeleteClipboard.set(requestId, { text: clipboard.readText(), expires: Date.now() + 5000 });
|
||||||
|
setTimeout(() => sessionDeleteClipboard.delete(requestId), 6000);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let threadId = String(payload.threadId || "");
|
||||||
|
if (payload.kind === "delete-session-clipboard-read") {
|
||||||
|
const saved = sessionDeleteClipboard.get(requestId);
|
||||||
|
sessionDeleteClipboard.delete(requestId);
|
||||||
|
const copied = clipboard.readText();
|
||||||
|
if (saved && saved.expires > Date.now()) {
|
||||||
|
try { clipboard.writeText(saved.text); } catch {}
|
||||||
|
}
|
||||||
|
const hit = copied.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i);
|
||||||
|
if (!hit) throw new Error("未能从 Codex 原生菜单读取会话 ID,已停止删除");
|
||||||
|
threadId = hit[0];
|
||||||
|
}
|
||||||
|
if (voiceSessionDeleteBusy) return;
|
||||||
|
voiceSessionDeleteBusy = true;
|
||||||
|
const item = sessions.findConversationById(threadId);
|
||||||
|
if (!item) { voiceSessionDeleteBusy = false; throw new Error("未找到对应的本地会话,已停止删除"); }
|
||||||
|
try {
|
||||||
|
await withStateWriteLock("永久删除会话", async () => {
|
||||||
|
const answer = await dialog.showMessageBox(mainWindow, {
|
||||||
|
type: "warning",
|
||||||
|
title: "彻底删除本地会话",
|
||||||
|
message: "永久删除“" + (item.title || "未命名会话") + "”?",
|
||||||
|
detail: "将退出 Codex,然后删除会话正文、归档副本、state SQLite 线程索引、标题索引和工具箱会话记忆。此操作不可恢复。",
|
||||||
|
buttons: ["取消", "彻底删除"],
|
||||||
|
defaultId: 0,
|
||||||
|
cancelId: 0,
|
||||||
|
});
|
||||||
|
if (answer.response !== 1) return;
|
||||||
|
voice.disconnect();
|
||||||
|
try {
|
||||||
|
const stopped = await codexDesktop.killCodex();
|
||||||
|
if (!stopped || !stopped.exited) throw new Error("Codex 未完全退出,已停止删除以保护 SQLite");
|
||||||
|
const result = await sessions.deleteConversation(item.path);
|
||||||
|
try { await memory.deleteSession(result.id, result.cwd); } catch (e) { log("会话主体已删除,但工具箱记忆清理失败:" + (e.message || e)); }
|
||||||
|
log("已彻底删除会话 " + result.id + ":正文 " + result.filesRemoved + ",SQLite 命中 " + result.stateRowsRemoved + " 个");
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
const restarted = await voice.connect(null, {});
|
||||||
|
if (!restarted || restarted.phase !== "connected") log("删除后重新启动 Codex 未连接:" + ((restarted && restarted.lastError) || "未知原因"));
|
||||||
|
} catch (e) {
|
||||||
|
log("删除后重新启动 Codex 失败:" + (e.message || e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
voiceSessionDeleteBusy = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// —— Token 用量监控(注入 Codex 页面,复用 CDP 连接)——
|
// —— Token 用量监控(注入 Codex 页面,复用 CDP 连接)——
|
||||||
ipcMain.handle("token-status", async () => tokenWatch.tokenStatus());
|
ipcMain.handle("token-status", async () => tokenWatch.tokenStatus());
|
||||||
|
|||||||
+28
-2
@@ -12,15 +12,26 @@ contextBridge.exposeInMainWorld("api", {
|
|||||||
// —— 技能商店(cocoloop 镜像)——
|
// —— 技能商店(cocoloop 镜像)——
|
||||||
skillCatalog: () => ipcRenderer.invoke("skill-catalog"),
|
skillCatalog: () => ipcRenderer.invoke("skill-catalog"),
|
||||||
skillInstall: (skill) => ipcRenderer.invoke("skill-install", { skill }),
|
skillInstall: (skill) => ipcRenderer.invoke("skill-install", { skill }),
|
||||||
skillInstalled: () => ipcRenderer.invoke("skill-installed"),
|
skillInstalled: (opts) => ipcRenderer.invoke("skill-installed", opts || {}),
|
||||||
skillUninstall: (dir) => ipcRenderer.invoke("skill-uninstall", { dir }),
|
skillUninstall: (dir) => ipcRenderer.invoke("skill-uninstall", { dir }),
|
||||||
skillMigrate: () => ipcRenderer.invoke("skill-migrate"),
|
skillMigrate: () => ipcRenderer.invoke("skill-migrate"),
|
||||||
// —— 插件市场(tokendun 官方插件,自建 gitea)——
|
// —— 插件市场(tokendun 官方插件,自建 gitea)——
|
||||||
pluginCatalog: () => ipcRenderer.invoke("plugin-catalog"),
|
pluginCatalog: () => ipcRenderer.invoke("plugin-catalog"),
|
||||||
pluginInstall: (plugin) => ipcRenderer.invoke("plugin-install", { plugin }),
|
pluginInstall: (plugin) => ipcRenderer.invoke("plugin-install", { plugin }),
|
||||||
pluginInstalled: () => ipcRenderer.invoke("plugin-installed"),
|
pluginInstalled: (opts) => ipcRenderer.invoke("plugin-installed", opts || {}),
|
||||||
pluginUninstall: (rel) => ipcRenderer.invoke("plugin-uninstall", { rel }),
|
pluginUninstall: (rel) => ipcRenderer.invoke("plugin-uninstall", { rel }),
|
||||||
pluginMigrate: () => ipcRenderer.invoke("plugin-migrate"),
|
pluginMigrate: () => ipcRenderer.invoke("plugin-migrate"),
|
||||||
|
// —— Codex 安装器 ——
|
||||||
|
codexInstallerInfo: () => ipcRenderer.invoke("codex-installer-info"),
|
||||||
|
codexDownloadInstaller: (platform) => ipcRenderer.invoke("codex-download-installer", { platform }),
|
||||||
|
codexCancelInstallerDownload: (platform) => ipcRenderer.invoke("codex-cancel-installer-download", { platform }),
|
||||||
|
codexInstallWindows: () => ipcRenderer.invoke("codex-install-windows"),
|
||||||
|
codexInstallMac: () => ipcRenderer.invoke("codex-install-mac"),
|
||||||
|
onCodexInstallerProgress: (cb) => {
|
||||||
|
const handler = (_e, p) => cb(p);
|
||||||
|
ipcRenderer.on("codex-installer-progress", handler);
|
||||||
|
return () => ipcRenderer.removeListener("codex-installer-progress", handler);
|
||||||
|
},
|
||||||
// —— 热更新 ——
|
// —— 热更新 ——
|
||||||
hotCheck: () => ipcRenderer.invoke("hot-check"),
|
hotCheck: () => ipcRenderer.invoke("hot-check"),
|
||||||
hotApply: () => ipcRenderer.invoke("hot-apply"),
|
hotApply: () => ipcRenderer.invoke("hot-apply"),
|
||||||
@@ -39,7 +50,11 @@ contextBridge.exposeInMainWorld("api", {
|
|||||||
quitApp: () => ipcRenderer.invoke("app-quit"),
|
quitApp: () => ipcRenderer.invoke("app-quit"),
|
||||||
sessionsList: (opts) =>
|
sessionsList: (opts) =>
|
||||||
ipcRenderer.invoke("sessions-list", typeof opts === "object" && opts !== null ? opts : { includeSubagent: !!opts }),
|
ipcRenderer.invoke("sessions-list", typeof opts === "object" && opts !== null ? opts : { includeSubagent: !!opts }),
|
||||||
|
sessionsDataDirInfo: () => ipcRenderer.invoke("sessions-data-dir-info"),
|
||||||
|
sessionsDataDirSelect: () => ipcRenderer.invoke("sessions-data-dir-select"),
|
||||||
|
sessionsDataDirReset: () => ipcRenderer.invoke("sessions-data-dir-reset"),
|
||||||
sessionRead: (path) => ipcRenderer.invoke("session-read", { path }),
|
sessionRead: (path) => ipcRenderer.invoke("session-read", { path }),
|
||||||
|
sessionDeletePermanently: (path) => ipcRenderer.invoke("session-delete-permanently", { path }),
|
||||||
sessionExport: (item) => ipcRenderer.invoke("session-export", { item }),
|
sessionExport: (item) => ipcRenderer.invoke("session-export", { item }),
|
||||||
sessionsBackup: () => ipcRenderer.invoke("sessions-backup"),
|
sessionsBackup: () => ipcRenderer.invoke("sessions-backup"),
|
||||||
sessionsImport: () => ipcRenderer.invoke("sessions-import"),
|
sessionsImport: () => ipcRenderer.invoke("sessions-import"),
|
||||||
@@ -64,12 +79,14 @@ contextBridge.exposeInMainWorld("api", {
|
|||||||
return () => ipcRenderer.removeListener("provider-sync-progress", handler);
|
return () => ipcRenderer.removeListener("provider-sync-progress", handler);
|
||||||
},
|
},
|
||||||
routerStatus: () => ipcRenderer.invoke("router-status"),
|
routerStatus: () => ipcRenderer.invoke("router-status"),
|
||||||
|
routerDefaultConfig: () => ipcRenderer.invoke("router-default-config"),
|
||||||
routerUpsertGroup: (group) => ipcRenderer.invoke("router-upsert-group", { group }),
|
routerUpsertGroup: (group) => ipcRenderer.invoke("router-upsert-group", { group }),
|
||||||
routerDeleteGroup: (id) => ipcRenderer.invoke("router-delete-group", { id }),
|
routerDeleteGroup: (id) => ipcRenderer.invoke("router-delete-group", { id }),
|
||||||
routerSetActive: (id) => ipcRenderer.invoke("router-set-active", { id }),
|
routerSetActive: (id) => ipcRenderer.invoke("router-set-active", { id }),
|
||||||
routerSetPort: (port) => ipcRenderer.invoke("router-set-port", { port }),
|
routerSetPort: (port) => ipcRenderer.invoke("router-set-port", { port }),
|
||||||
routerStart: () => ipcRenderer.invoke("router-start"),
|
routerStart: () => ipcRenderer.invoke("router-start"),
|
||||||
routerStop: () => ipcRenderer.invoke("router-stop"),
|
routerStop: () => ipcRenderer.invoke("router-stop"),
|
||||||
|
routerDisableCodex: () => ipcRenderer.invoke("router-disable-codex"),
|
||||||
routerApplyCodex: () => ipcRenderer.invoke("router-apply-codex"),
|
routerApplyCodex: () => ipcRenderer.invoke("router-apply-codex"),
|
||||||
routerApplyDirect: (id) => ipcRenderer.invoke("router-apply-direct", { id }),
|
routerApplyDirect: (id) => ipcRenderer.invoke("router-apply-direct", { id }),
|
||||||
routerApplyClaude: () => ipcRenderer.invoke("router-apply-claude"),
|
routerApplyClaude: () => ipcRenderer.invoke("router-apply-claude"),
|
||||||
@@ -92,6 +109,14 @@ contextBridge.exposeInMainWorld("api", {
|
|||||||
canvasRun: (kind, params) => ipcRenderer.invoke("canvas-run", { kind, params }),
|
canvasRun: (kind, params) => ipcRenderer.invoke("canvas-run", { kind, params }),
|
||||||
canvasGroups: () => ipcRenderer.invoke("canvas-groups"),
|
canvasGroups: () => ipcRenderer.invoke("canvas-groups"),
|
||||||
canvasModels: (groupId) => ipcRenderer.invoke("canvas-models", { groupId }),
|
canvasModels: (groupId) => ipcRenderer.invoke("canvas-models", { groupId }),
|
||||||
|
canvasWorkspaces: () => ipcRenderer.invoke("canvas-workspaces"),
|
||||||
|
canvasWorkspaceCreate: (name) => ipcRenderer.invoke("canvas-workspace-create", { name }),
|
||||||
|
canvasWorkspaceLoad: (id) => ipcRenderer.invoke("canvas-workspace-load", { id }),
|
||||||
|
canvasWorkspaceRename: (id, name) => ipcRenderer.invoke("canvas-workspace-rename", { id, name }),
|
||||||
|
canvasWorkspaceSave: (workspace) => ipcRenderer.invoke("canvas-workspace-save", { workspace }),
|
||||||
|
canvasWorkspaceSaveNow: (workspace) => ipcRenderer.send("canvas-workspace-save-now", workspace),
|
||||||
|
canvasMediaCache: (payload) => ipcRenderer.invoke("canvas-media-cache", payload || {}),
|
||||||
|
canvasMediaRead: (payload) => ipcRenderer.invoke("canvas-media-read", payload || {}),
|
||||||
// —— 多 Agent 员工团队生成 ——
|
// —— 多 Agent 员工团队生成 ——
|
||||||
teamGenerate: (params) => ipcRenderer.invoke("team-generate", params || {}),
|
teamGenerate: (params) => ipcRenderer.invoke("team-generate", params || {}),
|
||||||
teamPreview: (params) => ipcRenderer.invoke("team-preview", params || {}),
|
teamPreview: (params) => ipcRenderer.invoke("team-preview", params || {}),
|
||||||
@@ -109,6 +134,7 @@ contextBridge.exposeInMainWorld("api", {
|
|||||||
voiceStatus: () => ipcRenderer.invoke("voice-status"),
|
voiceStatus: () => ipcRenderer.invoke("voice-status"),
|
||||||
voiceConnect: (opts) => ipcRenderer.invoke("voice-connect", opts || {}),
|
voiceConnect: (opts) => ipcRenderer.invoke("voice-connect", opts || {}),
|
||||||
voiceDisconnect: () => ipcRenderer.invoke("voice-disconnect"),
|
voiceDisconnect: () => ipcRenderer.invoke("voice-disconnect"),
|
||||||
|
voiceRestartCodex: () => ipcRenderer.invoke("voice-restart-codex"),
|
||||||
voiceSttInfo: () => ipcRenderer.invoke("voice-stt-info"),
|
voiceSttInfo: () => ipcRenderer.invoke("voice-stt-info"),
|
||||||
voiceProbeStt: () => ipcRenderer.invoke("voice-probe-stt"),
|
voiceProbeStt: () => ipcRenderer.invoke("voice-probe-stt"),
|
||||||
onVoiceStatus: (cb) => {
|
onVoiceStatus: (cb) => {
|
||||||
|
|||||||
Generated
+280
-2
@@ -1,16 +1,17 @@
|
|||||||
{
|
{
|
||||||
"name": "codex-market",
|
"name": "codex-market",
|
||||||
"version": "1.5.16",
|
"version": "1.5.21",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "codex-market",
|
"name": "codex-market",
|
||||||
"version": "1.5.16",
|
"version": "1.5.21",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"electron-updater": "^6.8.9",
|
"electron-updater": "^6.8.9",
|
||||||
"isomorphic-git": "^1.29.0",
|
"isomorphic-git": "^1.29.0",
|
||||||
|
"pdfjs-dist": "^4.10.38",
|
||||||
"sql.js": "^1.14.1"
|
"sql.js": "^1.14.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -490,6 +491,271 @@
|
|||||||
"node": ">= 10.0.0"
|
"node": ">= 10.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@napi-rs/canvas": {
|
||||||
|
"version": "0.1.100",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas/-/canvas-0.1.100.tgz",
|
||||||
|
"integrity": "sha512-xglYA6q3XO5P3BNJYxVZ1IV7DLVjp1Py6nwag88YntrS+3vKHyYcMqXVS4ZztJmwz2uGvz1FWhI/4LgbR5uQDA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"workspaces": [
|
||||||
|
"e2e/*"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@napi-rs/canvas-android-arm64": "0.1.100",
|
||||||
|
"@napi-rs/canvas-darwin-arm64": "0.1.100",
|
||||||
|
"@napi-rs/canvas-darwin-x64": "0.1.100",
|
||||||
|
"@napi-rs/canvas-linux-arm-gnueabihf": "0.1.100",
|
||||||
|
"@napi-rs/canvas-linux-arm64-gnu": "0.1.100",
|
||||||
|
"@napi-rs/canvas-linux-arm64-musl": "0.1.100",
|
||||||
|
"@napi-rs/canvas-linux-riscv64-gnu": "0.1.100",
|
||||||
|
"@napi-rs/canvas-linux-x64-gnu": "0.1.100",
|
||||||
|
"@napi-rs/canvas-linux-x64-musl": "0.1.100",
|
||||||
|
"@napi-rs/canvas-win32-arm64-msvc": "0.1.100",
|
||||||
|
"@napi-rs/canvas-win32-x64-msvc": "0.1.100"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-android-arm64": {
|
||||||
|
"version": "0.1.100",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.100.tgz",
|
||||||
|
"integrity": "sha512-hjhCKhntPv9+t4ckHymdx0phYNcVW+GKQR6Lzw2zE+pOVjOplSmtx9nNNknTjbEDLcuLZqA1y8ufKg1XfgftzQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-darwin-arm64": {
|
||||||
|
"version": "0.1.100",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.100.tgz",
|
||||||
|
"integrity": "sha512-2PcswRaC7Ly645DGt88///zuFDhJxJYdKAs1uU3mfk1atYkXufgcgLfBpk6Tm12nCQBaNt1wpybuPZ4qOhTo8A==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-darwin-x64": {
|
||||||
|
"version": "0.1.100",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.100.tgz",
|
||||||
|
"integrity": "sha512-ePNZtj7pNIva/siZMg+HmbeozkIjqUIYdoymH8HaA3qK7LfzFN4WMBM8G6HQ9ZC+H3+Dnn5pqtiXpgLykaPOhw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
|
||||||
|
"version": "0.1.100",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.100.tgz",
|
||||||
|
"integrity": "sha512-d5cDB48oWFGU8/XPhUOFAlySgb/VAu7D+s8fi55K1Pcfg8aPplHWqMgibhVLU8ky7Pyg/fuiVLz4Nf3JrSTuUA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
|
||||||
|
"version": "0.1.100",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.100.tgz",
|
||||||
|
"integrity": "sha512-rDxgxRu69RvDlX/bh9o22DxLsGr8EqsNgotL9+RwQE1S0b0cqeatqsw6aW45mukm0B42DIAaAacKaYQ8cqS1nw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
|
||||||
|
"version": "0.1.100",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.100.tgz",
|
||||||
|
"integrity": "sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
|
||||||
|
"version": "0.1.100",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.100.tgz",
|
||||||
|
"integrity": "sha512-mooqUBTIsccZpnoQC4NgrC1v6C1vof39etLNMnBwCY+p0gajWJvAHLGQ6g/gGyS5YrpDW+GefSN4+Cvcr08UWw==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
|
||||||
|
"version": "0.1.100",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.100.tgz",
|
||||||
|
"integrity": "sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-linux-x64-musl": {
|
||||||
|
"version": "0.1.100",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.100.tgz",
|
||||||
|
"integrity": "sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-win32-arm64-msvc": {
|
||||||
|
"version": "0.1.100",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.100.tgz",
|
||||||
|
"integrity": "sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
|
||||||
|
"version": "0.1.100",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.100.tgz",
|
||||||
|
"integrity": "sha512-MyT1j3mHC2+Lu4pBi9mKyMJhtP6U7k7EldY7sj/uS5gJA65gTXt8MefJQXLJo5d/vZbuWmfxzkEUNc/urV3pHA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@pkgjs/parseargs": {
|
"node_modules/@pkgjs/parseargs": {
|
||||||
"version": "0.11.0",
|
"version": "0.11.0",
|
||||||
"resolved": "https://registry.npmmirror.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
|
"resolved": "https://registry.npmmirror.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
|
||||||
@@ -3537,6 +3803,18 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/pdfjs-dist": {
|
||||||
|
"version": "4.10.38",
|
||||||
|
"resolved": "https://registry.npmmirror.com/pdfjs-dist/-/pdfjs-dist-4.10.38.tgz",
|
||||||
|
"integrity": "sha512-/Y3fcFrXEAsMjJXeL9J8+ZG9U01LbuWaYypvDW2ycW1jL269L3js3DVBjDJ0Up9Np1uqDXsDrRihHANhZOlwdQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@napi-rs/canvas": "^0.1.65"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/pend": {
|
"node_modules/pend": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmmirror.com/pend/-/pend-1.2.0.tgz",
|
"resolved": "https://registry.npmmirror.com/pend/-/pend-1.2.0.tgz",
|
||||||
|
|||||||
+5
-1
@@ -5,10 +5,13 @@
|
|||||||
"author": "Codex Market",
|
"author": "Codex Market",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"main": "electron/bootstrap.js",
|
"main": "electron/bootstrap.js",
|
||||||
"hotContentVersion": "1.5.24",
|
"hotContentVersion": "1.5.83",
|
||||||
"hotMinAppVersion": "1.5.0",
|
"hotMinAppVersion": "1.5.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "electron .",
|
"start": "electron .",
|
||||||
|
"test": "node --test",
|
||||||
|
"smoke:multimodal": "node scripts/smoke-multimodal.js",
|
||||||
|
"prepare:multimodal": "node scripts/prepare-multimodal-gateway.js",
|
||||||
"dist": "electron-builder",
|
"dist": "electron-builder",
|
||||||
"dist:mac": "electron-builder --mac",
|
"dist:mac": "electron-builder --mac",
|
||||||
"dist:win": "electron-builder --win",
|
"dist:win": "electron-builder --win",
|
||||||
@@ -21,6 +24,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"electron-updater": "^6.8.9",
|
"electron-updater": "^6.8.9",
|
||||||
"isomorphic-git": "^1.29.0",
|
"isomorphic-git": "^1.29.0",
|
||||||
|
"pdfjs-dist": "^4.10.38",
|
||||||
"sql.js": "^1.14.1"
|
"sql.js": "^1.14.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1944
-240
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,33 @@
|
|||||||
|
(function initCanvasInputs(root) {
|
||||||
|
function uniqueNonEmpty(values) {
|
||||||
|
const seen = new Set();
|
||||||
|
return (Array.isArray(values) ? values : []).filter((value) => {
|
||||||
|
const normalized = typeof value === "string" ? value.trim() : "";
|
||||||
|
if (!normalized || seen.has(normalized)) return false;
|
||||||
|
seen.add(normalized);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectUpstreamOutputs(node, readNodeOutput) {
|
||||||
|
const out = { text: "", image: "", images: [] };
|
||||||
|
if (!node || !node.inputs || typeof readNodeOutput !== "function") return out;
|
||||||
|
|
||||||
|
for (const key of Object.keys(node.inputs)) {
|
||||||
|
const connections = (node.inputs[key] && node.inputs[key].connections) || [];
|
||||||
|
for (const connection of connections) {
|
||||||
|
const source = readNodeOutput(connection.node) || {};
|
||||||
|
if (source.text && !out.text) out.text = String(source.text).trim();
|
||||||
|
if (source.image) out.images.push(source.image);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out.images = uniqueNonEmpty(out.images);
|
||||||
|
out.image = out.images[0] || "";
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
const api = { uniqueNonEmpty, collectUpstreamOutputs };
|
||||||
|
if (typeof module !== "undefined" && module.exports) module.exports = api;
|
||||||
|
root.CanvasInputs = api;
|
||||||
|
})(typeof window !== "undefined" ? window : globalThis);
|
||||||
+142
-88
@@ -18,7 +18,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="brand-text">
|
<div class="brand-text">
|
||||||
<div class="brand-name">TD工具箱</div>
|
<div class="brand-name">TD工具箱</div>
|
||||||
<div class="brand-sub">tokendun 工具箱 · 技能 / 插件 · 路由 · 代理</div>
|
<div class="brand-sub">tokendun 工具箱 · 技能 / 插件 · 路由</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -33,7 +33,6 @@
|
|||||||
<button class="tab" data-tab="plugins" role="tab">插件市场</button>
|
<button class="tab" data-tab="plugins" role="tab">插件市场</button>
|
||||||
<button class="tab" data-tab="canvas" role="tab">创意画布</button>
|
<button class="tab" data-tab="canvas" role="tab">创意画布</button>
|
||||||
<button class="tab" data-tab="router" role="tab">路由中转</button>
|
<button class="tab" data-tab="router" role="tab">路由中转</button>
|
||||||
<button class="tab" data-tab="proxy" role="tab">全局代理</button>
|
|
||||||
<button class="tab" data-tab="settings" role="tab">设置</button>
|
<button class="tab" data-tab="settings" role="tab">设置</button>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
@@ -84,11 +83,11 @@
|
|||||||
<p class="muted">tokendun 官方自研插件与内置工具</p>
|
<p class="muted">tokendun 官方自研插件与内置工具</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="head-actions">
|
<div class="head-actions">
|
||||||
<div class="codex-link-status" title="Codex 桌面连接状态;语音输入与 Token 用量共用此连接">
|
<div class="codex-link-status" title="Codex 注入状态:语音输入、Token 用量和左侧任务行的彻底删除按钮都依赖此连接">
|
||||||
<span id="codex-link-dot" class="voice-dot" data-state="unknown"></span>
|
<span id="codex-link-dot" class="voice-dot" data-state="unknown"></span>
|
||||||
<span class="codex-link-label">Codex</span>
|
<span class="codex-link-label">Codex</span>
|
||||||
<span id="codex-link-phase-text" class="muted small">未连接</span>
|
<span id="codex-link-phase-text" class="muted small">未连接</span>
|
||||||
<button class="btn ghost small" id="codex-link-connect-btn" data-voice-link-connect>连接</button>
|
<button class="btn ghost small" id="codex-link-connect-btn" data-voice-link-connect>连接并注入</button>
|
||||||
<button class="btn ghost small hidden" id="codex-link-disconnect-btn" data-voice-link-disconnect>断开</button>
|
<button class="btn ghost small hidden" id="codex-link-disconnect-btn" data-voice-link-disconnect>断开</button>
|
||||||
</div>
|
</div>
|
||||||
<input id="plugin-search" class="search" type="search" placeholder="搜索插件 / 作者…" />
|
<input id="plugin-search" class="search" type="search" placeholder="搜索插件 / 作者…" />
|
||||||
@@ -111,11 +110,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="tool-card">
|
<div class="tool-card">
|
||||||
<div class="tool-top"><span class="tool-ico">🎤</span><div class="tool-name">语音输入</div></div>
|
<div class="tool-top"><span class="tool-ico">🎤</span><div class="tool-name">语音输入</div></div>
|
||||||
<div class="tool-desc">给 Codex 输入框注入麦克风按钮,说话即转文字填入。需由工具箱重启 Codex 一次以启用调试通道(登录态保留)。</div>
|
<div class="tool-desc">给 ChatGPT/Codex 输入框注入麦克风按钮,说话即转文字填入。需由工具箱重启 ChatGPT 一次以启用调试通道(登录态保留)。</div>
|
||||||
<div class="tool-foot"><button class="btn primary small" id="voice-enable-card-btn" data-voice-enable data-enable-label="启用" data-enabled-label="已启用">启用</button></div>
|
<div class="tool-foot"><button class="btn primary small" id="voice-enable-card-btn" data-voice-enable data-enable-label="启用" data-enabled-label="已启用">启用</button></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="tool-card">
|
<div class="tool-card">
|
||||||
<div class="tool-top"><span class="tool-ico">🧑💼</span><div class="tool-name">员工团队</div></div>
|
<div class="tool-top"><span class="tool-ico">👥</span><div class="tool-name">员工团队</div></div>
|
||||||
<div class="tool-desc">用一段话描述项目需求,自动拆解出一支带约束、能协作的 AI 员工团队(含总控 + 契约 + 文件边界 + 协同规范),打包成 Codex / Claude Code 可直接用的配置 zip。</div>
|
<div class="tool-desc">用一段话描述项目需求,自动拆解出一支带约束、能协作的 AI 员工团队(含总控 + 契约 + 文件边界 + 协同规范),打包成 Codex / Claude Code 可直接用的配置 zip。</div>
|
||||||
<div class="tool-foot"><button class="btn primary small" data-open-panel="team">打开</button></div>
|
<div class="tool-foot"><button class="btn primary small" data-open-panel="team">打开</button></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -129,6 +128,23 @@
|
|||||||
<div class="tool-desc">在 Codex 输入框下方、模型选择左侧显示每日 Token 累计消耗量。复用语音 CDP 连接自动注入,无需额外配置。每天 24 点自动清零。</div>
|
<div class="tool-desc">在 Codex 输入框下方、模型选择左侧显示每日 Token 累计消耗量。复用语音 CDP 连接自动注入,无需额外配置。每天 24 点自动清零。</div>
|
||||||
<div class="tool-foot"><button class="btn primary small" data-open-panel="token">打开</button></div>
|
<div class="tool-foot"><button class="btn primary small" data-open-panel="token">打开</button></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="tool-card" id="codex-installer-card">
|
||||||
|
<div class="tool-top"><span class="tool-ico">⬇</span><div class="tool-name">安装 Codex</div></div>
|
||||||
|
<div class="tool-desc">Windows / Mac 版远程下载并自动校验安装包,不把大体积安装文件塞进工具箱。下载和安装过程会显示进度。</div>
|
||||||
|
<div class="tool-status muted small" id="codex-installer-status">正在读取安装器信息…</div>
|
||||||
|
<div class="installer-progress hidden" id="codex-installer-progress-wrap">
|
||||||
|
<div class="installer-progress-bar" id="codex-installer-progress-bar"></div>
|
||||||
|
</div>
|
||||||
|
<div class="installer-cancel-row hidden" id="codex-installer-cancel-row">
|
||||||
|
<button class="btn ghost small" id="codex-installer-cancel-btn">取消下载</button>
|
||||||
|
</div>
|
||||||
|
<div class="tool-foot multi">
|
||||||
|
<button class="btn ghost small" id="codex-win-open-url-btn">浏览器打开 Windows</button>
|
||||||
|
<button class="btn primary small" id="codex-win-install-btn">Windows 一键安装</button>
|
||||||
|
<button class="btn ghost small" id="codex-mac-open-url-btn">浏览器打开 Mac</button>
|
||||||
|
<button class="btn primary small" id="codex-mac-install-btn">Mac 一键安装</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 已安装插件 -->
|
<!-- 已安装插件 -->
|
||||||
@@ -147,18 +163,19 @@
|
|||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
<div>
|
<div>
|
||||||
<h2><button class="btn ghost small back-btn" data-back-plugins>← 插件市场</button> 语音输入</h2>
|
<h2><button class="btn ghost small back-btn" data-back-plugins>← 插件市场</button> 语音输入</h2>
|
||||||
<p class="muted">由工具箱用 CDP 连接 Codex 桌面版,往输入框注入麦克风按钮;录音经中转站 Whisper 转文字并自动填入。</p>
|
<p class="muted">由工具箱用 CDP 连接 ChatGPT/Codex 桌面版,往输入框注入麦克风按钮;录音经中转站 Whisper 转文字并自动填入。</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="head-actions">
|
<div class="head-actions">
|
||||||
<span id="voice-dot" class="voice-dot" data-state="unknown"></span>
|
<span id="voice-dot" class="voice-dot" data-state="unknown"></span>
|
||||||
<span id="voice-phase-text" class="muted small">未连接</span>
|
<span id="voice-phase-text" class="muted small">未连接</span>
|
||||||
<button id="voice-enable-btn" class="btn primary" data-voice-enable data-enable-label="启用语音输入" data-enabled-label="已启用">启用语音输入</button>
|
<button id="voice-enable-btn" class="btn primary" data-voice-enable data-enable-label="启用语音输入" data-enabled-label="已启用">启用语音输入</button>
|
||||||
|
<button id="voice-disable-btn" class="btn ghost hidden" data-voice-disable>关闭语音输入</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="voice-body">
|
<div class="voice-body">
|
||||||
<div class="voice-card">
|
<div class="voice-card">
|
||||||
<div class="voice-card-title">状态</div>
|
<div class="voice-card-title">状态</div>
|
||||||
<div id="voice-detail" class="muted small">点击「启用语音输入」开始。若 Codex 正在运行,会提示你确认重启 Codex 以启用调试端口。</div>
|
<div id="voice-detail" class="muted small">点击「启用语音输入」开始。若 ChatGPT 正在运行,会提示你确认重启 ChatGPT 以启用调试端口。</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="voice-card">
|
<div class="voice-card">
|
||||||
<div class="voice-card-title">转写分组 / 模型</div>
|
<div class="voice-card-title">转写分组 / 模型</div>
|
||||||
@@ -176,7 +193,7 @@
|
|||||||
<div class="voice-card">
|
<div class="voice-card">
|
||||||
<div class="voice-card-title">使用说明</div>
|
<div class="voice-card-title">使用说明</div>
|
||||||
<ol class="voice-ol">
|
<ol class="voice-ol">
|
||||||
<li>点击「启用语音输入」。若 Codex 在运行且未带调试端口,会弹窗提示重启(当前窗口会关闭一次,登录态保留)。</li>
|
<li>点击「启用语音输入」。若 ChatGPT 在运行且未带调试端口,会弹窗提示重启(当前窗口会关闭一次,登录态保留)。</li>
|
||||||
<li>连接成功后,Codex 输入框旁出现 🎤 按钮。点击 → 授权麦克风权限 → 说话 → 再次点击停止。</li>
|
<li>连接成功后,Codex 输入框旁出现 🎤 按钮。点击 → 授权麦克风权限 → 说话 → 再次点击停止。</li>
|
||||||
<li>转写完成后文字自动填入输入框。首次需在「系统设置 → 隐私 → 麦克风」允许 ChatGPT。</li>
|
<li>转写完成后文字自动填入输入框。首次需在「系统设置 → 隐私 → 麦克风」允许 ChatGPT。</li>
|
||||||
<li>macOS 误杀进程风险已规避:仅在确认后重启。Codex 升级后若按钮消失,可能需等待工具箱适配。</li>
|
<li>macOS 误杀进程风险已规避:仅在确认后重启。Codex 升级后若按钮消失,可能需等待工具箱适配。</li>
|
||||||
@@ -189,27 +206,57 @@
|
|||||||
<section class="panel" id="tab-canvas">
|
<section class="panel" id="tab-canvas">
|
||||||
<div class="cv-toolbar">
|
<div class="cv-toolbar">
|
||||||
<div class="cv-toolbar-left">
|
<div class="cv-toolbar-left">
|
||||||
<h2 class="cv-title">🎨 创意画布</h2>
|
<button class="cv-nav-dot" type="button" title="画布"></button>
|
||||||
|
<h2 class="cv-title">创意画布</h2>
|
||||||
|
<button class="cv-project-name" id="cv-projects-btn" type="button" title="打开画布记录">未命名画布</button>
|
||||||
<span class="pc-badge cv-status" id="cv-status">检测中…</span>
|
<span class="pc-badge cv-status" id="cv-status">检测中…</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="cv-toolbar-right">
|
<div class="cv-toolbar-right">
|
||||||
<span class="cv-add-label">添加节点</span>
|
<button class="btn small ghost" id="cv-new-btn">+ 新建画布</button>
|
||||||
<button class="btn small" data-cv-add="text">📝 文本</button>
|
|
||||||
<button class="btn small" data-cv-add="image">🖼️ 文生图</button>
|
|
||||||
<button class="btn small" data-cv-add="video">🎬 图生视频</button>
|
|
||||||
<button class="btn small" data-cv-add="grokVideo">🎞️ Grok视频</button>
|
|
||||||
<button class="btn small" data-cv-add="tts">🔊 TTS配音</button>
|
|
||||||
<button class="btn small ghost" id="cv-models-btn">🔄 拉取模型</button>
|
<button class="btn small ghost" id="cv-models-btn">🔄 拉取模型</button>
|
||||||
<button class="btn small ghost" id="cv-seed-btn">✨ 载入示例</button>
|
<button class="btn small ghost" id="cv-seed-btn">✨ 载入示例</button>
|
||||||
<button class="btn small primary" id="cv-full-btn">⛶ 全屏</button>
|
<button class="btn small primary" id="cv-full-btn">⛶ 全屏</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="cv-tool-rail" aria-label="添加节点">
|
||||||
|
<button class="cv-rail-btn" type="button" data-cv-add="text" title="文本">T</button>
|
||||||
|
<button class="cv-rail-btn" type="button" data-cv-add="image" title="文生图">▧</button>
|
||||||
|
<button class="cv-rail-btn" type="button" data-cv-add="video" title="图生视频">▣</button>
|
||||||
|
<button class="cv-rail-btn" type="button" data-cv-add="grokVideo" title="Grok视频">◆</button>
|
||||||
|
<button class="cv-rail-btn" type="button" data-cv-add="tts" title="TTS配音">♪</button>
|
||||||
|
</div>
|
||||||
<div class="cv-stage">
|
<div class="cv-stage">
|
||||||
|
<div class="cv-project-picker hidden" id="cv-project-picker" role="dialog" aria-label="画布记录">
|
||||||
|
<div class="cv-project-picker-head">
|
||||||
|
<div><b>画布记录</b><span>自动保存到本机,生成素材也会缓存</span></div>
|
||||||
|
<button class="cv-project-close" id="cv-project-close" type="button" title="关闭">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="cv-project-actions">
|
||||||
|
<button class="btn small primary" id="cv-project-create" type="button">+ 新建画布</button>
|
||||||
|
</div>
|
||||||
|
<div class="cv-project-list" id="cv-project-list"></div>
|
||||||
|
</div>
|
||||||
<div id="drawflow"></div>
|
<div id="drawflow"></div>
|
||||||
|
<div class="cv-empty-hint" id="cv-empty-hint">
|
||||||
|
<span>✳ 双击</span>
|
||||||
|
<b>画布任意空白处,自由生成</b>
|
||||||
|
</div>
|
||||||
|
<div class="cv-quick-menu hidden" id="cv-quick-menu">
|
||||||
|
<div class="cv-quick-title">新建节点</div>
|
||||||
|
<button type="button" data-cv-quick-add="text"><span>T</span>文本生成</button>
|
||||||
|
<button type="button" data-cv-quick-add="image"><span>▧</span>图片生成</button>
|
||||||
|
<button type="button" data-cv-quick-add="video"><span>▣</span>视频生成</button>
|
||||||
|
<button type="button" data-cv-quick-add="grokVideo"><span>◆</span>Grok 视频</button>
|
||||||
|
<button type="button" data-cv-quick-add="tts"><span>♪</span>音频 / TTS</button>
|
||||||
|
</div>
|
||||||
<div class="cv-zoom">
|
<div class="cv-zoom">
|
||||||
<button class="cv-zoom-btn" id="cv-zoom-in" title="放大 (Ctrl+滚轮)">+</button>
|
<button class="cv-zoom-btn" id="cv-zoom-out" title="缩小">-</button>
|
||||||
|
<input id="cv-zoom-range" class="cv-zoom-range" type="range" min="50" max="160" step="5" value="100" aria-label="画布缩放" />
|
||||||
<button class="cv-zoom-btn cv-zoom-level" id="cv-zoom-reset" title="点击重置为 100%">100%</button>
|
<button class="cv-zoom-btn cv-zoom-level" id="cv-zoom-reset" title="点击重置为 100%">100%</button>
|
||||||
<button class="cv-zoom-btn" id="cv-zoom-out" title="缩小 (Ctrl+滚轮)">-</button>
|
<button class="cv-zoom-btn" id="cv-zoom-in" title="放大">+</button>
|
||||||
|
</div>
|
||||||
|
<div class="cv-minimap" aria-hidden="true">
|
||||||
|
<div class="cv-minimap-view"><span></span></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="cv-mask hidden" id="cv-mask">
|
<div class="cv-mask hidden" id="cv-mask">
|
||||||
<div class="cv-mask-box">
|
<div class="cv-mask-box">
|
||||||
@@ -241,19 +288,34 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tm-field">
|
<div class="tm-field">
|
||||||
<label class="tm-label">分析模型(中转分组)</label>
|
<label class="tm-label">团队生成模型(仅用于生成配置)</label>
|
||||||
<div class="tm-row">
|
<div class="tm-row">
|
||||||
<select class="tm-select" id="tm-group"></select>
|
<select class="tm-select" id="tm-group"></select>
|
||||||
<button class="btn small ghost" id="tm-fetch">🔄 拉取模型</button>
|
<button class="btn small ghost" id="tm-fetch">🔄 拉取模型</button>
|
||||||
</div>
|
</div>
|
||||||
<select class="tm-select" id="tm-model" style="margin-top:8px"></select>
|
<select class="tm-select" id="tm-model" style="margin-top:8px"></select>
|
||||||
<div class="tm-hint" id="tm-model-hint">先在『路由中转』配置好分组,再选具体模型</div>
|
<div class="tm-hint" id="tm-model-hint">只负责把需求转换成团队配置;它不会决定生成后 Agent 执行任务使用的模型</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tm-field">
|
<div class="tm-field">
|
||||||
<label class="tm-label">员工运行模型(可选)</label>
|
<label class="tm-label">总控执行模型(生成后实际运行)</label>
|
||||||
<input id="tm-agent-model" class="tm-text" placeholder="留空=用上面的分析模型,如 gpt-4o" />
|
<div class="tm-row">
|
||||||
<div class="tm-hint" id="tm-agent-hint">写进每个员工的 model 字段,改成你运行器支持的模型名</div>
|
<select class="tm-select" id="tm-orch-group"></select>
|
||||||
|
<button class="btn small ghost" id="tm-orch-fetch">🔄 拉取模型</button>
|
||||||
|
</div>
|
||||||
|
<select class="tm-select" id="tm-orch-model" style="margin-top:8px"></select>
|
||||||
|
<div class="tm-hint" id="tm-orch-hint">总控负责调度、设计、审计、验收和多模态任务;可以和团队生成模型不同</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tm-field">
|
||||||
|
<label class="tm-label">子 Agent 执行模型</label>
|
||||||
|
<div class="tm-row">
|
||||||
|
<select class="tm-select" id="tm-agent-group"></select>
|
||||||
|
<button class="btn small ghost" id="tm-agent-fetch">🔄 拉取模型</button>
|
||||||
|
</div>
|
||||||
|
<input id="tm-agent-model" class="tm-text" list="tm-agent-model-list" style="margin-top:2px" placeholder="选择或填写 DeepSeek 模型,如 deepseek-v4-flash" />
|
||||||
|
<datalist id="tm-agent-model-list"></datalist>
|
||||||
|
<div class="tm-hint" id="tm-agent-hint">子 Agent 负责编码、测试和文档;模型名会写入员工配置,并自动登记到对应路由分组</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button id="tm-gen" class="btn primary tm-gen" disabled>生成员工团队</button>
|
<button id="tm-gen" class="btn primary tm-gen" disabled>生成员工团队</button>
|
||||||
@@ -267,7 +329,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="tm-result-body">
|
<div class="tm-result-body">
|
||||||
<div class="tm-empty" id="tm-empty">
|
<div class="tm-empty" id="tm-empty">
|
||||||
<div class="tm-empty-ico">🧑💼→👥</div>
|
<div class="tm-empty-ico">👤→👥</div>
|
||||||
<p class="tm-empty-title">描述需求,自动组建 AI 员工团队</p>
|
<p class="tm-empty-title">描述需求,自动组建 AI 员工团队</p>
|
||||||
<p class="tm-empty-sub">拆解职责 · 定义契约 · 划分文件边界 · 生成协同规范</p>
|
<p class="tm-empty-sub">拆解职责 · 定义契约 · 划分文件边界 · 生成协同规范</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -413,7 +475,7 @@
|
|||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
<div>
|
<div>
|
||||||
<h2><button class="btn ghost small back-btn" data-back-plugins>← 插件市场</button> 会话记录</h2>
|
<h2><button class="btn ghost small back-btn" data-back-plugins>← 插件市场</button> 会话记录</h2>
|
||||||
<p class="muted">直接读取本机 <code>~/.codex/sessions</code>,全量模式会同时读取 <code>archived_sessions</code> 和子任务会话</p>
|
<p class="muted">直接读取所选 Codex 数据目录的 <code>sessions</code>;全量模式会同时读取 <code>archived_sessions</code> 和子任务会话</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="head-actions">
|
<div class="head-actions">
|
||||||
<input id="sess-search" class="search" type="search" placeholder="搜索标题 / 项目…" />
|
<input id="sess-search" class="search" type="search" placeholder="搜索标题 / 项目…" />
|
||||||
@@ -424,6 +486,12 @@
|
|||||||
<button id="sess-backup" class="btn">备份全部…</button>
|
<button id="sess-backup" class="btn">备份全部…</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="session-data-dir" id="session-data-dir">
|
||||||
|
<span class="muted small">Codex 会话数据目录</span>
|
||||||
|
<code id="session-data-dir-path">读取中…</code>
|
||||||
|
<button id="session-data-dir-select" class="btn ghost small">选择目录…</button>
|
||||||
|
<button id="session-data-dir-reset" class="btn ghost small hidden">恢复默认</button>
|
||||||
|
</div>
|
||||||
<div class="sync-banner">
|
<div class="sync-banner">
|
||||||
<div class="sync-text">
|
<div class="sync-text">
|
||||||
<strong>🔄 同步会话到当前账号</strong>
|
<strong>🔄 同步会话到当前账号</strong>
|
||||||
@@ -442,8 +510,11 @@
|
|||||||
<div class="sess-conv hidden" id="sess-conv">
|
<div class="sess-conv hidden" id="sess-conv">
|
||||||
<div class="sess-conv-head">
|
<div class="sess-conv-head">
|
||||||
<div class="sess-conv-title" id="sess-conv-title"></div>
|
<div class="sess-conv-title" id="sess-conv-title"></div>
|
||||||
|
<div class="sess-conv-actions">
|
||||||
|
<button id="sess-delete" class="btn small danger">永久删除</button>
|
||||||
<button id="sess-export" class="btn small">导出为 Markdown</button>
|
<button id="sess-export" class="btn small">导出为 Markdown</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="sess-messages" id="sess-messages"></div>
|
<div class="sess-messages" id="sess-messages"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -476,6 +547,7 @@
|
|||||||
工作方式:会话静默几分钟后自动摘要(优先走「路由中转」当前分组的 LLM,失败退化为本地提取)。
|
工作方式:会话静默几分钟后自动摘要(优先走「路由中转」当前分组的 LLM,失败退化为本地提取)。
|
||||||
项目根目录会生成 <code>HANDOFF.md</code>(状态快照),并在 <code>AGENTS.md</code> 里加一段「先读 HANDOFF」引导(带标记,可随时删)。
|
项目根目录会生成 <code>HANDOFF.md</code>(状态快照),并在 <code>AGENTS.md</code> 里加一段「先读 HANDOFF」引导(带标记,可随时删)。
|
||||||
</p>
|
</p>
|
||||||
|
<p class="muted small" style="margin:6px 0 0">正在监视:<code id="mem-data-dir-path">读取中…</code></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="panel-head" style="margin-top:14px">
|
<div class="panel-head" style="margin-top:14px">
|
||||||
@@ -510,6 +582,7 @@
|
|||||||
<button id="router-apply" class="btn primary">启用本地路由(高级)</button>
|
<button id="router-apply" class="btn primary">启用本地路由(高级)</button>
|
||||||
<button id="router-apply-claude" class="btn">接入 Claude Code</button>
|
<button id="router-apply-claude" class="btn">接入 Claude Code</button>
|
||||||
<button id="router-toggle" class="btn">启动</button>
|
<button id="router-toggle" class="btn">启动</button>
|
||||||
|
<button id="router-refresh-codex" class="btn ghost" title="仅当需要同步 Codex 顶部模型选择器时重启 Codex">刷新 Codex 显示</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
@@ -533,6 +606,31 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="router-groups" class="checklist"></div>
|
<div id="router-groups" class="checklist"></div>
|
||||||
|
|
||||||
|
<!-- 默认账号配置详情 -->
|
||||||
|
<div id="default-config-modal" class="modal-overlay hidden">
|
||||||
|
<div class="modal default-config-modal">
|
||||||
|
<div class="modal-head"><h3>默认账号配置(只读)</h3><button class="modal-close" id="default-config-close">✕</button></div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div id="default-config-summary" class="default-config-summary"></div>
|
||||||
|
<label class="field grow" style="margin-top:12px">
|
||||||
|
<span>当前 ~/.codex/config.toml</span>
|
||||||
|
<textarea id="default-config-current" class="config-textarea" readonly></textarea>
|
||||||
|
</label>
|
||||||
|
<label class="field grow" style="margin-top:12px">
|
||||||
|
<span>默认分组保存的原始快照(切回默认时参考)</span>
|
||||||
|
<textarea id="default-config-snapshot" class="config-textarea" readonly></textarea>
|
||||||
|
</label>
|
||||||
|
<div class="row between" style="margin-top:12px">
|
||||||
|
<span class="muted small">auth.json 仅展示脱敏摘要,不回显密钥或登录 token。</span>
|
||||||
|
<div class="btn-group">
|
||||||
|
<button type="button" class="btn ghost small" id="default-config-refresh">刷新</button>
|
||||||
|
<button type="button" class="btn small" id="default-config-copy">复制当前 config</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 分组编辑弹窗 -->
|
<!-- 分组编辑弹窗 -->
|
||||||
<div id="grp-modal" class="modal-overlay hidden">
|
<div id="grp-modal" class="modal-overlay hidden">
|
||||||
<div class="modal">
|
<div class="modal">
|
||||||
@@ -549,16 +647,26 @@
|
|||||||
</label>
|
</label>
|
||||||
<label class="field grow" style="margin-top:10px"><span>API 地址(base_url)</span><input id="grp-url" placeholder="https://api.xxx.com" /></label>
|
<label class="field grow" style="margin-top:10px"><span>API 地址(base_url)</span><input id="grp-url" placeholder="https://api.xxx.com" /></label>
|
||||||
<label class="field grow" style="margin-top:10px"><span>API 密钥</span><input id="grp-key" type="password" placeholder="sk-…" /></label>
|
<label class="field grow" style="margin-top:10px"><span>API 密钥</span><input id="grp-key" type="password" placeholder="sk-…" /></label>
|
||||||
<label class="field grow" style="margin-top:10px;flex-direction:row;align-items:center;gap:8px;cursor:pointer">
|
<div class="grp-purpose">
|
||||||
<input id="grp-codex-image" type="checkbox" style="width:auto;margin:0" />
|
<label class="grp-purpose-pill">
|
||||||
<span style="margin:0">在 Codex 中用于生图(写入 gen-image 技能,需支持 OpenAI 图片接口)</span>
|
<input id="grp-codex-image" type="checkbox" />
|
||||||
|
<span>生图专用</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="field grow" style="margin-top:10px;flex-direction:row;align-items:center;gap:8px;cursor:pointer">
|
<label class="grp-purpose-pill">
|
||||||
<input id="grp-stt" type="checkbox" style="width:auto;margin:0" />
|
<input id="grp-codex-video" type="checkbox" />
|
||||||
<span style="margin:0">支持语音转文字(语音输入插件用此分组转写)</span>
|
<span>视频专用</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="field grow" style="margin-top:8px">
|
<label class="grp-purpose-pill">
|
||||||
<span>语音转写模型(勾选 STT 后填,如 whisper-1 / speech-01)</span>
|
<input id="grp-stt" type="checkbox" />
|
||||||
|
<span>语音专用</span>
|
||||||
|
</label>
|
||||||
|
<label class="grp-purpose-pill" title="只用于当前 DeepSeek 分组的附件识别,不会替换对话模型">
|
||||||
|
<input id="grp-multimodal" type="checkbox" />
|
||||||
|
<span>启用 MiniMax 多模态</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label class="field grow hidden" id="grp-stt-model-field" style="margin-top:8px">
|
||||||
|
<span>语音专用模型(如 whisper-1 / speech-01)</span>
|
||||||
<input id="grp-stt-model" placeholder="whisper-1" />
|
<input id="grp-stt-model" placeholder="whisper-1" />
|
||||||
</label>
|
</label>
|
||||||
<div class="field grow" style="margin-top:10px">
|
<div class="field grow" style="margin-top:10px">
|
||||||
@@ -607,61 +715,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- ⑤ 全局代理 -->
|
|
||||||
<section class="panel" id="tab-proxy">
|
|
||||||
<div class="panel-head">
|
|
||||||
<div>
|
|
||||||
<h2>全局代理</h2>
|
|
||||||
<p class="muted">填入 SOCKS5 代理,一键把<b>整台电脑</b>(全局 TUN)或<b>系统</b>的网络切到代理上。支持要密码的 SOCKS5。</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card action-card">
|
|
||||||
<label class="field grow">
|
|
||||||
<span>SOCKS5 代理地址</span>
|
|
||||||
<input id="proxy-str" type="text" placeholder="host:port:user:pass 例如 1.2.3.4:1080:用户名:密码" />
|
|
||||||
</label>
|
|
||||||
<div class="row between">
|
|
||||||
<span class="muted small" id="proxy-parsed">支持格式:<code>host:port:user:pass</code> / <code>host:port</code> / <code>socks5://user:pass@host:port</code></span>
|
|
||||||
<div class="btn-group">
|
|
||||||
<button id="proxy-save" class="btn ghost">保存</button>
|
|
||||||
<button id="proxy-test" class="btn">测试连接</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="proxy-test-out" class="muted small" style="margin-top:6px"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card action-card">
|
|
||||||
<div class="row between">
|
|
||||||
<div class="field">
|
|
||||||
<span>当前状态</span>
|
|
||||||
<div><span class="pc-badge" id="proxy-state">未开启</span> <span class="muted small" id="proxy-state-detail"></span></div>
|
|
||||||
</div>
|
|
||||||
<div class="btn-group">
|
|
||||||
<button id="proxy-start-global" class="btn primary">启动全局代理(整机)</button>
|
|
||||||
<button id="proxy-start-system" class="btn">启动系统代理</button>
|
|
||||||
<button id="proxy-stop" class="btn ghost hidden">关闭代理</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="proxy-modes">
|
|
||||||
<div class="pm-item">
|
|
||||||
<b>🌐 全局代理(整机 TUN)</b>
|
|
||||||
<span class="muted small">创建虚拟网卡接管<b>所有</b>程序的流量(含命令行、各类 App)。macOS/Windows 启动时会弹一次<b>系统管理员密码</b>——创建网卡必需,和 Clash 一样。</span>
|
|
||||||
</div>
|
|
||||||
<div class="pm-item">
|
|
||||||
<b>⚙️ 系统代理</b>
|
|
||||||
<span class="muted small">只设操作系统代理,<b>不需要管理员密码</b>。浏览器和多数 App 生效;不认系统代理的程序(部分命令行)不走。</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card action-card">
|
|
||||||
<p class="muted small" style="margin:0">
|
|
||||||
<b>说明</b>:本功能内置 sing-box 引擎在本地做中转,因此即使 SOCKS5 需要账号密码也能正常用于系统代理(操作系统本身不发密码,靠本地中转补上)。关闭软件会自动还原网络设置。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- 设置 -->
|
<!-- 设置 -->
|
||||||
<section class="panel" id="tab-settings">
|
<section class="panel" id="tab-settings">
|
||||||
<div class="panel-head"><div><h2>设置</h2><p class="muted">密钥用系统安全存储加密后保存在本机</p></div></div>
|
<div class="panel-head"><div><h2>设置</h2><p class="muted">密钥用系统安全存储加密后保存在本机</p></div></div>
|
||||||
@@ -764,6 +817,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="vendor/drawflow.min.js"></script>
|
<script src="vendor/drawflow.min.js"></script>
|
||||||
|
<script src="canvas-inputs.js"></script>
|
||||||
<script src="app.js"></script>
|
<script src="app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+427
-78
@@ -141,12 +141,6 @@ h3 { margin: 0; font-size: 16px; }
|
|||||||
.cl-warn { margin-top: 6px; font-size: 12px; color: #f59e0b;
|
.cl-warn { margin-top: 6px; font-size: 12px; color: #f59e0b;
|
||||||
background: rgba(245,158,11,.1); border-radius: 8px; padding: 6px 9px; }
|
background: rgba(245,158,11,.1); border-radius: 8px; padding: 6px 9px; }
|
||||||
.pc-badge.on { background: rgba(74,222,128,.18); color: var(--ok); }
|
.pc-badge.on { background: rgba(74,222,128,.18); color: var(--ok); }
|
||||||
/* 全局代理:两种模式说明块 */
|
|
||||||
.proxy-modes { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-top: 14px; }
|
|
||||||
.pm-item { display: flex; flex-direction: column; gap: 5px; padding: 12px 14px;
|
|
||||||
border: 1px solid var(--border); border-radius: 10px; background: rgba(255,255,255,.02); }
|
|
||||||
.pm-item b { font-size: 14px; }
|
|
||||||
@media (max-width: 760px) { .proxy-modes { grid-template-columns: 1fr; } }
|
|
||||||
.pc-name { font-weight: 700; font-size: 15px; word-break: break-all; }
|
.pc-name { font-weight: 700; font-size: 15px; word-break: break-all; }
|
||||||
.pc-meta { color: var(--muted); font-size: 12px; display: flex; gap: 10px;
|
.pc-meta { color: var(--muted); font-size: 12px; display: flex; gap: 10px;
|
||||||
flex-wrap: wrap; }
|
flex-wrap: wrap; }
|
||||||
@@ -166,8 +160,12 @@ h3 { margin: 0; font-size: 16px; }
|
|||||||
cursor: pointer; transition: .12s; }
|
cursor: pointer; transition: .12s; }
|
||||||
.cl-item:hover { border-color: var(--accent); }
|
.cl-item:hover { border-color: var(--accent); }
|
||||||
.cl-item input { width: 17px; height: 17px; accent-color: var(--accent); }
|
.cl-item input { width: 17px; height: 17px; accent-color: var(--accent); }
|
||||||
|
.cl-item.is-disabled { cursor: default; opacity: .82; }
|
||||||
|
.cl-item.is-disabled:hover { border-color: var(--border); }
|
||||||
|
.cl-item input:disabled { cursor: not-allowed; opacity: .45; }
|
||||||
.cl-item .cl-name { font-weight: 600; }
|
.cl-item .cl-name { font-weight: 600; }
|
||||||
.cl-item .cl-src { color: var(--muted); font-size: 12px; }
|
.cl-item .cl-src { color: var(--muted); font-size: 12px; }
|
||||||
|
.cl-tool-hint { color: var(--muted); font-size: 12px; margin-top: 4px; }
|
||||||
.cl-item .cl-size { margin-left: auto; color: var(--muted); font-size: 12px; }
|
.cl-item .cl-size { margin-left: auto; color: var(--muted); font-size: 12px; }
|
||||||
|
|
||||||
/* 路由分组:按协议族分两栏,各一个单选,可同时激活 */
|
/* 路由分组:按协议族分两栏,各一个单选,可同时激活 */
|
||||||
@@ -222,7 +220,7 @@ h3 { margin: 0; font-size: 16px; }
|
|||||||
/* Toast */
|
/* Toast */
|
||||||
.toast { position: fixed; bottom: 60px; left: 50%; transform: translateX(-50%);
|
.toast { position: fixed; bottom: 60px; left: 50%; transform: translateX(-50%);
|
||||||
background: var(--card); border: 1px solid var(--border); color: var(--text);
|
background: var(--card); border: 1px solid var(--border); color: var(--text);
|
||||||
padding: 12px 20px; border-radius: 12px; box-shadow: var(--shadow); z-index: 50;
|
padding: 12px 20px; border-radius: 12px; box-shadow: var(--shadow); z-index: 10060;
|
||||||
max-width: 80%; font-size: 13px; }
|
max-width: 80%; font-size: 13px; }
|
||||||
.toast.ok { border-color: var(--ok); }
|
.toast.ok { border-color: var(--ok); }
|
||||||
.toast.err { border-color: var(--danger); }
|
.toast.err { border-color: var(--danger); }
|
||||||
@@ -232,6 +230,11 @@ h3 { margin: 0; font-size: 16px; }
|
|||||||
.installing { pointer-events: none; opacity: .7; }
|
.installing { pointer-events: none; opacity: .7; }
|
||||||
|
|
||||||
/* 会话记录:同步横幅 */
|
/* 会话记录:同步横幅 */
|
||||||
|
.session-data-dir { display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
|
||||||
|
margin-bottom: 12px; padding: 9px 12px; border: 1px solid var(--border); border-radius: var(--radius);
|
||||||
|
background: var(--card-2); }
|
||||||
|
.session-data-dir code { min-width: 0; max-width: min(620px, 55vw); overflow: hidden;
|
||||||
|
text-overflow: ellipsis; white-space: nowrap; color: var(--text); }
|
||||||
.sync-banner { display: flex; align-items: center; justify-content: space-between;
|
.sync-banner { display: flex; align-items: center; justify-content: space-between;
|
||||||
gap: 14px; background: linear-gradient(135deg, rgba(124,133,255,.12), rgba(168,85,247,.10));
|
gap: 14px; background: linear-gradient(135deg, rgba(124,133,255,.12), rgba(168,85,247,.10));
|
||||||
border: 1px solid var(--border); border-radius: var(--radius); padding: 12px 16px;
|
border: 1px solid var(--border); border-radius: var(--radius); padding: 12px 16px;
|
||||||
@@ -266,6 +269,9 @@ h3 { margin: 0; font-size: 16px; }
|
|||||||
.sess-conv { display: flex; flex-direction: column; height: 100%; }
|
.sess-conv { display: flex; flex-direction: column; height: 100%; }
|
||||||
.sess-conv-head { display: flex; align-items: center; justify-content: space-between;
|
.sess-conv-head { display: flex; align-items: center; justify-content: space-between;
|
||||||
gap: 10px; padding: 12px 16px; border-bottom: 1px solid var(--border); }
|
gap: 10px; padding: 12px 16px; border-bottom: 1px solid var(--border); }
|
||||||
|
.sess-conv-actions { display: flex; align-items: center; gap: 7px; }
|
||||||
|
.sess-conv-actions .btn.danger { color: var(--danger); border-color: rgba(255,107,107,.38); }
|
||||||
|
.sess-conv-actions .btn.danger:hover { background: rgba(255,107,107,.12); border-color: var(--danger); }
|
||||||
.sess-conv-title { font-weight: 700; font-size: 14px; white-space: nowrap;
|
.sess-conv-title { font-weight: 700; font-size: 14px; white-space: nowrap;
|
||||||
overflow: hidden; text-overflow: ellipsis; }
|
overflow: hidden; text-overflow: ellipsis; }
|
||||||
.sess-messages { overflow-y: auto; padding: 16px; display: flex;
|
.sess-messages { overflow-y: auto; padding: 16px; display: flex;
|
||||||
@@ -294,7 +300,7 @@ h3 { margin: 0; font-size: 16px; }
|
|||||||
|
|
||||||
/* 说明弹窗 */
|
/* 说明弹窗 */
|
||||||
.modal-overlay { position: fixed; inset: 0; background: rgba(0, 0, 0, .5);
|
.modal-overlay { position: fixed; inset: 0; background: rgba(0, 0, 0, .5);
|
||||||
display: flex; align-items: center; justify-content: center; z-index: 100;
|
display: flex; align-items: center; justify-content: center; z-index: 10050;
|
||||||
padding: 24px; animation: fade .15s ease; }
|
padding: 24px; animation: fade .15s ease; }
|
||||||
.modal { background: var(--card); border: 1px solid var(--border);
|
.modal { background: var(--card); border: 1px solid var(--border);
|
||||||
border-radius: var(--radius); box-shadow: var(--shadow); max-width: 640px;
|
border-radius: var(--radius); box-shadow: var(--shadow); max-width: 640px;
|
||||||
@@ -325,9 +331,31 @@ h3 { margin: 0; font-size: 16px; }
|
|||||||
border: 1px solid var(--border); color: var(--text); border-radius: 10px;
|
border: 1px solid var(--border); color: var(--text); border-radius: 10px;
|
||||||
padding: 9px 12px; outline: none; font-size: 13px; }
|
padding: 9px 12px; outline: none; font-size: 13px; }
|
||||||
.grp-select:focus { border-color: var(--accent); }
|
.grp-select:focus { border-color: var(--accent); }
|
||||||
|
.grp-purpose { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-top: 12px; }
|
||||||
|
.grp-purpose-pill {
|
||||||
|
min-height: 36px; display: inline-flex; align-items: center; gap: 8px; flex: 1 1 0;
|
||||||
|
min-width: 112px; padding: 8px 12px; border: 1px solid var(--border);
|
||||||
|
border-radius: 10px; background: var(--bg-2); color: var(--text);
|
||||||
|
font-size: 13px; font-weight: 600; cursor: pointer; user-select: none;
|
||||||
|
}
|
||||||
|
.grp-purpose-pill:hover { border-color: var(--accent); background: var(--card-2); }
|
||||||
|
.grp-purpose-pill input {
|
||||||
|
flex: none; width: 15px; height: 15px; margin: 0; accent-color: var(--accent); cursor: pointer;
|
||||||
|
}
|
||||||
|
.grp-purpose-pill span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.default-config-modal { max-width: 820px; }
|
||||||
|
.default-config-summary { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
|
||||||
|
.default-config-summary div { min-width: 0; border: 1px solid var(--border); border-radius: 10px;
|
||||||
|
background: var(--bg-2); padding: 9px 11px; color: var(--muted); font-size: 12px; line-height: 1.45; }
|
||||||
|
.default-config-summary b { display: block; color: var(--text); font-size: 13px; margin-bottom: 2px; }
|
||||||
|
.config-textarea { width: 100%; min-height: 180px; resize: vertical; border: 1px solid var(--border);
|
||||||
|
border-radius: 10px; background: var(--bg-2); color: var(--text); padding: 10px 12px;
|
||||||
|
font: 12px/1.55 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; outline: none; }
|
||||||
|
.config-textarea:focus { border-color: var(--accent); }
|
||||||
.grp-1m-list { margin-top: 8px; display: flex; flex-direction: column; gap: 2px;
|
.grp-1m-list { margin-top: 8px; display: flex; flex-direction: column; gap: 2px;
|
||||||
max-height: 180px; overflow-y: auto; border: 1px solid var(--border);
|
max-height: 180px; overflow-y: auto; border: 1px solid var(--border);
|
||||||
border-radius: 10px; padding: 6px; background: var(--bg-2); }
|
border-radius: 10px; padding: 6px; background: var(--bg-2); }
|
||||||
|
@media (max-width: 720px) { .default-config-summary { grid-template-columns: 1fr; } }
|
||||||
.grp-1m-item { display: flex; align-items: center; gap: 8px; padding: 5px 7px;
|
.grp-1m-item { display: flex; align-items: center; gap: 8px; padding: 5px 7px;
|
||||||
border-radius: 7px; cursor: pointer; font-size: 13px; }
|
border-radius: 7px; cursor: pointer; font-size: 13px; }
|
||||||
.grp-1m-item:hover { background: var(--card); }
|
.grp-1m-item:hover { background: var(--card); }
|
||||||
@@ -335,6 +363,7 @@ h3 { margin: 0; font-size: 16px; }
|
|||||||
.grp-1m-item span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.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 > span:first-child { font-weight: 600; font-size: 13px; }
|
||||||
.claudedesk-block .btn:disabled { opacity: .45; cursor: not-allowed; }
|
.claudedesk-block .btn:disabled { opacity: .45; cursor: not-allowed; }
|
||||||
|
.claudedesk-block.hidden { display: none !important; }
|
||||||
|
|
||||||
/* —— 会话记忆 —— */
|
/* —— 会话记忆 —— */
|
||||||
.mem-item { background: var(--card); border: 1px solid var(--border);
|
.mem-item { background: var(--card); border: 1px solid var(--border);
|
||||||
@@ -499,52 +528,194 @@ h3 { margin: 0; font-size: 16px; }
|
|||||||
.tool-name { font-weight: 700; font-size: 15px; }
|
.tool-name { font-weight: 700; font-size: 15px; }
|
||||||
.tool-desc { color: var(--muted); font-size: 13px; line-height: 1.6; flex: 1; }
|
.tool-desc { color: var(--muted); font-size: 13px; line-height: 1.6; flex: 1; }
|
||||||
.tool-foot { display: flex; justify-content: flex-end; }
|
.tool-foot { display: flex; justify-content: flex-end; }
|
||||||
|
.tool-foot.multi { justify-content: space-between; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||||
|
.tool-status { min-height: 18px; line-height: 1.5; }
|
||||||
|
.installer-progress { width: 100%; height: 8px; overflow: hidden; border-radius: 999px;
|
||||||
|
background: var(--card-2); border: 1px solid var(--border); }
|
||||||
|
.installer-progress-bar { width: 0%; height: 100%; border-radius: inherit;
|
||||||
|
background: var(--accent); transition: width .18s ease; }
|
||||||
|
.installer-cancel-row { display: flex; justify-content: flex-end; margin-top: -2px; }
|
||||||
.back-btn { margin-right: 8px; vertical-align: middle; padding: 4px 10px; }
|
.back-btn { margin-right: 8px; vertical-align: middle; padding: 4px 10px; }
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
创意画布(Drawflow 节点画布)
|
创意画布(Drawflow 节点画布)
|
||||||
复用 :root 设计变量,覆写 Drawflow 默认外观,明/暗色协调。
|
复用 :root 设计变量,覆写 Drawflow 默认外观,明/暗色协调。
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
#tab-canvas {
|
||||||
|
color-scheme: dark;
|
||||||
|
--text: #f6f6f6;
|
||||||
|
--muted: #999999;
|
||||||
|
--border: rgba(255,255,255,.08);
|
||||||
|
--bg: #0a0a0a;
|
||||||
|
--card: #161616;
|
||||||
|
--card-2: #1d1d1d;
|
||||||
|
--cv-accent: #ff6a2c;
|
||||||
|
--cv-accent-strong: #ff7f42;
|
||||||
|
--cv-accent-soft: rgba(255, 106, 44, .14);
|
||||||
|
--cv-blue: #3b82f6;
|
||||||
|
--cv-warn: #f59e0b;
|
||||||
|
--cv-shell: #0a0a0a;
|
||||||
|
--cv-surface: #161616;
|
||||||
|
--cv-surface-2: #1d1d1d;
|
||||||
|
--cv-panel-glass: rgba(35,35,41,.82);
|
||||||
|
--cv-field: #1d1d1d;
|
||||||
|
--cv-canvas: #0a0a0a;
|
||||||
|
--cv-grid-major: rgba(255,255,255,.055);
|
||||||
|
--cv-grid-minor: rgba(255,255,255,.032);
|
||||||
|
--cv-shadow-soft: 0 25px 50px -12px rgba(0,0,0,.25);
|
||||||
|
--cv-shadow-card: 0 8px 28px rgba(0,0,0,.42);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
#tab-canvas {
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* 画布面板占满内容区高度:工具条固定 + 画布撑满剩余空间 */
|
/* 画布面板占满内容区高度:工具条固定 + 画布撑满剩余空间 */
|
||||||
/* 画布面板占满内容区高度:抵消 .content 的 22px padding,铺满更大气 */
|
/* 画布面板占满内容区高度:抵消 .content 的 22px padding,铺满更大气 */
|
||||||
#tab-canvas.active { display: flex; flex-direction: column; min-height: 0;
|
#tab-canvas.active { display: flex; flex-direction: column; min-height: 0;
|
||||||
margin: -22px; height: calc(100% + 44px); padding: 22px; }
|
position: relative; margin: -22px; height: calc(100% + 44px); padding: 0; gap: 0;
|
||||||
.cv-toolbar { display: flex; align-items: center; justify-content: space-between;
|
overflow: hidden; background: var(--cv-canvas); }
|
||||||
gap: 12px; flex-wrap: wrap; margin-bottom: 12px; }
|
.cv-toolbar { position: absolute; top: 12px; left: 12px; right: 12px; z-index: 30;
|
||||||
.cv-toolbar-left { display: flex; align-items: center; gap: 12px; }
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
.cv-title { font-size: 18px; }
|
gap: 12px; flex-wrap: nowrap; margin-bottom: 0; padding: 0; pointer-events: none; }
|
||||||
.cv-status { border: 1px solid var(--border); background: var(--card-2); color: var(--muted); }
|
.cv-toolbar-left,
|
||||||
.cv-status.on { background: rgba(74,222,128,.18); color: var(--ok); border-color: transparent; }
|
.cv-toolbar-right { display: flex; align-items: center; gap: 7px; min-height: 34px;
|
||||||
.cv-status.off { background: rgba(255,107,107,.16); color: var(--danger); border-color: transparent; }
|
pointer-events: auto; padding: 4px 6px; border-radius: 999px;
|
||||||
.cv-toolbar-right { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
border: 1px solid var(--border); background: rgba(22,22,22,.96);
|
||||||
.cv-add-label { color: var(--muted); font-size: 12px; font-weight: 600; margin-right: 2px; }
|
box-shadow: 0 4px 12px rgba(0,0,0,.28); }
|
||||||
|
.cv-toolbar-left { min-width: 0; }
|
||||||
|
.cv-nav-dot { width: 24px; height: 24px; border: 0; border-radius: 999px; cursor: default;
|
||||||
|
background: radial-gradient(circle at 38% 36%, #ffb077, var(--cv-accent));
|
||||||
|
box-shadow: 0 0 0 4px rgba(255,138,77,.12); }
|
||||||
|
.cv-title { position: relative; display: inline-flex; align-items: center; gap: 8px;
|
||||||
|
font-size: 14px; letter-spacing: 0; font-weight: 760; white-space: nowrap; }
|
||||||
|
.cv-title::before { content: none; }
|
||||||
|
.cv-status { border: 1px solid color-mix(in srgb, var(--border) 74%, transparent);
|
||||||
|
background: rgba(29,29,29,.9); color: var(--muted); box-shadow: none; }
|
||||||
|
.cv-status.on { background: rgba(16,185,129,.10); color: #059669; border-color: rgba(16,185,129,.22); }
|
||||||
|
.cv-status.off { background: rgba(255,107,107,.13); color: var(--danger); border-color: rgba(255,107,107,.28); }
|
||||||
|
#tab-canvas .cv-toolbar .btn { min-height: 30px; padding: 6px 10px; border-radius: 8px;
|
||||||
|
background: rgba(29,29,29,.9); color: var(--text);
|
||||||
|
border-color: var(--border); box-shadow: none; }
|
||||||
|
#tab-canvas .cv-toolbar .btn:hover { border-color: rgba(255,138,77,.42);
|
||||||
|
background: color-mix(in srgb, var(--cv-accent-soft) 46%, var(--cv-surface-2)); }
|
||||||
|
#tab-canvas .cv-toolbar .btn.primary { background: var(--cv-blue); border-color: transparent;
|
||||||
|
color: #fff; box-shadow: 0 9px 18px rgba(59,130,246,.22); }
|
||||||
|
#tab-canvas .cv-toolbar .btn.primary:hover { background: #2563eb; filter: none; }
|
||||||
|
.cv-project-name { max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||||
|
border: 0; background: transparent; color: var(--text); font: inherit; font-size: 12px; font-weight: 680;
|
||||||
|
padding: 5px 7px; border-radius: 6px; cursor: pointer; }
|
||||||
|
.cv-project-name:hover { background: var(--cv-surface-2); }
|
||||||
|
|
||||||
|
.cv-tool-rail { position: absolute; left: 18px; top: 50%; transform: translateY(-50%);
|
||||||
|
z-index: 28; display: flex; flex-direction: column; align-items: center; gap: 4px;
|
||||||
|
width: 50px; padding: 7px; box-sizing: border-box;
|
||||||
|
border: 1px solid var(--border); border-radius: 26px;
|
||||||
|
background: rgba(22,22,22,.96); box-shadow: 0 8px 18px rgba(0,0,0,.24); }
|
||||||
|
.cv-rail-btn { width: 36px; height: 36px; border: 0; border-radius: 999px;
|
||||||
|
display: grid; place-items: center; color: #8b96a8; background: transparent;
|
||||||
|
font-weight: 760; font-size: 13px; cursor: pointer;
|
||||||
|
transition: background-color .14s, color .14s; }
|
||||||
|
.cv-rail-btn:hover { color: var(--text); background: var(--cv-surface-2); box-shadow: none; }
|
||||||
|
|
||||||
/* 画布舞台:明确高度(Drawflow 容器需要),点阵底纹 */
|
/* 画布舞台:明确高度(Drawflow 容器需要),点阵底纹 */
|
||||||
.cv-stage { position: relative; flex: 1; min-height: 560px;
|
.cv-stage { position: relative; flex: 1; min-height: 560px;
|
||||||
border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden;
|
border: 0; border-radius: 0; overflow: hidden; box-shadow: none; background: var(--cv-canvas); }
|
||||||
box-shadow: var(--shadow); }
|
.cv-stage::before { content: ""; position: absolute; inset: 0; z-index: 1; pointer-events: none;
|
||||||
|
background: radial-gradient(circle at 50% 42%, rgba(255,255,255,.025), transparent 44%);
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(255,255,255,.04); }
|
||||||
|
.cv-project-picker { position: absolute; z-index: 40; top: 14px; left: 50%; transform: translateX(-50%);
|
||||||
|
width: min(520px, calc(100% - 28px)); max-height: min(620px, calc(100% - 28px)); overflow: auto;
|
||||||
|
border: 1px solid rgba(255,255,255,.12); border-radius: 10px; background: #171717;
|
||||||
|
box-shadow: 0 20px 55px rgba(0,0,0,.48); padding: 14px; box-sizing: border-box; }
|
||||||
|
.cv-project-picker.hidden { display: none; }
|
||||||
|
.cv-project-picker-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 14px; }
|
||||||
|
.cv-project-picker-head b { display: block; color: var(--text); font-size: 15px; }
|
||||||
|
.cv-project-picker-head span { display: block; margin-top: 4px; color: var(--muted); font-size: 12px; }
|
||||||
|
.cv-project-close { width: 28px; height: 28px; border: 0; border-radius: 6px; color: var(--muted); background: transparent; font-size: 22px; line-height: 1; cursor: pointer; }
|
||||||
|
.cv-project-close:hover { color: var(--text); background: var(--cv-surface-2); }
|
||||||
|
.cv-project-actions { display: flex; justify-content: flex-end; margin: 12px 0 10px; }
|
||||||
|
.cv-project-list { display: grid; gap: 6px; }
|
||||||
|
.cv-project-item { display: grid; grid-template-columns: minmax(0,1fr) auto; gap: 12px; align-items: center; padding: 11px;
|
||||||
|
border: 1px solid rgba(255,255,255,.08); border-radius: 8px; background: #1c1c1c; }
|
||||||
|
.cv-project-item.active { border-color: rgba(59,130,246,.58); background: rgba(59,130,246,.08); }
|
||||||
|
.cv-project-info { min-width: 0; }
|
||||||
|
.cv-project-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text); font-size: 13px; font-weight: 680; }
|
||||||
|
.cv-project-title-input { width: 100%; height: 30px; box-sizing: border-box; border: 1px solid rgba(59,130,246,.7);
|
||||||
|
border-radius: 6px; outline: none; background: #111; color: var(--text); padding: 5px 8px; font: inherit; font-size: 13px; }
|
||||||
|
.cv-project-meta { margin-top: 4px; color: var(--muted); font-size: 11px; }
|
||||||
|
.cv-project-item-actions { display: flex; align-items: center; gap: 6px; }
|
||||||
|
.cv-project-icon { width: 30px; height: 30px; display: grid; place-items: center; padding: 0; border: 0; border-radius: 6px;
|
||||||
|
background: transparent; color: var(--muted); font-size: 16px; line-height: 1; cursor: pointer; }
|
||||||
|
.cv-project-icon:hover { background: var(--cv-surface-2); color: var(--text); }
|
||||||
|
.cv-project-icon.ok { color: #6ee7a0; }
|
||||||
|
.cv-project-open { min-height: 30px; }
|
||||||
|
.cv-project-empty { padding: 24px 8px; text-align: center; color: var(--muted); font-size: 13px; }
|
||||||
#drawflow { position: absolute; inset: 0; width: 100%; height: 100%;
|
#drawflow { position: absolute; inset: 0; width: 100%; height: 100%;
|
||||||
background-color: var(--bg-2);
|
z-index: 2; background-color: var(--cv-canvas);
|
||||||
background-image: radial-gradient(var(--border) 1px, transparent 1px);
|
background-image:
|
||||||
background-size: 22px 22px; }
|
linear-gradient(var(--cv-grid-major) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, var(--cv-grid-major) 1px, transparent 1px),
|
||||||
|
radial-gradient(var(--cv-grid-minor) 1px, transparent 1px);
|
||||||
|
background-size: 112px 112px, 112px 112px, 22px 22px;
|
||||||
|
background-position: -1px -1px, -1px -1px, 0 0; }
|
||||||
|
|
||||||
/* 悬浮缩放控件(右下角,覆盖在画布上) */
|
/* 悬浮缩放控件(右下角,覆盖在画布上) */
|
||||||
.cv-zoom { position: absolute; right: 14px; bottom: 14px; z-index: 15;
|
.cv-zoom { position: absolute; right: 14px; bottom: 14px; z-index: 15;
|
||||||
display: flex; flex-direction: column; gap: 1px; border-radius: 10px; overflow: hidden;
|
left: 50%; right: auto; transform: translateX(-50%);
|
||||||
border: 1px solid var(--border); box-shadow: var(--shadow); }
|
display: flex; align-items: center; justify-content: center; gap: 4px;
|
||||||
.cv-zoom-btn { border: 0; background: var(--card); color: var(--text);
|
width: min(436px, calc(100vw - 72px)); min-height: 46px; border-radius: 999px; overflow: hidden;
|
||||||
width: 38px; height: 34px; cursor: pointer; font-size: 17px; font-weight: 700;
|
border: 1px solid var(--border);
|
||||||
|
background: rgba(22,22,22,.96);
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,.28); padding: 5px; }
|
||||||
|
.cv-zoom-btn { border: 0; background: transparent; color: var(--text);
|
||||||
|
width: 30px; height: 30px; border-radius: 8px; cursor: pointer; font-size: 15px; font-weight: 750;
|
||||||
line-height: 1; display: flex; align-items: center; justify-content: center;
|
line-height: 1; display: flex; align-items: center; justify-content: center;
|
||||||
transition: .12s; user-select: none; }
|
transition: background-color .12s, color .12s, transform .12s; user-select: none; }
|
||||||
.cv-zoom-btn:hover { background: var(--accent); color: #fff; }
|
.cv-zoom-btn:hover { background: var(--cv-accent); color: #fff; }
|
||||||
.cv-zoom-btn:active { transform: scale(.94); }
|
.cv-zoom-btn:active { transform: scale(.94); }
|
||||||
.cv-zoom-level { font-size: 11px; font-weight: 600; color: var(--muted);
|
.cv-zoom-level { font-size: 11px; font-weight: 750; color: var(--text);
|
||||||
border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); }
|
min-width: 56px; padding: 0 8px; background: var(--cv-surface-2);
|
||||||
|
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--border) 62%, transparent); }
|
||||||
|
.cv-zoom-range { width: 128px; height: 18px; accent-color: var(--cv-accent); cursor: pointer; }
|
||||||
|
.cv-zoom-range::-webkit-slider-runnable-track { height: 4px; border-radius: 999px;
|
||||||
|
background: color-mix(in srgb, var(--border) 64%, var(--cv-accent-soft)); }
|
||||||
|
.cv-zoom-range::-webkit-slider-thumb { margin-top: -5px; width: 14px; height: 14px;
|
||||||
|
border-radius: 50%; background: var(--cv-accent); border: 2px solid #fff;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,.22); }
|
||||||
|
.cv-minimap { position: absolute; left: 18px; bottom: 18px; z-index: 15;
|
||||||
|
width: 202px; height: 152px; padding: 9px; border-radius: 12px;
|
||||||
|
border: 1px solid var(--border); background: rgba(22,22,22,.92);
|
||||||
|
box-shadow: 0 6px 16px rgba(15,23,42,.16); }
|
||||||
|
.cv-minimap-view { position: relative; width: 100%; height: 100%; border-radius: 6px;
|
||||||
|
background: #101010; box-shadow: inset 0 0 0 1px rgba(255,255,255,.08); }
|
||||||
|
.cv-minimap-view span { position: absolute; left: 50%; top: 50%; width: 34px; height: 9px;
|
||||||
|
transform: translate(-50%,-50%); border-radius: 2px; background: var(--cv-accent); }
|
||||||
|
.cv-empty-hint { position: absolute; left: 50%; top: 37%; z-index: 16;
|
||||||
|
display: flex; align-items: center; gap: 12px; transform: translate(-50%, -50%);
|
||||||
|
color: var(--muted); font-size: 14px; pointer-events: none; }
|
||||||
|
.cv-empty-hint span { display: inline-flex; align-items: center; gap: 5px; height: 34px;
|
||||||
|
padding: 0 15px; border-radius: 10px; color: var(--cv-accent-strong); background: var(--cv-surface);
|
||||||
|
border: 1px solid var(--border); box-shadow: 0 12px 30px rgba(0,0,0,.28); }
|
||||||
|
.cv-empty-hint b { font-size: 15px; color: var(--muted); font-weight: 660; }
|
||||||
|
.cv-quick-menu { position: absolute; z-index: 32; width: 240px; padding: 12px;
|
||||||
|
border-radius: 18px; border: 1px solid var(--border);
|
||||||
|
background: rgba(22,22,22,.98); box-shadow: 0 12px 32px rgba(0,0,0,.34); }
|
||||||
|
.cv-quick-title { color: var(--muted); font-size: 12px; padding: 2px 6px 8px; }
|
||||||
|
.cv-quick-menu button { width: 100%; border: 0; background: transparent; color: var(--text);
|
||||||
|
display: flex; align-items: center; gap: 14px; padding: 10px 8px; border-radius: 12px;
|
||||||
|
font: inherit; font-size: 14px; font-weight: 670; cursor: pointer; text-align: left;
|
||||||
|
transition: background-color .14s, color .14s, transform .14s; }
|
||||||
|
.cv-quick-menu button span { width: 32px; height: 32px; display: grid; place-items: center;
|
||||||
|
border-radius: 10px; color: var(--muted); background: var(--cv-surface-2); font-weight: 780; }
|
||||||
|
.cv-quick-menu button:hover { background: var(--cv-surface-2); transform: translateX(2px); }
|
||||||
|
.cv-quick-menu button:hover span { color: var(--cv-accent-strong); background: var(--cv-accent-soft); }
|
||||||
|
|
||||||
/* 未就绪遮罩 */
|
/* 未就绪遮罩 */
|
||||||
.cv-mask { position: absolute; inset: 0; z-index: 20; display: grid; place-items: center;
|
.cv-mask { position: absolute; inset: 0; z-index: 20; display: grid; place-items: center;
|
||||||
background: color-mix(in srgb, var(--bg) 78%, transparent);
|
background: color-mix(in srgb, var(--bg) 86%, transparent); }
|
||||||
backdrop-filter: blur(2px); }
|
|
||||||
.cv-mask-box { text-align: center; background: var(--card); border: 1px solid var(--border);
|
.cv-mask-box { text-align: center; background: var(--card); border: 1px solid var(--border);
|
||||||
border-radius: var(--radius); padding: 28px 32px; box-shadow: var(--shadow); max-width: 380px; }
|
border-radius: var(--radius); padding: 28px 32px; box-shadow: var(--shadow); max-width: 380px; }
|
||||||
.cv-mask-ico { font-size: 34px; }
|
.cv-mask-ico { font-size: 34px; }
|
||||||
@@ -552,30 +723,155 @@ h3 { margin: 0; font-size: 16px; }
|
|||||||
.cv-mask-text { color: var(--muted); font-size: 13px; margin: 0 0 14px; line-height: 1.6; }
|
.cv-mask-text { color: var(--muted); font-size: 13px; margin: 0 0 14px; line-height: 1.6; }
|
||||||
|
|
||||||
/* —— 覆写 Drawflow 节点为卡片风 —— */
|
/* —— 覆写 Drawflow 节点为卡片风 —— */
|
||||||
.drawflow .drawflow-node { background: transparent; border: 0; padding: 0;
|
.drawflow .drawflow-node { background: transparent !important; border: 0; padding: 0;
|
||||||
border-radius: 16px; box-shadow: none; width: 250px; }
|
border-radius: 12px; box-shadow: none; width: 250px; }
|
||||||
.drawflow .drawflow-node.selected { box-shadow: none; }
|
.drawflow .drawflow-node.cv-df-video,
|
||||||
|
.drawflow .drawflow-node.cv-df-grokVideo { width: 420px; }
|
||||||
|
.drawflow .drawflow-node.cv-df-image { width: 420px; }
|
||||||
|
.drawflow .drawflow-node.cv-df-tts,
|
||||||
|
.drawflow .drawflow-node.cv-df-text { width: 430px; }
|
||||||
|
.drawflow .drawflow-node.selected { background: transparent !important; box-shadow: none; }
|
||||||
.drawflow .drawflow-node .drawflow_content_node { width: 100%; }
|
.drawflow .drawflow-node .drawflow_content_node { width: 100%; }
|
||||||
.cv-node { background: var(--card); border: 1px solid var(--border);
|
.cv-node { background: var(--cv-surface); border: 1px solid color-mix(in srgb, var(--border) 72%, transparent);
|
||||||
border-radius: 16px; overflow: hidden; box-shadow: var(--shadow);
|
border-radius: 12px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,.22);
|
||||||
transition: border-color .15s; }
|
transition: border-color .12s; }
|
||||||
.drawflow-node:hover .cv-node { border-color: var(--accent); }
|
.drawflow-node:hover .cv-node { border-color: rgba(255,255,255,.14);
|
||||||
.drawflow-node.selected .cv-node { border-color: transparent;
|
box-shadow: 0 2px 8px rgba(0,0,0,.22); }
|
||||||
box-shadow: 0 0 0 2px var(--accent), var(--shadow); }
|
.drawflow-node.selected .cv-node { border-color: rgba(255,106,44,.42);
|
||||||
|
background: var(--cv-surface) !important;
|
||||||
|
box-shadow: 0 0 0 2px rgba(255,106,44,.16), 0 2px 8px rgba(0,0,0,.22); }
|
||||||
|
.drawflow .drawflow-node.cv-node-config-open { z-index: 30; }
|
||||||
|
.drawflow-node.selected .cv-node-body { background: transparent !important; }
|
||||||
.cv-node-head { display: flex; align-items: center; gap: 8px;
|
.cv-node-head { display: flex; align-items: center; gap: 8px;
|
||||||
padding: 9px 12px; background: var(--card-2); border-bottom: 1px solid var(--border); }
|
padding: 9px 11px; background: linear-gradient(180deg, var(--cv-surface-2), color-mix(in srgb, var(--cv-surface-2) 70%, transparent));
|
||||||
.cv-node-ico { font-size: 15px; }
|
border-bottom: 1px solid color-mix(in srgb, var(--border) 72%, transparent); }
|
||||||
.cv-node-name { font-weight: 700; font-size: 13px; color: var(--text); }
|
.cv-node-ico { display: grid; place-items: center; width: 22px; height: 22px;
|
||||||
.cv-node-tag { margin-left: auto; font-size: 10px; font-weight: 700; color: #f59e0b;
|
border-radius: 7px; font-size: 13px; background: var(--cv-accent-soft); }
|
||||||
background: rgba(245,158,11,.16); padding: 2px 7px; border-radius: 999px; }
|
.cv-node-name { font-weight: 750; font-size: 13px; color: var(--text); }
|
||||||
.cv-node-body { display: flex; flex-direction: column; gap: 8px; padding: 11px 12px; }
|
.cv-node-tag { margin-left: auto; font-size: 10px; font-weight: 750; color: var(--cv-warn);
|
||||||
|
background: rgba(217,119,6,.14); padding: 2px 7px; border-radius: 999px;
|
||||||
|
border: 1px solid rgba(217,119,6,.18); }
|
||||||
|
.cv-node-body { display: flex; flex-direction: column; gap: 8px; padding: 10px 11px 11px; }
|
||||||
.cv-node-hint { color: var(--muted); font-size: 11px; line-height: 1.5; }
|
.cv-node-hint { color: var(--muted); font-size: 11px; line-height: 1.5; }
|
||||||
|
.cv-df-video .cv-node,
|
||||||
|
.cv-df-grokVideo .cv-node,
|
||||||
|
.cv-df-image .cv-node,
|
||||||
|
.cv-df-tts .cv-node,
|
||||||
|
.cv-df-text .cv-node { background: transparent !important; border: 0; box-shadow: none;
|
||||||
|
overflow: visible; backdrop-filter: none; }
|
||||||
|
.cv-df-video .cv-node-head,
|
||||||
|
.cv-df-grokVideo .cv-node-head,
|
||||||
|
.cv-df-image .cv-node-head,
|
||||||
|
.cv-df-tts .cv-node-head,
|
||||||
|
.cv-df-text .cv-node-head { display: none; }
|
||||||
|
.cv-df-video .cv-node-body,
|
||||||
|
.cv-df-grokVideo .cv-node-body,
|
||||||
|
.cv-df-image .cv-node-body,
|
||||||
|
.cv-df-tts .cv-node-body,
|
||||||
|
.cv-df-text .cv-node-body { padding: 0; background: transparent !important; }
|
||||||
|
.cv-df-video.drawflow-node.selected .cv-node,
|
||||||
|
.cv-df-grokVideo.drawflow-node.selected .cv-node,
|
||||||
|
.cv-df-image.drawflow-node.selected .cv-node,
|
||||||
|
.cv-df-tts.drawflow-node.selected .cv-node,
|
||||||
|
.cv-df-text.drawflow-node.selected .cv-node { background: transparent !important; box-shadow: none !important; }
|
||||||
|
.cv-video-compact { position: relative; display: flex; flex-direction: column; align-items: center; gap: 12px;
|
||||||
|
padding-top: 14px; }
|
||||||
|
.cv-video-label { align-self: center; display: inline-flex; align-items: center; gap: 7px;
|
||||||
|
max-width: calc(100% - 44px); padding: 0 8px; color: var(--muted);
|
||||||
|
font-size: 13px; font-weight: 650; background: var(--cv-canvas);
|
||||||
|
border-radius: 999px; line-height: 1.2; white-space: nowrap; overflow: hidden;
|
||||||
|
text-overflow: ellipsis; }
|
||||||
|
.cv-video-label span { color: #777; flex: none; }
|
||||||
|
.cv-video-frame { width: 276px; max-width: calc(100% - 44px); min-height: 0; border: 1px solid var(--border);
|
||||||
|
border-radius: 16px; padding: 8px; background: var(--cv-surface);
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,.24); }
|
||||||
|
.drawflow-node.selected .cv-video-frame { border-color: rgba(255,106,44,.42);
|
||||||
|
box-shadow: 0 0 0 2px rgba(255,106,44,.14), 0 2px 8px rgba(0,0,0,.24); }
|
||||||
|
.cv-video-frame-inner { height: auto; aspect-ratio: var(--cv-video-preview-aspect, 16 / 9);
|
||||||
|
border-radius: 10px; border: 1px dashed rgba(255,255,255,.12);
|
||||||
|
display: grid; place-items: center; color: var(--muted); font-size: 34px; background: #101010;
|
||||||
|
overflow: hidden; }
|
||||||
|
.cv-video-frame-inner .cv-skeleton { width: 100%; height: 100%; border-radius: inherit; }
|
||||||
|
.cv-video-frame-inner .cv-preview-shell { width: 100%; height: 100%; border-radius: inherit; overflow: hidden; }
|
||||||
|
.cv-video-frame-inner .cv-preview-shell.cv-preview-video { background: #05070b; }
|
||||||
|
.cv-video-frame-inner .cv-preview-shell.cv-preview-image { background: #111; }
|
||||||
|
.cv-video-frame-inner .cv-preview-video .cv-media { width: 100%; height: 100%; max-height: none; object-fit: contain; background: #05070b; }
|
||||||
|
.cv-video-frame-inner .cv-preview-image .cv-media { width: 100%; height: 100%; max-height: none; object-fit: contain; background: #111; }
|
||||||
|
.cv-video-frame-inner .cv-meta { display: none; }
|
||||||
|
.cv-image-frame { width: 276px; max-width: calc(100% - 44px); min-height: 0; align-self: center; }
|
||||||
|
.cv-image-frame[data-ratio="16:9"],
|
||||||
|
.cv-image-frame[data-ratio="4:3"],
|
||||||
|
.cv-image-frame[data-ratio="3:2"] { width: 276px; max-width: calc(100% - 44px); }
|
||||||
|
.cv-image-frame[data-ratio="9:16"],
|
||||||
|
.cv-image-frame[data-ratio="3:4"] { width: 276px; max-width: calc(100% - 44px); }
|
||||||
|
.cv-image-frame .cv-video-frame-inner { height: auto; aspect-ratio: var(--cv-image-preview-aspect, 1 / 1);
|
||||||
|
min-height: 0; border-style: solid; border-color: rgba(255,255,255,.08); background: #111; font-size: 30px; }
|
||||||
|
.cv-image-compact .cv-video-foot { width: min(420px, calc(100% - 60px)); margin-top: -6px; }
|
||||||
|
.cv-audio-frame .cv-video-frame-inner,
|
||||||
|
.cv-text-frame .cv-video-frame-inner { aspect-ratio: 16 / 5; border-style: solid;
|
||||||
|
border-color: rgba(255,255,255,.08); font-size: 28px; }
|
||||||
|
.cv-video-foot { width: min(420px, 100%); display: flex; align-items: center;
|
||||||
|
justify-content: space-between; color: var(--muted); font-size: 12px; pointer-events: none; }
|
||||||
|
.cv-video-foot b { padding: 4px 8px; border-radius: 999px; background: var(--cv-accent-soft);
|
||||||
|
border: 1px solid rgba(255,106,44,.38); color: var(--cv-accent); font-size: 10px; font-weight: 500; }
|
||||||
|
.cv-video-composer { width: min(420px, 100%); min-height: 188px; margin-top: 0; padding: 12px;
|
||||||
|
border-radius: 16px; border: 1px solid var(--border);
|
||||||
|
background: var(--cv-surface); box-shadow: 0 2px 8px rgba(0,0,0,.24);
|
||||||
|
contain: layout paint style; display: none; flex-direction: column; gap: 8px; }
|
||||||
|
.cv-video-compact.cv-config-open .cv-video-composer { display: flex; }
|
||||||
|
.cv-video-compact.cv-config-open .cv-video-foot { display: none; }
|
||||||
|
.cv-tts-compact .cv-video-composer,
|
||||||
|
.cv-text-compact .cv-video-composer { min-height: 0; }
|
||||||
|
.cv-video-compose-tools { display: flex; align-items: center; gap: 4px; color: var(--muted);
|
||||||
|
font-size: 16px; height: 28px; }
|
||||||
|
.cv-video-compose-tools .cv-video-expand { margin-left: auto; font-size: 13px; }
|
||||||
|
.cv-video-prompt { min-height: 58px; border: 0; box-shadow: none; padding: 6px 0;
|
||||||
|
background: transparent; resize: none; color: var(--text); }
|
||||||
|
.cv-video-prompt:focus { box-shadow: none; border-color: transparent; }
|
||||||
|
.cv-video-chip-row { display: flex; align-items: flex-start; gap: 7px; flex-wrap: wrap; }
|
||||||
|
.cv-chip-select, .cv-chip-action, .cv-video-pop > summary { width: auto; min-width: 0; height: 27px;
|
||||||
|
border-radius: 999px !important; border: 1px solid var(--border) !important;
|
||||||
|
background: var(--cv-surface-2) !important; color: var(--muted) !important; font-size: 11px !important;
|
||||||
|
font-weight: 500; padding: 0 10px !important; resize: none; }
|
||||||
|
.cv-chip-select.cv-field-group { max-width: 132px; }
|
||||||
|
.cv-chip-select.cv-field-model { max-width: 150px; }
|
||||||
|
.cv-chip-action { cursor: pointer; }
|
||||||
|
.cv-video-pop { position: relative; flex: 0 0 auto; }
|
||||||
|
.cv-video-pop > summary { display: inline-flex; align-items: center; cursor: pointer; list-style: none; }
|
||||||
|
.cv-video-pop > summary::-webkit-details-marker { display: none; }
|
||||||
|
.cv-video-pop[open] { flex-basis: 100%; width: 100%; }
|
||||||
|
.cv-video-pop-body { position: static; left: auto; top: auto; transform: none;
|
||||||
|
z-index: 42; width: 100%; margin-top: 8px; padding: 12px; border-radius: 14px;
|
||||||
|
border: 1px solid var(--border); background: #141414;
|
||||||
|
box-shadow: none; contain: layout paint style; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
|
||||||
|
.cv-video-pop[open] > summary { color: var(--cv-accent-strong) !important; background: var(--cv-accent-soft) !important; }
|
||||||
|
.cv-video-pop-body label { display: grid; gap: 5px; min-width: 0; color: #7f8da1; font-size: 11px; font-weight: 650; }
|
||||||
|
.cv-video-pop-body .cv-pop-wide,
|
||||||
|
.cv-video-pop-body .cv-refimg-row,
|
||||||
|
.cv-video-pop-body .cv-field-refsource,
|
||||||
|
.cv-video-pop-body .cv-pop-close { grid-column: 1 / -1; }
|
||||||
|
.cv-video-pop-body .cv-field { height: 34px; border-radius: 10px; background: var(--cv-surface-2); color: var(--text); border-color: var(--border); }
|
||||||
|
.cv-video-pop-body .cv-field,
|
||||||
|
.cv-video-pop-body select,
|
||||||
|
.cv-video-pop-body input,
|
||||||
|
.cv-video-pop-body textarea { min-width: 0; width: 100%; box-sizing: border-box; }
|
||||||
|
.cv-pop-close { height: 32px; border: 0; border-radius: 10px; background: var(--cv-surface-2);
|
||||||
|
color: var(--muted); font-size: 12px; font-weight: 700; cursor: pointer; }
|
||||||
|
.cv-pop-close:hover { background: #242424; color: var(--text); }
|
||||||
|
.cv-video-compact.cv-pop-open .cv-video-foot { display: none !important; }
|
||||||
|
.cv-send-run { margin-left: auto; width: 34px; height: 34px; border-radius: 999px;
|
||||||
|
padding: 0; display: grid; place-items: center; background: #ffc7b8; color: #f26b2d; }
|
||||||
|
.cv-video-materials { margin-top: 8px; }
|
||||||
|
.cv-df-video .cv-node-preview,
|
||||||
|
.cv-df-grokVideo .cv-node-preview,
|
||||||
|
.cv-df-image .cv-node-preview,
|
||||||
|
.cv-df-tts .cv-node-preview { margin-top: 10px; }
|
||||||
|
|
||||||
/* 节点内表单控件 */
|
/* 节点内表单控件 */
|
||||||
.cv-field { width: 100%; background: var(--bg-2); border: 1px solid var(--border);
|
.cv-field { width: 100%; background: var(--cv-field); border: 1px solid color-mix(in srgb, var(--border) 76%, transparent);
|
||||||
color: var(--text); border-radius: 9px; padding: 8px 10px; font-size: 12px;
|
color: var(--text); border-radius: 8px; padding: 8px 10px; font-size: 12px;
|
||||||
font-family: inherit; outline: none; resize: vertical; }
|
font-family: inherit; outline: none; resize: vertical; }
|
||||||
.cv-field:focus { border-color: var(--accent); }
|
.cv-field:focus { border-color: var(--cv-accent); box-shadow: 0 0 0 3px var(--cv-accent-soft); }
|
||||||
select.cv-field { resize: none; cursor: pointer; }
|
select.cv-field { resize: none; cursor: pointer; }
|
||||||
|
|
||||||
/* 并排两个下拉(比例 + 清晰度 / 音色 + 格式 等) */
|
/* 并排两个下拉(比例 + 清晰度 / 音色 + 格式 等) */
|
||||||
@@ -585,34 +881,39 @@ select.cv-field { resize: none; cursor: pointer; }
|
|||||||
/* 模型输入框 + 每节点「拉取」小按钮 */
|
/* 模型输入框 + 每节点「拉取」小按钮 */
|
||||||
.cv-model-row { display: flex; gap: 6px; align-items: stretch; }
|
.cv-model-row { display: flex; gap: 6px; align-items: stretch; }
|
||||||
.cv-model-row .cv-field { flex: 1; min-width: 0; }
|
.cv-model-row .cv-field { flex: 1; min-width: 0; }
|
||||||
.cv-fetch-models { flex: none; border: 1px solid var(--border); background: var(--card-2);
|
.cv-fetch-models { flex: none; border: 1px solid color-mix(in srgb, var(--border) 76%, transparent); background: var(--cv-surface-2);
|
||||||
color: var(--muted); border-radius: 9px; padding: 0 10px; cursor: pointer;
|
color: var(--muted); border-radius: 8px; padding: 0 10px; cursor: pointer;
|
||||||
font-size: 11px; font-weight: 700; transition: .15s; }
|
font-size: 11px; font-weight: 700; transition: .15s; }
|
||||||
.cv-fetch-models:hover { border-color: var(--accent); color: var(--accent); }
|
.cv-fetch-models:hover { border-color: var(--cv-accent); color: var(--cv-accent); }
|
||||||
.cv-fetch-models:disabled { opacity: .5; cursor: not-allowed; }
|
.cv-fetch-models:disabled { opacity: .5; cursor: not-allowed; }
|
||||||
|
|
||||||
/* 素材上传控件(图片节点参考图 / 视频节点首帧·尾帧·多参考图)——走自建图床 */
|
/* 素材上传控件(图片节点参考图 / 视频节点首帧·尾帧·多参考图)——走自建图床 */
|
||||||
.cv-refimg-row, .cv-vmat { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
|
.cv-refimg-row, .cv-vmat { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
|
||||||
.cv-refimg-pick { display: inline-flex; align-items: center; gap: 5px; cursor: pointer;
|
.cv-refimg-pick { display: inline-flex; align-items: center; gap: 5px; cursor: pointer;
|
||||||
border: 1px dashed var(--accent); color: var(--accent); background: var(--card-2);
|
border: 1px dashed rgba(255,106,44,.52); color: var(--cv-accent); background: var(--cv-accent-soft);
|
||||||
border-radius: 9px; padding: 7px 12px; font-size: 11px; font-weight: 700;
|
border-radius: 8px; padding: 7px 12px; font-size: 11px; font-weight: 700;
|
||||||
transition: .15s; white-space: nowrap; user-select: none; }
|
transition: .15s; white-space: nowrap; user-select: none; }
|
||||||
.cv-refimg-pick:hover { background: var(--accent); color: #fff; }
|
.cv-refimg-pick:hover { background: var(--cv-accent); color: #fff; }
|
||||||
.cv-refimg-thumb, .cv-vmat-thumb { border: 1px solid var(--border); }
|
.cv-refimg-thumb, .cv-vmat-thumb { flex: none; display: block; width: 42px; height: 42px;
|
||||||
.cv-refimg-clear, .cv-vmat-clear { flex: none; border: 1px solid var(--border);
|
max-width: 42px; max-height: 42px; object-fit: cover;
|
||||||
background: var(--card-2); color: var(--muted); border-radius: 7px; cursor: pointer;
|
border: 1px solid color-mix(in srgb, var(--border) 76%, transparent); border-radius: 8px; }
|
||||||
|
.cv-refimg-clear, .cv-vmat-clear { flex: none; border: 1px solid color-mix(in srgb, var(--border) 76%, transparent);
|
||||||
|
background: var(--cv-surface-2); color: var(--muted); border-radius: 7px; cursor: pointer;
|
||||||
width: 24px; height: 24px; font-size: 12px; line-height: 1; padding: 0; transition: .15s; }
|
width: 24px; height: 24px; font-size: 12px; line-height: 1; padding: 0; transition: .15s; }
|
||||||
.cv-refimg-clear:hover, .cv-vmat-clear:hover { border-color: var(--danger); color: var(--danger); }
|
.cv-refimg-clear:hover, .cv-vmat-clear:hover { border-color: var(--danger); color: var(--danger); }
|
||||||
/* URL 输入 / 提示 / 多图缩略图 各自独占一行 */
|
/* URL 输入 / 提示 / 多图缩略图 各自独占一行 */
|
||||||
.cv-vmat-url, .cv-vmat-urls { flex: 1 1 100%; }
|
.cv-vmat-url, .cv-vmat-urls { flex: 1 1 100%; }
|
||||||
.cv-refimg-row .cv-node-hint, .cv-vmat .cv-node-hint { flex: 1 1 100%; }
|
.cv-refimg-row .cv-node-hint, .cv-vmat .cv-node-hint { flex: 1 1 100%; }
|
||||||
.cv-vmat-refs-thumbs { display: flex; flex-wrap: wrap; gap: 2px; flex: 1 1 100%; }
|
.cv-vmat-refs-thumbs { display: flex; flex-wrap: wrap; gap: 2px; flex: 1 1 100%; }
|
||||||
|
.cv-vmat-refs-thumbs img { width: 34px !important; height: 34px !important;
|
||||||
|
max-width: 34px !important; max-height: 34px !important; object-fit: cover;
|
||||||
|
border-radius: 7px; border: 1px solid color-mix(in srgb, var(--border) 76%, transparent); }
|
||||||
/* 复选标签(生成音频 / 水印 / 固定镜头)对齐 */
|
/* 复选标签(生成音频 / 水印 / 固定镜头)对齐 */
|
||||||
.cv-check { display: inline-flex; align-items: center; gap: 5px; font-size: 11px;
|
.cv-check { display: inline-flex; align-items: center; gap: 5px; font-size: 11px;
|
||||||
color: var(--muted); cursor: pointer; user-select: none; }
|
color: var(--muted); cursor: pointer; user-select: none; }
|
||||||
|
|
||||||
/* 折叠「高级」区(接口地址覆盖),别把节点撑太高 */
|
/* 折叠「高级」区(接口地址覆盖),别把节点撑太高 */
|
||||||
.cv-adv { border-top: 1px dashed var(--border); padding-top: 6px; }
|
.cv-adv { border-top: 1px dashed color-mix(in srgb, var(--border) 78%, transparent); padding-top: 6px; }
|
||||||
.cv-adv > summary { cursor: pointer; color: var(--muted); font-size: 11px;
|
.cv-adv > summary { cursor: pointer; color: var(--muted); font-size: 11px;
|
||||||
font-weight: 600; list-style: none; user-select: none; }
|
font-weight: 600; list-style: none; user-select: none; }
|
||||||
.cv-adv > summary::-webkit-details-marker { display: none; }
|
.cv-adv > summary::-webkit-details-marker { display: none; }
|
||||||
@@ -621,29 +922,39 @@ select.cv-field { resize: none; cursor: pointer; }
|
|||||||
.cv-adv[open] > summary { margin-bottom: 6px; }
|
.cv-adv[open] > summary { margin-bottom: 6px; }
|
||||||
|
|
||||||
/* 运行按钮(沿用主色渐变观感) */
|
/* 运行按钮(沿用主色渐变观感) */
|
||||||
.cv-run { align-self: stretch; border: 0; background: var(--grad); color: #fff;
|
.cv-run { align-self: stretch; border: 0; background: var(--cv-accent); color: #fff;
|
||||||
padding: 8px 14px; border-radius: 9px; cursor: pointer; font-size: 12px;
|
padding: 8px 14px; border-radius: 8px; cursor: pointer; font-size: 12px;
|
||||||
font-weight: 700; transition: .15s; }
|
font-weight: 700; transition: .15s; }
|
||||||
.cv-run:hover { filter: brightness(1.08); }
|
.cv-run:hover { background: var(--cv-accent-strong); filter: none; }
|
||||||
.cv-run:disabled { opacity: .6; cursor: not-allowed; }
|
.cv-run:disabled { opacity: .6; cursor: not-allowed; }
|
||||||
|
.cv-video-chip-row .cv-send-run { align-self: auto; flex: none; margin-left: auto;
|
||||||
|
width: 34px; height: 34px; border-radius: 999px; padding: 0; display: grid; place-items: center;
|
||||||
|
background: var(--cv-accent); color: #0a0a0a; box-shadow: none; overflow: hidden; white-space: nowrap; }
|
||||||
|
.cv-video-chip-row .cv-send-run:hover { background: var(--cv-accent-strong); color: #0a0a0a; }
|
||||||
|
.cv-video-chip-row .cv-send-run.loading { color: transparent; position: relative; }
|
||||||
|
.cv-video-chip-row .cv-send-run.loading::after { content: ""; position: absolute; width: 14px; height: 14px;
|
||||||
|
border-radius: 999px; border: 2px solid rgba(242,107,45,.24); border-top-color: #f26b2d;
|
||||||
|
animation: cv-rot .7s linear infinite; }
|
||||||
|
|
||||||
/* 错误红字 */
|
/* 错误红字 */
|
||||||
.cv-node-err { color: var(--danger); font-size: 11px; line-height: 1.5;
|
.cv-node-err { color: var(--danger); font-size: 11px; line-height: 1.5;
|
||||||
background: rgba(255,107,107,.1); border-radius: 8px; padding: 6px 9px; }
|
background: rgba(255,107,107,.1); border-radius: 8px; padding: 6px 9px; }
|
||||||
|
|
||||||
/* 预览区(img/video/audio)圆角、限高、加载骨架 */
|
/* 预览区(img/video/audio)圆角、限高、加载骨架 */
|
||||||
.cv-node-preview { border-radius: 10px; overflow: hidden; background: var(--bg-2);
|
.cv-node-preview { border-radius: 9px; overflow: hidden; background: var(--cv-field);
|
||||||
border: 1px solid var(--border); }
|
border: 1px solid color-mix(in srgb, var(--border) 76%, transparent); }
|
||||||
.cv-preview-shell { position: relative; background: #000; }
|
.cv-preview-shell { position: relative; background: #000; }
|
||||||
|
.cv-preview-shell.cv-preview-audio { display: grid; place-items: center; min-height: 92px; background: #111; }
|
||||||
.cv-preview-actions { position: absolute; top: 6px; right: 6px; display: flex; gap: 5px;
|
.cv-preview-actions { position: absolute; top: 6px; right: 6px; display: flex; gap: 5px;
|
||||||
z-index: 2; opacity: .92; }
|
z-index: 2; opacity: .92; }
|
||||||
.cv-preview-actions button { width: 26px; height: 26px; display: grid; place-items: center;
|
.cv-preview-actions button { width: 26px; height: 26px; display: grid; place-items: center;
|
||||||
border: 1px solid rgba(255,255,255,.22); border-radius: 7px; background: rgba(15,17,23,.78);
|
border: 1px solid rgba(255,255,255,.22); border-radius: 7px; background: rgba(15,17,23,.78);
|
||||||
color: #fff; cursor: pointer; font-size: 13px; line-height: 1; padding: 0; }
|
color: #fff; cursor: pointer; font-size: 13px; line-height: 1; padding: 0; }
|
||||||
.cv-preview-actions button:hover { background: rgba(90,103,216,.92); border-color: rgba(255,255,255,.4); }
|
.cv-preview-actions button:hover { background: rgba(255,106,44,.92); border-color: rgba(255,255,255,.4); }
|
||||||
.cv-media { display: block; width: 100%; max-height: 220px; object-fit: contain;
|
.cv-media { display: block; width: 100%; max-height: 220px; object-fit: contain;
|
||||||
background: #000; }
|
background: #000; }
|
||||||
.cv-media-audio { display: block; width: 100%; }
|
.cv-media-audio { display: block; width: 100%; }
|
||||||
|
.cv-preview-audio .cv-media-audio { width: calc(100% - 16px); margin: 34px 8px 8px; }
|
||||||
.cv-meta { font-size: 10px; color: var(--muted); padding: 5px 8px;
|
.cv-meta { font-size: 10px; color: var(--muted); padding: 5px 8px;
|
||||||
border-top: 1px solid var(--border); }
|
border-top: 1px solid var(--border); }
|
||||||
.cv-skeleton { display: flex; align-items: center; justify-content: center; gap: 8px;
|
.cv-skeleton { display: flex; align-items: center; justify-content: center; gap: 8px;
|
||||||
@@ -652,25 +963,59 @@ select.cv-field { resize: none; cursor: pointer; }
|
|||||||
background-size: 200% 100%; animation: cv-shimmer 1.2s linear infinite; }
|
background-size: 200% 100%; animation: cv-shimmer 1.2s linear infinite; }
|
||||||
@keyframes cv-shimmer { from { background-position: 200% 0; } to { background-position: -200% 0; } }
|
@keyframes cv-shimmer { from { background-position: 200% 0; } to { background-position: -200% 0; } }
|
||||||
.cv-spin { width: 13px; height: 13px; border-radius: 50%;
|
.cv-spin { width: 13px; height: 13px; border-radius: 50%;
|
||||||
border: 2px solid var(--border); border-top-color: var(--accent);
|
border: 2px solid var(--border); border-top-color: var(--cv-accent);
|
||||||
animation: cv-rot .7s linear infinite; }
|
animation: cv-rot .7s linear infinite; }
|
||||||
@keyframes cv-rot { to { transform: rotate(360deg); } }
|
@keyframes cv-rot { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
/* —— 端口与连线用主题色 —— */
|
/* —— 端口与连线用主题色 —— */
|
||||||
.drawflow .drawflow-node .input,
|
.drawflow .drawflow-node .input,
|
||||||
.drawflow .drawflow-node .output { background: var(--card);
|
.drawflow .drawflow-node .output { width: 28px; height: 28px; border: 0;
|
||||||
border: 2px solid var(--accent); width: 13px; height: 13px; }
|
background: transparent; box-shadow: none; opacity: 1; z-index: 8;
|
||||||
.drawflow .drawflow-node .output:hover,
|
position: absolute !important; top: var(--cv-port-y, 50%) !important; margin-bottom: 0 !important; }
|
||||||
.drawflow .drawflow-node .input:hover { background: var(--accent); }
|
.drawflow .drawflow-node .input { pointer-events: none; }
|
||||||
.drawflow .connection .main-path { stroke: var(--accent); stroke-width: 2.4px; }
|
.drawflow .drawflow-node .output { pointer-events: auto; }
|
||||||
|
.drawflow .drawflow-node .input { left: var(--cv-port-left, 0) !important; transform: translate(-50%, -50%) !important; }
|
||||||
|
.drawflow .drawflow-node .output { left: var(--cv-port-right, 100%) !important; right: auto !important; transform: translate(-50%, -50%) !important; }
|
||||||
|
.drawflow .drawflow-node .input::after,
|
||||||
|
.drawflow .drawflow-node .output::after { content: "+"; position: absolute; left: 50%; top: 50%;
|
||||||
|
display: grid; place-items: center; color: var(--text); font-size: 16px; font-weight: 720; line-height: 1;
|
||||||
|
width: 22px; height: 22px; border-radius: 999px; transform: translate(-50%, -50%) scale(.86);
|
||||||
|
background: var(--cv-surface-2); border: 1px solid rgba(255,255,255,.16);
|
||||||
|
box-shadow: none;
|
||||||
|
opacity: 0; pointer-events: none; transition: opacity .10s ease, color .10s ease, border-color .10s ease; }
|
||||||
|
.drawflow .drawflow-node.selected .output::after,
|
||||||
|
.drawflow .drawflow-node.cv-node-config-open .output::after,
|
||||||
|
#drawflow.cv-linking .drawflow-node .input::after,
|
||||||
|
#drawflow.cv-linking .drawflow-node .output::after {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translate(-50%, -50%) scale(1);
|
||||||
|
}
|
||||||
|
#drawflow.cv-linking .drawflow-node .input::after { opacity: 1; }
|
||||||
|
#drawflow.cv-dragging .cv-node,
|
||||||
|
#drawflow.cv-dragging .cv-video-frame,
|
||||||
|
#drawflow.cv-dragging .cv-video-composer,
|
||||||
|
#drawflow.cv-dragging .cv-skeleton,
|
||||||
|
#drawflow.cv-dragging .cv-spin,
|
||||||
|
#drawflow.cv-dragging .drawflow-node .input::after,
|
||||||
|
#drawflow.cv-dragging .drawflow-node .output::after {
|
||||||
|
box-shadow: none !important;
|
||||||
|
transition: none !important;
|
||||||
|
animation-play-state: paused !important;
|
||||||
|
}
|
||||||
|
#drawflow.cv-dragging .drawflow-node.selected { will-change: transform; }
|
||||||
|
#drawflow.cv-linking .drawflow-node .input { pointer-events: auto; }
|
||||||
|
#drawflow.cv-linking .drawflow-node .output:hover::after,
|
||||||
|
#drawflow.cv-linking .drawflow-node .input:hover::after { color: var(--cv-accent-strong); border-color: var(--cv-accent-strong); }
|
||||||
|
.drawflow .connection .main-path { stroke: var(--cv-accent); stroke-width: 2.3px; }
|
||||||
.drawflow .connection .main-path:hover { stroke: var(--text); }
|
.drawflow .connection .main-path:hover { stroke: var(--text); }
|
||||||
.drawflow .connection .point { stroke: var(--accent); fill: var(--card); }
|
.drawflow .connection .point { stroke: var(--cv-accent); fill: var(--cv-surface); }
|
||||||
|
|
||||||
/* —— 沉浸式全屏:fixed 覆盖整个应用窗口(含顶部导航) —— */
|
/* —— 沉浸式全屏:fixed 覆盖整个应用窗口(含顶部导航) —— */
|
||||||
body.cv-noscroll { overflow: hidden; }
|
body.cv-noscroll { overflow: hidden; }
|
||||||
#tab-canvas.cv-fullscreen { position: fixed; inset: 0; z-index: 9999; margin: 0;
|
#tab-canvas.cv-fullscreen { position: fixed; inset: 0; z-index: 9999; margin: 0;
|
||||||
height: auto; padding: 14px 16px; background: var(--bg); }
|
height: auto; padding: 0; background: var(--cv-canvas); }
|
||||||
#tab-canvas.cv-fullscreen .cv-stage { min-height: 0; border-radius: 12px; }
|
#tab-canvas.cv-fullscreen .cv-stage { min-height: 0; border-radius: 11px; }
|
||||||
|
#tab-canvas.cv-fullscreen .cv-toolbar { padding: 8px 10px; }
|
||||||
|
|
||||||
/* —— 语音输入面板 —— */
|
/* —— 语音输入面板 —— */
|
||||||
.voice-body { display: grid; gap: 14px; max-width: 760px; }
|
.voice-body { display: grid; gap: 14px; max-width: 760px; }
|
||||||
@@ -682,9 +1027,9 @@ body.cv-noscroll { overflow: hidden; }
|
|||||||
.voice-dot { width: 10px; height: 10px; border-radius: 999px; display: inline-block; background: #6b7280; flex: none; }
|
.voice-dot { width: 10px; height: 10px; border-radius: 999px; display: inline-block; background: #6b7280; flex: none; }
|
||||||
.voice-dot[data-state="connected"] { background: #34d399; box-shadow: 0 0 8px rgba(52,211,153,.7); }
|
.voice-dot[data-state="connected"] { background: #34d399; box-shadow: 0 0 8px rgba(52,211,153,.7); }
|
||||||
.voice-dot[data-state="transcribing"], .voice-dot[data-state="recording"] { background: #fbbf24; box-shadow: 0 0 8px rgba(251,191,36,.7); }
|
.voice-dot[data-state="transcribing"], .voice-dot[data-state="recording"] { background: #fbbf24; box-shadow: 0 0 8px rgba(251,191,36,.7); }
|
||||||
.voice-dot[data-state="restarting"], .voice-dot[data-state="connecting"], .voice-dot[data-state="injecting"], .voice-dot[data-state="checking"] { background: #60a5fa; }
|
.voice-dot[data-state="restarting"], .voice-dot[data-state="waiting_debug_port"], .voice-dot[data-state="connecting"], .voice-dot[data-state="injecting"], .voice-dot[data-state="checking"] { background: #60a5fa; }
|
||||||
.voice-dot[data-state="error"] { background: #ef4444; box-shadow: 0 0 8px rgba(239,68,68,.7); }
|
.voice-dot[data-state="error"] { background: #ef4444; box-shadow: 0 0 8px rgba(239,68,68,.7); }
|
||||||
.voice-dot[data-state="running_no_port"] { background: #f59e0b; }
|
.voice-dot[data-state="running_no_port"], .voice-dot[data-state="unsupported_packaged"] { background: #f59e0b; }
|
||||||
.codex-link-status {
|
.codex-link-status {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -712,6 +1057,7 @@ body.cv-noscroll { overflow: hidden; }
|
|||||||
.tm-field { display: flex; flex-direction: column; gap: 6px; }
|
.tm-field { display: flex; flex-direction: column; gap: 6px; }
|
||||||
.tm-label { font-size: 12.5px; font-weight: 600; color: var(--text); }
|
.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-hint { font-size: 11.5px; color: var(--muted); line-height: 1.4; }
|
||||||
|
.tm-hint strong { color: var(--text); }
|
||||||
.tm-row { display: flex; gap: 8px; align-items: center; }
|
.tm-row { display: flex; gap: 8px; align-items: center; }
|
||||||
.tm-select, .tm-text { background: var(--card); border: 1px solid var(--border); color: var(--text);
|
.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; }
|
border-radius: 9px; padding: 8px 10px; font-size: 13px; font-family: inherit; flex: 1; min-width: 0; }
|
||||||
@@ -742,6 +1088,8 @@ body.cv-noscroll { overflow: hidden; }
|
|||||||
.tm-team-name { font-size: 18px; font-weight: 700; }
|
.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-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-team-stack { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
|
||||||
|
.tm-team-models { margin-top: 8px; color: var(--muted); font-size: 12px; }
|
||||||
|
.tm-team-models code, .tm-emp-model code { color: var(--text); font-family: ui-monospace, monospace; }
|
||||||
.tm-stack-chip { background: var(--card-2); border: 1px solid var(--border); color: var(--text);
|
.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; }
|
padding: 3px 10px; border-radius: 20px; font-size: 11.5px; }
|
||||||
.tm-conv-card, .tm-emp-card { background: var(--card); border: 1px solid var(--border);
|
.tm-conv-card, .tm-emp-card { background: var(--card); border: 1px solid var(--border);
|
||||||
@@ -758,6 +1106,7 @@ body.cv-noscroll { overflow: hidden; }
|
|||||||
.tm-emp-id { font-family: ui-monospace, monospace; font-size: 11.5px; color: var(--muted);
|
.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; }
|
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-emp-duty { font-size: 13px; color: var(--text); margin-top: 5px; line-height: 1.5; }
|
||||||
|
.tm-emp-model { margin-top: 5px; color: var(--muted); font-size: 11.5px; }
|
||||||
.tm-sec-label { font-size: 11.5px; font-weight: 600; color: var(--accent); margin: 10px 0 4px; }
|
.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-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-collab { display: flex; flex-direction: column; gap: 3px; margin-top: 8px; }
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"baseUrl": "https://api.tokendun.com",
|
||||||
|
"key": "replace-at-release-time",
|
||||||
|
"model": "MiniMax-M3",
|
||||||
|
"sttModel": ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
// 在发布机执行:把免费多模态网关凭据写进未跟踪的打包资源。
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const key = String(process.env.TD_MULTIMODAL_GATEWAY_KEY || "").trim();
|
||||||
|
if (!key) {
|
||||||
|
console.error("缺少 TD_MULTIMODAL_GATEWAY_KEY,未生成多模态网关配置。");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectTarget = path.join(__dirname, "..", "resources", "multimodal-gateway.json");
|
||||||
|
const userDataTarget = path.join(
|
||||||
|
process.env.TD_MULTIMODAL_USER_DATA_DIR || path.join(process.env.HOME || process.cwd(), "Library", "Application Support", "codex-market"),
|
||||||
|
"multimodal-gateway.json"
|
||||||
|
);
|
||||||
|
const config = {
|
||||||
|
baseUrl: String(process.env.TD_MULTIMODAL_GATEWAY_URL || "https://api.tokendun.com").trim(),
|
||||||
|
key,
|
||||||
|
model: String(process.env.TD_MULTIMODAL_MODEL || "MiniMax-M3").trim(),
|
||||||
|
sttModel: String(process.env.TD_MULTIMODAL_STT_MODEL || "").trim(),
|
||||||
|
};
|
||||||
|
for (const target of Array.from(new Set([projectTarget, userDataTarget]))) {
|
||||||
|
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||||
|
fs.writeFileSync(target, JSON.stringify(config, null, 2) + "\n", { mode: 0o600 });
|
||||||
|
console.log(`已生成 ${path.relative(process.cwd(), target)}`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// publish-codex-installer.js —— 发布 Codex 安装器资源到自建 gitea。
|
||||||
|
//
|
||||||
|
// 用法:
|
||||||
|
// node scripts/publish-codex-installer.js /Users/mac/Downloads/Codex-Windows-x64.msix
|
||||||
|
// node scripts/publish-codex-installer.js /Users/mac/Downloads/ChatGPT.dmg
|
||||||
|
// token 可设置 GIT_TOKEN,或写入 ~/.tokendun-git-token。
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const fs = require("fs");
|
||||||
|
const os = require("os");
|
||||||
|
const path = require("path");
|
||||||
|
const crypto = require("crypto");
|
||||||
|
const { execFileSync, spawnSync } = require("child_process");
|
||||||
|
|
||||||
|
const OWNER = "mhgmh";
|
||||||
|
const REPO = "codex-market-app";
|
||||||
|
const TAG = "codex-installers";
|
||||||
|
const MSIX_NAME = "Codex-Windows-x64.msix";
|
||||||
|
const DMG_NAME = "ChatGPT.dmg";
|
||||||
|
const ALLOWED_NAMES = new Set([MSIX_NAME, DMG_NAME]);
|
||||||
|
const API = `https://up.tokendun.cc/api/v1/repos/${OWNER}/${REPO}`;
|
||||||
|
const DL = `https://update.tokendun.cc/${OWNER}/${REPO}/releases/download/${TAG}`;
|
||||||
|
|
||||||
|
const installerPath = path.resolve(process.argv[2] || "/Users/mac/Downloads/Codex-Windows-x64.msix");
|
||||||
|
const installerName = path.basename(installerPath);
|
||||||
|
if (!ALLOWED_NAMES.has(installerName)) {
|
||||||
|
console.error(`安装包文件名必须是 ${Array.from(ALLOWED_NAMES).join(" 或 ")},当前是 ${installerName}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
if (!fs.existsSync(installerPath)) {
|
||||||
|
console.error(`安装包不存在:${installerPath}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let token = process.env.GIT_TOKEN || "";
|
||||||
|
if (!token) {
|
||||||
|
try { token = fs.readFileSync(path.join(os.homedir(), ".tokendun-git-token"), "utf8").trim(); } catch {}
|
||||||
|
}
|
||||||
|
if (!token) {
|
||||||
|
console.error("缺少 gitea token:设置 GIT_TOKEN 或写入 ~/.tokendun-git-token");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hashFile(file) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const h = crypto.createHash("sha256");
|
||||||
|
fs.createReadStream(file)
|
||||||
|
.on("data", (chunk) => h.update(chunk))
|
||||||
|
.on("error", reject)
|
||||||
|
.on("end", () => resolve(h.digest("hex")));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function api(method, urlPath, body) {
|
||||||
|
const args = ["-s", "-X", method, "-H", `Authorization: token ${token}`, `${API}${urlPath}`];
|
||||||
|
if (body !== undefined) {
|
||||||
|
args.push("-H", "Content-Type: application/json", "-d", JSON.stringify(body));
|
||||||
|
}
|
||||||
|
return execFileSync("curl", args, { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function apiJson(method, urlPath, body) {
|
||||||
|
const out = api(method, urlPath, body);
|
||||||
|
try { return JSON.parse(out); } catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function upload(rel, file, name) {
|
||||||
|
const result = spawnSync("curl", [
|
||||||
|
"--progress-bar",
|
||||||
|
"-o", "/dev/null",
|
||||||
|
"-w", "%{http_code}",
|
||||||
|
"-X", "POST",
|
||||||
|
"-H", `Authorization: token ${token}`,
|
||||||
|
"-F", `attachment=@${file};type=application/octet-stream`,
|
||||||
|
`${API}/releases/${rel.id}/assets?name=${encodeURIComponent(name)}`,
|
||||||
|
], { encoding: "utf8", stdio: ["ignore", "pipe", "inherit"], maxBuffer: 64 * 1024 * 1024 });
|
||||||
|
if (result.error) throw result.error;
|
||||||
|
const code = String(result.stdout || "").trim();
|
||||||
|
console.log(`上传 ${name} → HTTP ${code}`);
|
||||||
|
if (code !== "201") {
|
||||||
|
console.error(`上传失败:${name}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const stat = fs.statSync(installerPath);
|
||||||
|
console.log(`准备发布 ${installerName}:${(stat.size / 1048576).toFixed(2)}MB`);
|
||||||
|
const sha256 = await hashFile(installerPath);
|
||||||
|
const shaPath = path.join(os.tmpdir(), `${installerName}.sha256`);
|
||||||
|
fs.writeFileSync(shaPath, `${sha256} ${installerName}\n`);
|
||||||
|
console.log(`sha256=${sha256}`);
|
||||||
|
|
||||||
|
let rel = apiJson("GET", `/releases/tags/${encodeURIComponent(TAG)}`);
|
||||||
|
if (!rel || !rel.id) {
|
||||||
|
console.log(`创建 release ${TAG} …`);
|
||||||
|
rel = apiJson("POST", "/releases", {
|
||||||
|
tag_name: TAG,
|
||||||
|
target_commitish: "main",
|
||||||
|
name: "Codex 安装器资源",
|
||||||
|
body: "TD工具箱内置安装器使用的远程安装包资源。",
|
||||||
|
draft: false,
|
||||||
|
prerelease: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!rel || !rel.id) {
|
||||||
|
console.error(`无法创建/获取 release ${TAG}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = new Map((rel.assets || []).map((a) => [a.name, a.id]));
|
||||||
|
for (const name of [installerName, `${installerName}.sha256`]) {
|
||||||
|
if (existing.has(name)) {
|
||||||
|
api("DELETE", `/releases/${rel.id}/assets/${existing.get(name)}`);
|
||||||
|
console.log(`删除旧 asset: ${name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
upload(rel, installerPath, installerName);
|
||||||
|
upload(rel, shaPath, `${installerName}.sha256`);
|
||||||
|
|
||||||
|
console.log("\n发布完成:");
|
||||||
|
console.log(` ${DL}/${installerName}`);
|
||||||
|
console.log(` ${DL}/${installerName}.sha256`);
|
||||||
|
})();
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const media = require("../electron/lib/media-understanding");
|
||||||
|
const { gatewayConfig } = require("../electron/lib/multimodal-config");
|
||||||
|
|
||||||
|
const imagePath = process.argv[2] || path.join(__dirname, "..", "renderer", "logo.png");
|
||||||
|
const config = gatewayConfig();
|
||||||
|
|
||||||
|
if (!config.key) {
|
||||||
|
console.error("缺少多模态网关 Key:请先生成 resources/multimodal-gateway.json,或设置 TD_MULTIMODAL_GATEWAY_KEY。");
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext = path.extname(imagePath).toLowerCase();
|
||||||
|
const mime =
|
||||||
|
ext === ".jpg" || ext === ".jpeg" ? "image/jpeg" :
|
||||||
|
ext === ".webp" ? "image/webp" :
|
||||||
|
"image/png";
|
||||||
|
const data = fs.readFileSync(imagePath).toString("base64");
|
||||||
|
const request = {
|
||||||
|
input: [{
|
||||||
|
type: "message",
|
||||||
|
role: "user",
|
||||||
|
content: [
|
||||||
|
{ type: "input_text", text: "请识别这张图片里的主要内容和可见文字。" },
|
||||||
|
{ type: "input_image", image_url: `data:${mime};base64,${data}` },
|
||||||
|
],
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
|
||||||
|
media.enrichResponsesRequest(request, { instruction: "请识别这张图片里的主要内容和可见文字。" })
|
||||||
|
.then((result) => {
|
||||||
|
if (!result.processed) throw new Error("没有检测到可处理的图片附件");
|
||||||
|
const evidence = result.evidence || {};
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
provider: evidence.provider,
|
||||||
|
modality: evidence.modality,
|
||||||
|
summary: evidence.summary,
|
||||||
|
items: evidence.items,
|
||||||
|
unsupported: evidence.unsupported,
|
||||||
|
}, null, 2));
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error(String((error && error.message) || error));
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
const test = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
|
||||||
|
const { collectUpstreamOutputs } = require("../renderer/canvas-inputs.js");
|
||||||
|
|
||||||
|
function nodeWithConnections(connections) {
|
||||||
|
return { inputs: { input_1: { connections } } };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("collects text from a connected text node for image prompts", () => {
|
||||||
|
const result = collectUpstreamOutputs(
|
||||||
|
nodeWithConnections([{ node: 1 }]),
|
||||||
|
() => ({ text: "a cinematic desk lamp", image: "" })
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(result.text, "a cinematic desk lamp");
|
||||||
|
assert.deepEqual(result.images, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("exposes the first connected image for first-frame video", () => {
|
||||||
|
const result = collectUpstreamOutputs(
|
||||||
|
nodeWithConnections([{ node: 1 }]),
|
||||||
|
() => ({ image: "data:image/png;base64,first" })
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(result.image, "data:image/png;base64,first");
|
||||||
|
assert.deepEqual(result.images, ["data:image/png;base64,first"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps every connected image in connection order and removes duplicates", () => {
|
||||||
|
const outputs = {
|
||||||
|
1: { image: "data:image/png;base64,one" },
|
||||||
|
2: { image: "data:image/png;base64,two" },
|
||||||
|
3: { image: "data:image/png;base64,one" },
|
||||||
|
4: { image: " " },
|
||||||
|
};
|
||||||
|
const result = collectUpstreamOutputs(
|
||||||
|
nodeWithConnections([{ node: 1 }, { node: 2 }, { node: 3 }, { node: 4 }]),
|
||||||
|
(id) => outputs[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(result.image, "data:image/png;base64,one");
|
||||||
|
assert.deepEqual(result.images, [
|
||||||
|
"data:image/png;base64,one",
|
||||||
|
"data:image/png;base64,two",
|
||||||
|
]);
|
||||||
|
});
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
const test = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const path = require("node:path");
|
||||||
|
|
||||||
|
const {
|
||||||
|
clmmResolutionFromParamsV2,
|
||||||
|
clmmSizeFromParamsV2,
|
||||||
|
} = require("../electron/lib/canvas.js");
|
||||||
|
|
||||||
|
test("keeps Seedance 2K and 4K resolutions for upstream capability negotiation", () => {
|
||||||
|
assert.equal(clmmResolutionFromParamsV2({ resolution: "2K" }), "2k");
|
||||||
|
assert.equal(clmmResolutionFromParamsV2({ resolution: "4k" }), "4k");
|
||||||
|
assert.equal(clmmResolutionFromParamsV2({ resolution: "unsupported" }), "720p");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("derives standard 2K and 4K video sizes from aspect ratio", () => {
|
||||||
|
assert.equal(clmmSizeFromParamsV2({ ratio: "16:9" }, "2k"), "2560x1440");
|
||||||
|
assert.equal(clmmSizeFromParamsV2({ ratio: "9:16" }, "2k"), "1440x2560");
|
||||||
|
assert.equal(clmmSizeFromParamsV2({ ratio: "1:1" }, "4k"), "2160x2160");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("exposes 2K and 4K only on the Seedance video control", () => {
|
||||||
|
const source = fs.readFileSync(path.resolve(__dirname, "../renderer/app.js"), "utf8");
|
||||||
|
assert.match(source, /value="2k">2K(需上游支持)/);
|
||||||
|
assert.match(source, /value="4k">4K(需上游支持)/);
|
||||||
|
assert.match(source, /const resolutionOptions = isGrok/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
const test = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const os = require("node:os");
|
||||||
|
const path = require("node:path");
|
||||||
|
const Module = require("node:module");
|
||||||
|
|
||||||
|
const DATA_DIR_MODULE = path.resolve(__dirname, "../electron/lib/codex_data_dir.js");
|
||||||
|
const SESSIONS_MODULE = path.resolve(__dirname, "../electron/lib/sessions.js");
|
||||||
|
|
||||||
|
function loadModules(userData) {
|
||||||
|
const originalLoad = Module._load;
|
||||||
|
Module._load = function (request, parent, isMain) {
|
||||||
|
if (request === "electron") return { app: { getPath: () => userData } };
|
||||||
|
return originalLoad.call(this, request, parent, isMain);
|
||||||
|
};
|
||||||
|
delete require.cache[DATA_DIR_MODULE];
|
||||||
|
delete require.cache[SESSIONS_MODULE];
|
||||||
|
try {
|
||||||
|
return { dataDir: require(DATA_DIR_MODULE), sessions: require(SESSIONS_MODULE) };
|
||||||
|
} finally {
|
||||||
|
Module._load = originalLoad;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeRollout(root, id, title) {
|
||||||
|
const dir = path.join(root, "sessions", "2026", "08", "06");
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(dir, "rollout-test.jsonl"), [
|
||||||
|
JSON.stringify({ type: "session_meta", payload: { session_id: id, cwd: "/work/demo", timestamp: "2026-08-06T10:00:00.000Z" } }),
|
||||||
|
JSON.stringify({ type: "event_msg", payload: { type: "user_message", message: title } }),
|
||||||
|
].join("\n") + "\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
test("会话数据目录切换后列表读取新目录,配置可持久化", () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "codex-market-data-dir-"));
|
||||||
|
const userData = path.join(root, "user-data");
|
||||||
|
const first = path.join(root, "first-codex");
|
||||||
|
const second = path.join(root, "second-codex");
|
||||||
|
fs.mkdirSync(userData, { recursive: true });
|
||||||
|
writeRollout(first, "11111111-1111-4111-8111-111111111111", "第一份记录");
|
||||||
|
writeRollout(second, "22222222-2222-4222-8222-222222222222", "第二份记录");
|
||||||
|
|
||||||
|
try {
|
||||||
|
let { dataDir, sessions } = loadModules(userData);
|
||||||
|
assert.throws(() => dataDir.set(path.join(root, "not-codex")), /Codex 数据/);
|
||||||
|
assert.equal(dataDir.set(first).path, first);
|
||||||
|
assert.equal(sessions.getCodexHome(), first);
|
||||||
|
assert.deepEqual(sessions.listConversations().map((row) => row.title), ["第一份记录"]);
|
||||||
|
|
||||||
|
assert.equal(dataDir.set(second).path, second);
|
||||||
|
assert.equal(sessions.getCodexHome(), second);
|
||||||
|
assert.deepEqual(sessions.listConversations().map((row) => row.title), ["第二份记录"]);
|
||||||
|
|
||||||
|
({ dataDir, sessions } = loadModules(userData));
|
||||||
|
assert.equal(dataDir.info().path, second);
|
||||||
|
assert.deepEqual(sessions.listConversations().map((row) => row.title), ["第二份记录"]);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
const test = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
|
||||||
|
const media = require("../electron/lib/media-understanding");
|
||||||
|
|
||||||
|
test("collectAttachments finds a Responses image part", () => {
|
||||||
|
const attachments = media.collectAttachments({
|
||||||
|
input: [{
|
||||||
|
type: "message",
|
||||||
|
role: "user",
|
||||||
|
content: [{ type: "input_image", image_url: "data:image/png;base64,abc" }],
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
assert.equal(attachments.length, 1);
|
||||||
|
assert.equal(attachments[0].kind, "image");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("collectAttachments finds standard Responses input_audio data", () => {
|
||||||
|
const attachments = media.collectAttachments({
|
||||||
|
input: [{
|
||||||
|
type: "message",
|
||||||
|
role: "user",
|
||||||
|
content: [{
|
||||||
|
type: "input_audio",
|
||||||
|
input_audio: { data: "YXVkaW8=", format: "wav" },
|
||||||
|
}],
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
assert.equal(attachments.length, 1);
|
||||||
|
assert.equal(attachments[0].kind, "audio");
|
||||||
|
assert.equal(attachments[0].name, "audio.wav");
|
||||||
|
assert.equal(attachments[0].value, "YXVkaW8=");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("enrichResponsesRequest injects MiniMax evidence without exposing credentials", async () => {
|
||||||
|
const previousKey = process.env.TD_MULTIMODAL_GATEWAY_KEY;
|
||||||
|
process.env.TD_MULTIMODAL_GATEWAY_KEY = "test-key";
|
||||||
|
const request = {
|
||||||
|
input: [{
|
||||||
|
type: "message",
|
||||||
|
role: "user",
|
||||||
|
content: [{ type: "input_image", image_url: "data:image/png;base64,abc" }],
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const result = await media.enrichResponsesRequest(request, {
|
||||||
|
instruction: "图片里写了什么?",
|
||||||
|
fetchImpl: async () => ({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
choices: [{
|
||||||
|
message: {
|
||||||
|
content: '{"summary":"界面截图","items":[{"kind":"ocr","text":"设置","confidence":0.99}]}',
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.equal(result.processed, true);
|
||||||
|
const last = request.input[0].content.at(-1);
|
||||||
|
assert.equal(last.type, "input_text");
|
||||||
|
assert.match(last.text, /界面截图/);
|
||||||
|
assert.doesNotMatch(last.text, /test-key/);
|
||||||
|
} finally {
|
||||||
|
if (previousKey == null) delete process.env.TD_MULTIMODAL_GATEWAY_KEY;
|
||||||
|
else process.env.TD_MULTIMODAL_GATEWAY_KEY = previousKey;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("enrichResponsesRequest reports an unavailable gateway instead of empty image evidence", async () => {
|
||||||
|
const previousKey = process.env.TD_MULTIMODAL_GATEWAY_KEY;
|
||||||
|
delete process.env.TD_MULTIMODAL_GATEWAY_KEY;
|
||||||
|
const request = {
|
||||||
|
input: [{
|
||||||
|
type: "message",
|
||||||
|
role: "user",
|
||||||
|
content: [{ type: "input_image", image_url: "data:image/png;base64,abc" }],
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const result = await media.enrichResponsesRequest(request);
|
||||||
|
assert.match(result.evidence.summary, /尚未注入访问密钥/);
|
||||||
|
assert.match(request.input[0].content.at(-1).text, /不能识别图片内容/);
|
||||||
|
} finally {
|
||||||
|
if (previousKey == null) delete process.env.TD_MULTIMODAL_GATEWAY_KEY;
|
||||||
|
else process.env.TD_MULTIMODAL_GATEWAY_KEY = previousKey;
|
||||||
|
}
|
||||||
|
});
|
||||||
+1238
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,166 @@
|
|||||||
|
const test = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const os = require("node:os");
|
||||||
|
const path = require("node:path");
|
||||||
|
const Module = require("node:module");
|
||||||
|
const childProcess = require("node:child_process");
|
||||||
|
const initSqlJs = require("sql.js");
|
||||||
|
|
||||||
|
const PROVIDER_MODULE = path.resolve(__dirname, "../electron/lib/provider_sync.js");
|
||||||
|
const DESKTOP_MODULE = path.resolve(__dirname, "../electron/lib/codex_desktop.js");
|
||||||
|
|
||||||
|
function writeRollout(root, id) {
|
||||||
|
const dir = path.join(root, "sessions", "2026", "08", "09");
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(dir, `rollout-${id}.jsonl`), [
|
||||||
|
JSON.stringify({ type: "session_meta", payload: { session_id: id, cwd: "C:/work/demo", timestamp: "2026-08-09T10:00:00.000Z", model_provider: "old" } }),
|
||||||
|
JSON.stringify({ type: "event_msg", payload: { type: "user_message", message: "Windows restore test" } }),
|
||||||
|
].join("\n") + "\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// subagent 会话的 session_meta.payload.source 是对象({subagent:{thread_spawn:{...}}}),
|
||||||
|
// 不是字符串。曾导致 Windows(无 sqlite3 CLI,走 sql.js 绑定)整批 upsert 抛错回滚,
|
||||||
|
// state 库 0 线程而侧栏 catalog 正常,Codex 启动 full reconcile 后会话"闪现后全部消失"。
|
||||||
|
function writeSubagentRollout(root, id, parentId) {
|
||||||
|
const dir = path.join(root, "sessions", "2026", "08", "09");
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(dir, `rollout-${id}.jsonl`), [
|
||||||
|
JSON.stringify({
|
||||||
|
type: "session_meta",
|
||||||
|
payload: {
|
||||||
|
session_id: id,
|
||||||
|
cwd: "C:/work/demo",
|
||||||
|
timestamp: "2026-08-09T11:00:00.000Z",
|
||||||
|
model_provider: "old",
|
||||||
|
source: { subagent: { thread_spawn: { parent_thread_id: parentId, depth: 1, agent_path: null, agent_nickname: "Dirac", agent_role: null } } },
|
||||||
|
thread_source: "subagent",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
JSON.stringify({ type: "event_msg", payload: { type: "user_message", message: "subagent task" } }),
|
||||||
|
].join("\n") + "\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
test("会话恢复在没有 sqlite3 时插入线程并创建侧栏目录", { concurrency: false }, async () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "codex-session-restore-"));
|
||||||
|
const dataDir = path.join(root, "codex");
|
||||||
|
const userData = path.join(root, "app-data");
|
||||||
|
const id = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
|
||||||
|
const originalLoad = Module._load;
|
||||||
|
const originalSpawnSync = childProcess.spawnSync;
|
||||||
|
const originalBundleRoot = global.__BUNDLE_ROOT__;
|
||||||
|
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(userData, { recursive: true });
|
||||||
|
fs.mkdirSync(path.join(dataDir, "sessions"), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(dataDir, "config.toml"), 'model_provider = "openai"\n');
|
||||||
|
writeRollout(dataDir, id);
|
||||||
|
|
||||||
|
Module._load = function (request, parent, isMain) {
|
||||||
|
if (request === "electron") return { app: { getPath: () => userData } };
|
||||||
|
return originalLoad.call(this, request, parent, isMain);
|
||||||
|
};
|
||||||
|
childProcess.spawnSync = function (command, args, options) {
|
||||||
|
if (command === "sqlite3") return { error: new Error("sqlite3 not installed"), status: null, stdout: "", stderr: "" };
|
||||||
|
return originalSpawnSync.call(this, command, args, options);
|
||||||
|
};
|
||||||
|
delete require.cache[PROVIDER_MODULE];
|
||||||
|
delete require.cache[DESKTOP_MODULE];
|
||||||
|
global.__BUNDLE_ROOT__ = path.resolve(__dirname, "..");
|
||||||
|
const provider = require(PROVIDER_MODULE);
|
||||||
|
const desktop = require(DESKTOP_MODULE);
|
||||||
|
desktop.codexRunning = () => false;
|
||||||
|
provider.setCodexDataDir(dataDir);
|
||||||
|
|
||||||
|
const result = await provider.syncProviderToCurrent(() => {});
|
||||||
|
assert.equal(result.insertedRows, 1);
|
||||||
|
assert.equal(result.catalogRebuilt, 1);
|
||||||
|
assert.equal(result.stateDbCreated, 1);
|
||||||
|
assert.equal(result.stateDbCreateError, "");
|
||||||
|
assert.equal(fs.existsSync(path.join(dataDir, "sqlite", "codex-dev.db")), true);
|
||||||
|
assert.equal(fs.existsSync(path.join(dataDir, "session_index.jsonl")), true);
|
||||||
|
assert.equal(fs.existsSync(path.join(dataDir, "state_5.sqlite")), true);
|
||||||
|
|
||||||
|
const SQL = await initSqlJs({ locateFile: (file) => path.join(__dirname, "../node_modules/sql.js/dist", file) });
|
||||||
|
const restoredState = new SQL.Database(fs.readFileSync(path.join(dataDir, "state_5.sqlite")));
|
||||||
|
assert.equal(restoredState.exec("SELECT COUNT(*) FROM threads")[0].values[0][0], 1);
|
||||||
|
assert.equal(restoredState.exec("SELECT has_user_event FROM threads WHERE id='" + id + "'")[0].values[0][0], 1);
|
||||||
|
assert.equal(restoredState.exec("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN ('thread_sections','thread_dynamic_tools','thread_spawn_edges','backfill_state','external_agent_config_imports','remote_control_enrollments')")[0].values[0][0], 6);
|
||||||
|
assert.equal(restoredState.exec("SELECT COUNT(*) FROM _sqlx_migrations")[0].values[0][0], 45);
|
||||||
|
restoredState.close();
|
||||||
|
|
||||||
|
const catalog = new SQL.Database(fs.readFileSync(path.join(dataDir, "sqlite", "codex-dev.db")));
|
||||||
|
assert.equal(catalog.exec("SELECT COUNT(*) FROM local_thread_catalog")[0].values[0][0], 1);
|
||||||
|
catalog.close();
|
||||||
|
} finally {
|
||||||
|
Module._load = originalLoad;
|
||||||
|
childProcess.spawnSync = originalSpawnSync;
|
||||||
|
global.__BUNDLE_ROOT__ = originalBundleRoot;
|
||||||
|
delete require.cache[PROVIDER_MODULE];
|
||||||
|
delete require.cache[DESKTOP_MODULE];
|
||||||
|
fs.rmSync(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("subagent source 对象不再让 Windows(sql.js) 路径整批失败,且自建 catalog 库带 user_version", { concurrency: false }, async () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "codex-session-restore-"));
|
||||||
|
const dataDir = path.join(root, "codex");
|
||||||
|
const userData = path.join(root, "app-data");
|
||||||
|
const id = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
|
||||||
|
const subId = "cccccccc-cccc-4ccc-8ccc-cccccccccccc";
|
||||||
|
const originalLoad = Module._load;
|
||||||
|
const originalSpawnSync = childProcess.spawnSync;
|
||||||
|
const originalBundleRoot = global.__BUNDLE_ROOT__;
|
||||||
|
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(userData, { recursive: true });
|
||||||
|
fs.mkdirSync(path.join(dataDir, "sessions"), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(dataDir, "config.toml"), 'model_provider = "openai"\n');
|
||||||
|
writeRollout(dataDir, id);
|
||||||
|
writeSubagentRollout(dataDir, subId, id);
|
||||||
|
|
||||||
|
Module._load = function (request, parent, isMain) {
|
||||||
|
if (request === "electron") return { app: { getPath: () => userData } };
|
||||||
|
return originalLoad.call(this, request, parent, isMain);
|
||||||
|
};
|
||||||
|
childProcess.spawnSync = function (command, args, options) {
|
||||||
|
if (command === "sqlite3") return { error: new Error("sqlite3 not installed"), status: null, stdout: "", stderr: "" };
|
||||||
|
return originalSpawnSync.call(this, command, args, options);
|
||||||
|
};
|
||||||
|
delete require.cache[PROVIDER_MODULE];
|
||||||
|
delete require.cache[DESKTOP_MODULE];
|
||||||
|
global.__BUNDLE_ROOT__ = path.resolve(__dirname, "..");
|
||||||
|
const provider = require(PROVIDER_MODULE);
|
||||||
|
const desktop = require(DESKTOP_MODULE);
|
||||||
|
desktop.codexRunning = () => false;
|
||||||
|
provider.setCodexDataDir(dataDir);
|
||||||
|
|
||||||
|
const result = await provider.syncProviderToCurrent(() => {}, () => {});
|
||||||
|
// 两条线程都必须进 state 库(修复前 subagent 的 source 对象会让整批回滚,insertedRows=0)
|
||||||
|
assert.equal(result.insertedRows, 2);
|
||||||
|
|
||||||
|
const SQL = await initSqlJs({ locateFile: (file) => path.join(__dirname, "../node_modules/sql.js/dist", file) });
|
||||||
|
const state = new SQL.Database(fs.readFileSync(path.join(dataDir, "state_5.sqlite")));
|
||||||
|
assert.equal(state.exec("SELECT COUNT(*) FROM threads")[0].values[0][0], 2);
|
||||||
|
// subagent 的 source 以 JSON 字符串入库(与 Codex 自身存储一致),而不是 '[object Object]'
|
||||||
|
const subSource = state.exec(`SELECT source FROM threads WHERE id='${subId}'`)[0].values[0][0];
|
||||||
|
assert.ok(subSource.startsWith('{"subagent"'), `source 应为 JSON 字符串,实际: ${subSource}`);
|
||||||
|
assert.ok(subSource.includes("Dirac"));
|
||||||
|
state.close();
|
||||||
|
|
||||||
|
// 自建 codex-dev.db 必须带 user_version(对齐 Codex 迁移链 v30),
|
||||||
|
// 否则 Codex 重跑迁移 v27/28/29 会置空 last_full_reconciled_at 触发 full reconcile
|
||||||
|
const catalog = new SQL.Database(fs.readFileSync(path.join(dataDir, "sqlite", "codex-dev.db")));
|
||||||
|
const uv = catalog.exec("PRAGMA user_version")[0].values[0][0];
|
||||||
|
assert.equal(uv, 30);
|
||||||
|
assert.equal(catalog.exec("SELECT COUNT(*) FROM local_thread_catalog")[0].values[0][0], 2);
|
||||||
|
catalog.close();
|
||||||
|
} finally {
|
||||||
|
Module._load = originalLoad;
|
||||||
|
childProcess.spawnSync = originalSpawnSync;
|
||||||
|
global.__BUNDLE_ROOT__ = originalBundleRoot;
|
||||||
|
delete require.cache[PROVIDER_MODULE];
|
||||||
|
delete require.cache[DESKTOP_MODULE];
|
||||||
|
fs.rmSync(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
const test = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const Module = require("node:module");
|
||||||
|
|
||||||
|
function loadTeam() {
|
||||||
|
const originalLoad = Module._load;
|
||||||
|
Module._load = function load(request, parent, isMain) {
|
||||||
|
if (request === "electron") {
|
||||||
|
return {
|
||||||
|
app: { getPath: () => "/tmp/codex-market-team-test" },
|
||||||
|
safeStorage: { isEncryptionAvailable: () => false },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return originalLoad.call(this, request, parent, isMain);
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
delete require.cache[require.resolve("../electron/lib/team")];
|
||||||
|
delete require.cache[require.resolve("../electron/lib/router")];
|
||||||
|
return require("../electron/lib/team");
|
||||||
|
} finally {
|
||||||
|
Module._load = originalLoad;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test("team package keeps generation and runtime models separate", () => {
|
||||||
|
const team = loadTeam();
|
||||||
|
const files = team.packFiles({
|
||||||
|
project_name: "model-routing",
|
||||||
|
employees: [{
|
||||||
|
id: "worker",
|
||||||
|
name: "执行 Agent",
|
||||||
|
duty: "实现与测试",
|
||||||
|
model: "deepseek-v4-flash",
|
||||||
|
responsibilities: [],
|
||||||
|
constraints: [],
|
||||||
|
owner_paths: [],
|
||||||
|
review_paths: [],
|
||||||
|
allowed_paths: ["**"],
|
||||||
|
collaborators: [],
|
||||||
|
}],
|
||||||
|
orchestrator: {
|
||||||
|
id: "orchestrator",
|
||||||
|
name: "总控 Agent",
|
||||||
|
duty: "调度与验收",
|
||||||
|
model: "gpt-5.6-luna",
|
||||||
|
responsibilities: [],
|
||||||
|
constraints: [],
|
||||||
|
owner_paths: [],
|
||||||
|
review_paths: ["**"],
|
||||||
|
allowed_paths: [],
|
||||||
|
workflow: [],
|
||||||
|
},
|
||||||
|
model_routing: {
|
||||||
|
generation_model: "gpt-5.4-mini",
|
||||||
|
orchestrator_model: "gpt-5.6-luna",
|
||||||
|
worker_model: "deepseek-v4-flash",
|
||||||
|
},
|
||||||
|
}, "codex");
|
||||||
|
|
||||||
|
const orchestrator = files.find((file) => file.path === "agents/_orchestrator.toml").content;
|
||||||
|
const worker = files.find((file) => file.path === "agents/worker.toml").content;
|
||||||
|
const manifest = JSON.parse(files.find((file) => file.path === "agent-manifest.json").content);
|
||||||
|
assert.match(orchestrator, /model = "gpt-5\.6-luna"/);
|
||||||
|
assert.match(worker, /model = "deepseek-v4-flash"/);
|
||||||
|
assert.match(worker, /TD_MODEL_ROUTE model="deepseek-v4-flash" role="worker" agent="worker"/);
|
||||||
|
assert.doesNotMatch(orchestrator, /gpt-5\.4-mini/);
|
||||||
|
assert.deepEqual(manifest.model_routing, {
|
||||||
|
generation: "gpt-5.4-mini",
|
||||||
|
orchestrator: "gpt-5.6-luna",
|
||||||
|
workers: ["deepseek-v4-flash"],
|
||||||
|
capability_policy: "",
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
const test = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
|
||||||
|
const { TOKEN_INJECT_SCRIPT } = require("../electron/lib/token_inject");
|
||||||
|
|
||||||
|
test("token injection script remains compilable", () => {
|
||||||
|
assert.doesNotThrow(() => new Function(TOKEN_INJECT_SCRIPT));
|
||||||
|
assert.doesNotMatch(TOKEN_INJECT_SCRIPT, /const VER = "td-token-v17"/);
|
||||||
|
assert.doesNotMatch(TOKEN_INJECT_SCRIPT, /const STYLE_ID = "td-token-style-v17"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("token bar waits for a visible composer toolbar instead of falling back to the page root", () => {
|
||||||
|
assert.match(TOKEN_INJECT_SCRIPT, /const EDITOR_SELECTOR = "DIV\.ProseMirror/);
|
||||||
|
assert.match(TOKEN_INJECT_SCRIPT, /if \(!toolbar\) return false;[\s\S]*?const bar = document\.getElementById\(BAR_ID\) \|\| buildBar\(\);/);
|
||||||
|
assert.match(TOKEN_INJECT_SCRIPT, /if \(!toolbar\.contains\(bar\)\) return placeInToolbar\(bar, toolbar\);/);
|
||||||
|
assert.doesNotMatch(TOKEN_INJECT_SCRIPT, /centerUnderEditor\(/);
|
||||||
|
assert.doesNotMatch(TOKEN_INJECT_SCRIPT, /document\.body \|\| document\.documentElement\)\.appendChild\(bar\)/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
const test = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
|
||||||
|
const { responsesToChat } = require("../electron/lib/translate");
|
||||||
|
|
||||||
|
test("chat projection keeps completed custom tool results without forwarding tool protocol", () => {
|
||||||
|
const result = responsesToChat({
|
||||||
|
model: "codex-model",
|
||||||
|
stream: true,
|
||||||
|
tool_choice: "auto",
|
||||||
|
input: [
|
||||||
|
{ type: "additional_tools", role: "system", tools: [{ name: "exec_command" }] },
|
||||||
|
{ type: "message", role: "user", content: [{ type: "input_text", text: "识别这张图" }] },
|
||||||
|
{ type: "custom_tool_call", call_id: "call_1", name: "exec_command", input: "tesseract image.png" },
|
||||||
|
{ type: "custom_tool_call_output", call_id: "call_1", output: [{ type: "text", text: "识别结果:设置" }] },
|
||||||
|
{
|
||||||
|
type: "message",
|
||||||
|
role: "user",
|
||||||
|
content: [
|
||||||
|
{ type: "input_image", image_url: "data:image/png;base64,abc" },
|
||||||
|
{ type: "input_text", text: "[TD 多模态识别证据] 设置页面" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}, "deepseek-chat");
|
||||||
|
|
||||||
|
assert.equal(result.model, "deepseek-chat");
|
||||||
|
assert.equal(result.tools, undefined);
|
||||||
|
assert.equal(result.tool_choice, undefined);
|
||||||
|
assert.match(result.messages[0].content, /不能调用本地工具/);
|
||||||
|
assert.ok(result.messages.some((message) => /工具:exec_command/.test(message.content)));
|
||||||
|
assert.ok(result.messages.some((message) => /识别结果:设置/.test(message.content)));
|
||||||
|
assert.ok(result.messages.some((message) => /TD 多模态识别证据/.test(message.content)));
|
||||||
|
assert.ok(result.messages.every((message) => message.role !== "tool" && !message.tool_calls));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("chat projection forwards native tools only when they are supplied", () => {
|
||||||
|
const withoutTools = responsesToChat({ model: "model", tool_choice: "auto", input: "你好" });
|
||||||
|
assert.equal(withoutTools.tool_choice, undefined);
|
||||||
|
|
||||||
|
const withTools = responsesToChat({
|
||||||
|
model: "model",
|
||||||
|
tool_choice: "auto",
|
||||||
|
tools: [{ type: "function", name: "lookup", parameters: { type: "object" } }],
|
||||||
|
input: "你好",
|
||||||
|
});
|
||||||
|
assert.equal(withTools.tool_choice, "auto");
|
||||||
|
assert.equal(withTools.tools[0].function.name, "lookup");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("chat projection forwards Codex additional_tools so coding agents can execute", () => {
|
||||||
|
const result = responsesToChat({
|
||||||
|
model: "deepseek-chat",
|
||||||
|
tool_choice: "auto",
|
||||||
|
input: [
|
||||||
|
{
|
||||||
|
type: "additional_tools",
|
||||||
|
role: "developer",
|
||||||
|
tools: [{
|
||||||
|
type: "function",
|
||||||
|
name: "exec_command",
|
||||||
|
description: "Run a shell command",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: { cmd: { type: "string" } },
|
||||||
|
required: ["cmd"],
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
{ type: "message", role: "user", content: "发布热更" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.tool_choice, "auto");
|
||||||
|
assert.equal(result.tools.length, 1);
|
||||||
|
assert.equal(result.tools[0].function.name, "exec_command");
|
||||||
|
assert.doesNotMatch(result.messages[0].content, /不能调用本地工具/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("multimodal chat projection removes image reading but keeps coding tools", () => {
|
||||||
|
const result = responsesToChat({
|
||||||
|
model: "model",
|
||||||
|
tool_choice: "auto",
|
||||||
|
tools: [
|
||||||
|
{ type: "function", name: "exec_command", parameters: { type: "object" } },
|
||||||
|
{ type: "function", name: "view_image", parameters: { type: "object" } },
|
||||||
|
{ type: "web_search_preview" },
|
||||||
|
],
|
||||||
|
input: "识别图片",
|
||||||
|
}, undefined, { omitVisualTools: true });
|
||||||
|
|
||||||
|
assert.equal(result.tool_choice, "auto");
|
||||||
|
assert.deepEqual(result.tools.map((tool) => tool.function.name), ["exec_command"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("chat projection never forwards media Base64 from tool messages", () => {
|
||||||
|
const image = "data:image/png;base64," + "A".repeat(2 * 1024 * 1024);
|
||||||
|
const result = responsesToChat({
|
||||||
|
model: "deepseek-chat",
|
||||||
|
input: [{
|
||||||
|
type: "message",
|
||||||
|
role: "tool",
|
||||||
|
content: [{ type: "input_image", image_url: image }],
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
|
||||||
|
const content = result.messages.map((message) => String(message.content || "")).join("\n");
|
||||||
|
assert.match(content, /图像二进制已省略/);
|
||||||
|
assert.doesNotMatch(content, /data:image/);
|
||||||
|
assert.ok(result.messages.every((message) => message.role !== "tool"));
|
||||||
|
assert.ok(content.length < 1000);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user