mirror of
https://github.com/JimLiu/baoyu-skills.git
synced 2026-07-13 06:19:46 +08:00
Compare commits
98 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7a0ffd9533 | |||
| 69355b4ee1 | |||
| 23b7487321 | |||
| ad8781c1c5 | |||
| 86a3d6521b | |||
| e99ce744cd | |||
| 40f9f05c22 | |||
| 09ce80357f | |||
| 7c995fcc24 | |||
| 151f1ec2a8 | |||
| 12e207dc3f | |||
| 00e74ab071 | |||
| 1653b8544b | |||
| dad8f3a800 | |||
| 35298d7c9d | |||
| f22374ab62 | |||
| d4e80b1bc3 | |||
| a5761dc71a | |||
| a5189dff37 | |||
| 39fe872bf3 | |||
| 52813504f8 | |||
| a4d4108cd1 | |||
| d7e763f1f5 | |||
| 097c09c59b | |||
| e4cd8bfefc | |||
| 3dc5f2e06f | |||
| e5d6c8ec68 | |||
| 6a4b312146 | |||
| 2d6fe533eb | |||
| f53af25e65 | |||
| c7e32b4590 | |||
| 8d973f2bc5 | |||
| ba20cf89f2 | |||
| 1827be9234 | |||
| 93c98dfc3c | |||
| fbd9f9b622 | |||
| b6e293d059 | |||
| bb78aab095 | |||
| 5071a1d0d0 | |||
| e413ade164 | |||
| e52f92b193 | |||
| 603cabaef4 | |||
| 7d12526e90 | |||
| e7f9764a49 | |||
| e55f91b0ea | |||
| fe3b3d9125 | |||
| 105339cf3f | |||
| dcfd9033ae | |||
| eb416d174c | |||
| 83afacb00e | |||
| 0e6bfbcabd | |||
| 7b67ff44b8 | |||
| 79c289ca92 | |||
| fc5ad4b762 | |||
| 747977416d | |||
| e43eec260a | |||
| 96ef6e2251 | |||
| efb7a1917a | |||
| 1af984a64f | |||
| 60363fc2df | |||
| 28ec1053f6 | |||
| 5a6afd576f | |||
| ebc74a10ad | |||
| e79a42fd94 | |||
| ea84f21439 | |||
| 0b9e51d6cc | |||
| 7cc9c92722 | |||
| fc2f0d042a | |||
| cf76a0d4d5 | |||
| 4a25bc2651 | |||
| f407c950c3 | |||
| 93e54a7b86 | |||
| c1f8a9ad07 | |||
| 8b8ecf61a6 | |||
| 4be6f3682a | |||
| b89ef02221 | |||
| 55356c8820 | |||
| f23d4eebc3 | |||
| 3b795b6ef3 | |||
| c62d9d5a1b | |||
| 84c56b0da3 | |||
| 462d080a0e | |||
| bd01c370c9 | |||
| b7bcc8c094 | |||
| d9f9da639d | |||
| 126b3040e6 | |||
| 682888cc95 | |||
| e31294415d | |||
| 926f74e9c9 | |||
| 339990e87e | |||
| 3c5c3e006d | |||
| 2aa9790789 | |||
| 38fc733b99 | |||
| 4d2b95d1d1 | |||
| ac2ce0b8b6 | |||
| de7dc85361 | |||
| 0c02b81885 | |||
| 774ad784d8 |
@@ -6,48 +6,33 @@
|
||||
},
|
||||
"metadata": {
|
||||
"description": "Skills shared by Baoyu for improving daily work efficiency",
|
||||
"version": "1.66.0"
|
||||
"version": "1.84.0"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "content-skills",
|
||||
"description": "Content generation and publishing skills",
|
||||
"name": "baoyu-skills",
|
||||
"description": "Content generation, AI backends, and utility tools for daily work efficiency",
|
||||
"source": "./",
|
||||
"strict": true,
|
||||
"skills": [
|
||||
"./skills/baoyu-xhs-images",
|
||||
"./skills/baoyu-post-to-x",
|
||||
"./skills/baoyu-post-to-wechat",
|
||||
"./skills/baoyu-post-to-weibo",
|
||||
"./skills/baoyu-article-illustrator",
|
||||
"./skills/baoyu-cover-image",
|
||||
"./skills/baoyu-slide-deck",
|
||||
"./skills/baoyu-comic",
|
||||
"./skills/baoyu-infographic"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "ai-generation-skills",
|
||||
"description": "AI-powered generation backends",
|
||||
"source": "./",
|
||||
"strict": true,
|
||||
"skills": [
|
||||
"./skills/baoyu-danger-gemini-web",
|
||||
"./skills/baoyu-image-gen"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "utility-skills",
|
||||
"description": "Utility tools for content processing",
|
||||
"source": "./",
|
||||
"strict": true,
|
||||
"skills": [
|
||||
"./skills/baoyu-danger-x-to-markdown",
|
||||
"./skills/baoyu-compress-image",
|
||||
"./skills/baoyu-url-to-markdown",
|
||||
"./skills/baoyu-cover-image",
|
||||
"./skills/baoyu-danger-gemini-web",
|
||||
"./skills/baoyu-danger-x-to-markdown",
|
||||
"./skills/baoyu-format-markdown",
|
||||
"./skills/baoyu-imagine",
|
||||
"./skills/baoyu-infographic",
|
||||
"./skills/baoyu-markdown-to-html",
|
||||
"./skills/baoyu-translate"
|
||||
"./skills/baoyu-post-to-weibo",
|
||||
"./skills/baoyu-post-to-wechat",
|
||||
"./skills/baoyu-post-to-x",
|
||||
"./skills/baoyu-slide-deck",
|
||||
"./skills/baoyu-translate",
|
||||
"./skills/baoyu-url-to-markdown",
|
||||
"./skills/baoyu-xhs-images",
|
||||
"./skills/baoyu-youtube-transcript"
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -16,6 +16,10 @@ jobs:
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run tests
|
||||
run: npm test
|
||||
|
||||
@@ -166,3 +166,4 @@ posts/
|
||||
.clawdhub/
|
||||
.release-artifacts/
|
||||
.worktrees/
|
||||
youtube-transcript/
|
||||
|
||||
+196
@@ -2,6 +2,202 @@
|
||||
|
||||
English | [中文](./CHANGELOG.zh.md)
|
||||
|
||||
## 1.84.0 - 2026-03-25
|
||||
|
||||
### Features
|
||||
- Rename `baoyu-image-gen` skill to `baoyu-imagine` — shorter command name, all references updated across docs, configs, and dependent skills
|
||||
|
||||
## 1.83.0 - 2026-03-25
|
||||
|
||||
### Features
|
||||
- `baoyu-image-gen`: add MiniMax provider (`image-01` / `image-01-live`) with subject_reference for character/portrait consistency, custom sizes, and aspect ratio support
|
||||
|
||||
## 1.82.0 - 2026-03-24
|
||||
|
||||
### Features
|
||||
- `baoyu-url-to-markdown`: add browser fallback strategy — headless first, automatic retry in visible Chrome on technical failure; new `--browser auto|headless|headed` flag with `--headless`/`--headed` shortcuts
|
||||
- `baoyu-url-to-markdown`: add content cleaner module for HTML preprocessing before extraction (remove ads, base64 images, scripts, styles)
|
||||
- `baoyu-url-to-markdown`: support base64 data URI images in media localizer alongside remote URLs
|
||||
- `baoyu-url-to-markdown`: capture final URL from browser to track redirects for output path generation
|
||||
- `baoyu-url-to-markdown`: add agent quality gate documentation for post-capture content validation
|
||||
|
||||
### Dependencies
|
||||
- `baoyu-url-to-markdown`: upgrade defuddle ^0.12.0 → ^0.14.0
|
||||
|
||||
### Tests
|
||||
- `baoyu-url-to-markdown`: add unit tests for content-cleaner, html-to-markdown, legacy-converter, media-localizer
|
||||
|
||||
## 1.81.0 - 2026-03-24
|
||||
|
||||
### Features
|
||||
- `baoyu-youtube-transcript`: add yt-dlp fallback when YouTube blocks direct InnerTube API, with alternate client identity retry and cookie support via `YOUTUBE_TRANSCRIPT_COOKIES_FROM_BROWSER` env var
|
||||
|
||||
### Refactor
|
||||
- `baoyu-youtube-transcript`: split monolithic script into typed modules (youtube, transcript, storage, shared, types) and add unit tests
|
||||
|
||||
## 1.80.1 - 2026-03-24
|
||||
|
||||
### Fixes
|
||||
- `baoyu-image-gen`: use correct `prompt` field name for Jimeng API request
|
||||
|
||||
## 1.80.0 - 2026-03-24
|
||||
|
||||
### Features
|
||||
- `baoyu-image-gen`: add Azure OpenAI as independent image generation provider with flexible endpoint parsing, deployment-name resolution, quality mapping, and reference image validation
|
||||
|
||||
## 1.79.2 - 2026-03-23
|
||||
|
||||
### Fixes
|
||||
- `baoyu-cover-image`: simplify reference image handling — use `--ref` when model supports it, only create description files for models without reference image support
|
||||
- `baoyu-post-to-weibo`: add no-theme rule for article markdown-to-HTML conversion
|
||||
|
||||
### Tests
|
||||
- Fix Node-compatible parser tests and add parser test dependencies
|
||||
|
||||
## 1.79.1 - 2026-03-23
|
||||
|
||||
### Fixes
|
||||
- Consolidate to single plugin to prevent duplicate skill registration (by @TyrealQ)
|
||||
- `baoyu-article-illustrator`: remove opacity parameter from watermark prompt
|
||||
- `baoyu-comic`: fix Doraemon naming spacing and remove opacity from watermark prompt
|
||||
- `baoyu-xhs-images`: remove opacity from watermark prompt and fix CJK spacing
|
||||
|
||||
### Documentation
|
||||
- Update project documentation to reflect single-plugin architecture
|
||||
|
||||
## 1.79.0 - 2026-03-22
|
||||
|
||||
### Features
|
||||
- `baoyu-post-to-wechat`: improve credential loading with multi-source resolution, priority ordering, and diagnostics for skipped incomplete sources
|
||||
|
||||
## 1.78.0 - 2026-03-22
|
||||
|
||||
### Features
|
||||
- `baoyu-url-to-markdown`: add URL-specific parser layer for X/Twitter and archive.ph sites
|
||||
- `baoyu-url-to-markdown`: improved slug generation with stop words removal and subdirectory output structure
|
||||
|
||||
### Fixes
|
||||
- `baoyu-url-to-markdown`: preserve anchor elements containing media in legacy converter
|
||||
- `baoyu-url-to-markdown`: smarter title deduplication to avoid redundant headings
|
||||
|
||||
## 1.77.0 - 2026-03-22
|
||||
|
||||
### Features
|
||||
- `baoyu-youtube-transcript`: add end times to chapter data (by @jzOcb)
|
||||
|
||||
### Fixes
|
||||
- `sync-clawhub`: skip failed skills instead of aborting
|
||||
|
||||
## 1.76.1 - 2026-03-21
|
||||
|
||||
### Documentation
|
||||
- `baoyu-youtube-transcript`: fix zsh glob issue — always single-quote YouTube URLs when running the script
|
||||
|
||||
## 1.76.0 - 2026-03-21
|
||||
|
||||
### Features
|
||||
- `baoyu-youtube-transcript`: add title heading, description summary, and cover image to markdown output
|
||||
|
||||
### Fixes
|
||||
- `baoyu-markdown-to-html`: use process.execPath and tsx import in test runner
|
||||
|
||||
## 1.75.0 - 2026-03-21
|
||||
|
||||
### Features
|
||||
- `baoyu-youtube-transcript`: new skill — download YouTube video transcripts/subtitles and cover images with multi-language, chapters, and speaker identification support
|
||||
|
||||
## 1.74.1 - 2026-03-21
|
||||
|
||||
### Fixes
|
||||
- `baoyu-image-gen`: align OpenRouter image generation with current API, harden image support, and narrow Gemini aspect ratios (by @cwandev)
|
||||
- `baoyu-image-gen`: broaden OpenRouter model detection and aspect ratio validation
|
||||
|
||||
## 1.74.0 - 2026-03-20
|
||||
|
||||
### Features
|
||||
- `baoyu-markdown-to-html`: CLI now supports all rendering options — color, font-family, font-size, code-theme, mac-code-block, line-number, count, legend
|
||||
|
||||
### Fixes
|
||||
- `baoyu-markdown-to-html`: fix CSS custom property regex to handle quoted values; grace/simple themes now layer default CSS
|
||||
|
||||
## 1.73.3 - 2026-03-20
|
||||
|
||||
### Fixes
|
||||
- `baoyu-post-to-wechat`: fix placeholder replacement to avoid shorter placeholders matching longer numbered variants
|
||||
|
||||
## 1.73.2 - 2026-03-20
|
||||
|
||||
### Fixes
|
||||
- `baoyu-post-to-wechat`: fix body image upload to correctly use media/uploadimg API with format and size validation (by @AICreator-Wind)
|
||||
|
||||
### Refactor
|
||||
- `baoyu-post-to-wechat`: extract image processor module for local format conversion (WebP/BMP/GIF → JPEG/PNG) instead of material API fallback
|
||||
|
||||
## 1.73.1 - 2026-03-18
|
||||
|
||||
### Refactor
|
||||
- `baoyu-danger-x-to-markdown`: migrate tests from bun:test to node:test
|
||||
|
||||
## 1.73.0 - 2026-03-18
|
||||
|
||||
### Features
|
||||
- `baoyu-danger-x-to-markdown`: add video media support for X articles with poster image and video link rendering
|
||||
|
||||
## 1.72.0 - 2026-03-18
|
||||
|
||||
### Features
|
||||
- `baoyu-danger-x-to-markdown`: add MARKDOWN entity support for rendering embedded markdown/code blocks in X articles
|
||||
|
||||
## 1.71.0 - 2026-03-17
|
||||
|
||||
### Features
|
||||
- `baoyu-image-gen`: add Seedream reference image support for 5.0/4.5/4.0 models with model-specific size validation
|
||||
|
||||
## 1.70.0 - 2026-03-17
|
||||
|
||||
### Features
|
||||
- `baoyu-format-markdown`: optimize title generation with formula-based recommendations and straightforward alternatives
|
||||
- `baoyu-format-markdown`: auto-generate dual summaries (`summary` + `description`) in frontmatter
|
||||
|
||||
## 1.69.1 - 2026-03-16
|
||||
|
||||
### Fixes
|
||||
- `baoyu-chrome-cdp`: tighten chrome auto-connect logic to reduce false positives
|
||||
|
||||
## 1.69.0 - 2026-03-16
|
||||
|
||||
### Features
|
||||
- `baoyu-chrome-cdp`: support connecting to existing Chrome session (by @bviews)
|
||||
|
||||
### Fixes
|
||||
- `baoyu-chrome-cdp`: support Chrome 146 native remote debugging in approval mode (by @bviews)
|
||||
- `baoyu-chrome-cdp`: keep HTTP validation in findExistingChromeDebugPort (by @bviews)
|
||||
- `baoyu-danger-gemini-web`: reuse openPageSession and fix orphaned tab leak (by @bviews)
|
||||
- `baoyu-danger-gemini-web`: respect explicit profile config over auto-discovery (by @bviews)
|
||||
- `baoyu-danger-gemini-web`: respect BAOYU_CHROME_PROFILE_DIR in auto-discovery skip (by @bviews)
|
||||
- `baoyu-post-to-wechat`: improve browser publishing reliability (by @cfh-7598)
|
||||
|
||||
### Documentation
|
||||
- `baoyu-cover-image`: clarify people reference image workflow and interactive confirmation
|
||||
|
||||
## 1.68.0 - 2026-03-14
|
||||
|
||||
### Features
|
||||
- `baoyu-article-illustrator`: add configurable output directory (`default_output_dir`) with 4 options — `imgs-subdir`, `same-dir`, `illustrations-subdir`, `independent`
|
||||
- `baoyu-cover-image`: add character preservation from reference images — use `usage: direct` to pass people references to model for stylized likeness
|
||||
|
||||
## 1.67.0 - 2026-03-13
|
||||
|
||||
### Features
|
||||
- `baoyu-image-gen`: add qwen-image-2.0-pro model support for DashScope provider with free-form sizes and text rendering (by @JianJang2017)
|
||||
|
||||
## 1.66.1 - 2026-03-13
|
||||
|
||||
### Tests
|
||||
- Migrate test files from centralized `tests/` directory to colocate with source code
|
||||
- Convert tests from `.mjs` to TypeScript (`.test.ts`) with `tsx` runner
|
||||
- Add npm workspaces configuration and npm cache to CI workflow
|
||||
|
||||
## 1.66.0 - 2026-03-13
|
||||
|
||||
### Features
|
||||
|
||||
+196
@@ -2,6 +2,202 @@
|
||||
|
||||
[English](./CHANGELOG.md) | 中文
|
||||
|
||||
## 1.84.0 - 2026-03-25
|
||||
|
||||
### 新功能
|
||||
- 将 `baoyu-image-gen` 技能重命名为 `baoyu-imagine` — 更简短的命令名,所有文档、配置和依赖技能中的引用已同步更新
|
||||
|
||||
## 1.83.0 - 2026-03-25
|
||||
|
||||
### 新功能
|
||||
- `baoyu-image-gen`:新增 MiniMax 服务商(`image-01` / `image-01-live`),支持 subject_reference 角色/肖像一致性、自定义尺寸和宽高比
|
||||
|
||||
## 1.82.0 - 2026-03-24
|
||||
|
||||
### 新功能
|
||||
- `baoyu-url-to-markdown`:新增浏览器回退策略 — 默认无头模式优先,技术故障时自动重试有头 Chrome;新增 `--browser auto|headless|headed` 参数及 `--headless`/`--headed` 快捷方式
|
||||
- `baoyu-url-to-markdown`:新增内容清理模块,提取前预处理 HTML(移除广告、base64 图片、脚本、样式)
|
||||
- `baoyu-url-to-markdown`:媒体本地化支持 base64 data URI 图片
|
||||
- `baoyu-url-to-markdown`:从浏览器捕获最终 URL 以跟踪重定向,用于输出路径生成
|
||||
- `baoyu-url-to-markdown`:新增 Agent 质量门控文档,规范捕获后的内容验证流程
|
||||
|
||||
### 依赖
|
||||
- `baoyu-url-to-markdown`:升级 defuddle ^0.12.0 → ^0.14.0
|
||||
|
||||
### 测试
|
||||
- `baoyu-url-to-markdown`:新增 content-cleaner、html-to-markdown、legacy-converter、media-localizer 单元测试
|
||||
|
||||
## 1.81.0 - 2026-03-24
|
||||
|
||||
### 新功能
|
||||
- `baoyu-youtube-transcript`:YouTube 封锁直连 InnerTube API 时自动回退到 yt-dlp,支持备用客户端身份重试及通过 `YOUTUBE_TRANSCRIPT_COOKIES_FROM_BROWSER` 环境变量传递浏览器 Cookie
|
||||
|
||||
### 重构
|
||||
- `baoyu-youtube-transcript`:将单体脚本拆分为类型化模块(youtube、transcript、storage、shared、types)并添加单元测试
|
||||
|
||||
## 1.80.1 - 2026-03-24
|
||||
|
||||
### 修复
|
||||
- `baoyu-image-gen`:修正即梦 API 请求中的 `prompt` 字段名
|
||||
|
||||
## 1.80.0 - 2026-03-24
|
||||
|
||||
### 新功能
|
||||
- `baoyu-image-gen`:新增 Azure OpenAI 作为独立图像生成服务商,支持灵活的端点解析、部署名称推断、质量映射及参考图片格式校验
|
||||
|
||||
## 1.79.2 - 2026-03-23
|
||||
|
||||
### 修复
|
||||
- `baoyu-cover-image`:简化参考图片处理流程 — 模型支持 `--ref` 时直接传递,仅在模型不支持参考图时创建描述文件
|
||||
- `baoyu-post-to-weibo`:文章 Markdown 转 HTML 时不传递 --theme 参数
|
||||
|
||||
### 测试
|
||||
- 修复 Node 兼容的解析器测试,添加解析器测试依赖
|
||||
|
||||
## 1.79.1 - 2026-03-23
|
||||
|
||||
### 修复
|
||||
- 合并为单一插件,防止 skill 重复注册 (by @TyrealQ)
|
||||
- `baoyu-article-illustrator`:移除水印提示词中的不透明度参数
|
||||
- `baoyu-comic`:修正哆啦 A 梦命名间距,移除水印不透明度参数
|
||||
- `baoyu-xhs-images`:移除水印不透明度参数,修正中英文间距
|
||||
|
||||
### 文档
|
||||
- 更新项目文档以反映单一插件架构
|
||||
|
||||
## 1.79.0 - 2026-03-22
|
||||
|
||||
### 新功能
|
||||
- `baoyu-post-to-wechat`:改进凭据加载机制,支持多来源优先级解析,并提供不完整凭据来源的诊断信息
|
||||
|
||||
## 1.78.0 - 2026-03-22
|
||||
|
||||
### 新功能
|
||||
- `baoyu-url-to-markdown`:新增 URL 专用解析层,支持 X/Twitter 和 archive.ph 站点的定制化 HTML 提取
|
||||
- `baoyu-url-to-markdown`:改进 slug 生成算法,去除停用词并采用子目录输出结构
|
||||
|
||||
### 修复
|
||||
- `baoyu-url-to-markdown`:旧版转换器保留包含媒体元素的锚标签
|
||||
- `baoyu-url-to-markdown`:更智能的标题去重,避免重复添加标题
|
||||
|
||||
## 1.77.0 - 2026-03-22
|
||||
|
||||
### 新功能
|
||||
- `baoyu-youtube-transcript`:为章节数据添加结束时间 (by @jzOcb)
|
||||
|
||||
### 修复
|
||||
- `sync-clawhub`:跳过失败的技能而不是中止同步
|
||||
|
||||
## 1.76.1 - 2026-03-21
|
||||
|
||||
### 文档
|
||||
- `baoyu-youtube-transcript`:修复 zsh glob 问题 — 运行脚本时始终对 YouTube URL 使用单引号
|
||||
|
||||
## 1.76.0 - 2026-03-21
|
||||
|
||||
### 新功能
|
||||
- `baoyu-youtube-transcript`:Markdown 输出中新增标题、描述摘要和封面图片
|
||||
|
||||
### 修复
|
||||
- `baoyu-markdown-to-html`:测试运行器改用 process.execPath 和 tsx import
|
||||
|
||||
## 1.75.0 - 2026-03-21
|
||||
|
||||
### 新功能
|
||||
- `baoyu-youtube-transcript`:新技能 — 下载 YouTube 视频字幕/转录文本和封面图片,支持多语言、章节分段和说话人识别
|
||||
|
||||
## 1.74.1 - 2026-03-21
|
||||
|
||||
### 修复
|
||||
- `baoyu-image-gen`:对齐 OpenRouter 图像生成与当前 API,增强图像支持,收窄 Gemini 宽高比范围 (by @cwandev)
|
||||
- `baoyu-image-gen`:扩展 OpenRouter 模型检测和宽高比验证
|
||||
|
||||
## 1.74.0 - 2026-03-20
|
||||
|
||||
### 新功能
|
||||
- `baoyu-markdown-to-html`:CLI 支持全部渲染选项 — color、font-family、font-size、code-theme、mac-code-block、line-number、count、legend
|
||||
|
||||
### 修复
|
||||
- `baoyu-markdown-to-html`:修复 CSS 自定义属性正则无法处理带引号值的问题;grace/simple 主题现在会叠加 default 主题 CSS
|
||||
|
||||
## 1.73.3 - 2026-03-20
|
||||
|
||||
### 修复
|
||||
- `baoyu-post-to-wechat`:修复占位符替换时短占位符错误匹配更长编号变体的问题
|
||||
|
||||
## 1.73.2 - 2026-03-20
|
||||
|
||||
### 修复
|
||||
- `baoyu-post-to-wechat`:修复正文图片上传,正确使用 media/uploadimg 接口并处理格式和大小限制 (by @AICreator-Wind)
|
||||
|
||||
### 重构
|
||||
- `baoyu-post-to-wechat`:提取图片处理模块,本地转换不支持的格式(WebP/BMP/GIF → JPEG/PNG)而非回退到 material 接口
|
||||
|
||||
## 1.73.1 - 2026-03-18
|
||||
|
||||
### 重构
|
||||
- `baoyu-danger-x-to-markdown`:测试从 bun:test 迁移至 node:test
|
||||
|
||||
## 1.73.0 - 2026-03-18
|
||||
|
||||
### 新功能
|
||||
- `baoyu-danger-x-to-markdown`:支持 X 文章中的视频媒体,渲染封面图和视频链接
|
||||
|
||||
## 1.72.0 - 2026-03-18
|
||||
|
||||
### 新功能
|
||||
- `baoyu-danger-x-to-markdown`:支持渲染 X 文章中嵌入的 MARKDOWN 实体(代码块等)
|
||||
|
||||
## 1.71.0 - 2026-03-17
|
||||
|
||||
### 新功能
|
||||
- `baoyu-image-gen`:为 Seedream 5.0/4.5/4.0 模型添加参考图支持,并增加模型特定的尺寸校验
|
||||
|
||||
## 1.70.0 - 2026-03-17
|
||||
|
||||
### 新功能
|
||||
- `baoyu-format-markdown`:优化标题生成,基于公式智能推荐并提供平实风格备选
|
||||
- `baoyu-format-markdown`:自动生成双版本摘要(`summary` + `description`),写入 frontmatter
|
||||
|
||||
## 1.69.1 - 2026-03-16
|
||||
|
||||
### 修复
|
||||
- `baoyu-chrome-cdp`:收紧 Chrome 自动连接逻辑,减少误连接
|
||||
|
||||
## 1.69.0 - 2026-03-16
|
||||
|
||||
### 新功能
|
||||
- `baoyu-chrome-cdp`:支持连接到已有的 Chrome 会话 (by @bviews)
|
||||
|
||||
### 修复
|
||||
- `baoyu-chrome-cdp`:支持 Chrome 146 原生远程调试(审批模式)(by @bviews)
|
||||
- `baoyu-chrome-cdp`:保留 findExistingChromeDebugPort 中的 HTTP 验证 (by @bviews)
|
||||
- `baoyu-danger-gemini-web`:复用 openPageSession 并修复孤立标签页泄漏 (by @bviews)
|
||||
- `baoyu-danger-gemini-web`:显式配置优先于自动发现 (by @bviews)
|
||||
- `baoyu-danger-gemini-web`:自动发现跳过时也遵循 BAOYU_CHROME_PROFILE_DIR (by @bviews)
|
||||
- `baoyu-post-to-wechat`:提升浏览器发布可靠性 (by @cfh-7598)
|
||||
|
||||
### 文档
|
||||
- `baoyu-cover-image`:完善人物参考图片工作流和交互式确认说明
|
||||
|
||||
## 1.68.0 - 2026-03-14
|
||||
|
||||
### 新功能
|
||||
- `baoyu-article-illustrator`:新增可配置输出目录(`default_output_dir`),支持 4 种选项——`imgs-subdir`、`same-dir`、`illustrations-subdir`、`independent`
|
||||
- `baoyu-cover-image`:新增参考图片人物保留功能——当参考图包含人物时使用 `usage: direct` 传递给模型,风格化保留人物特征
|
||||
|
||||
## 1.67.0 - 2026-03-13
|
||||
|
||||
### 新功能
|
||||
- `baoyu-image-gen`:新增 DashScope qwen-image-2.0-pro 模型支持,支持自由尺寸和文字渲染 (by @JianJang2017)
|
||||
|
||||
## 1.66.1 - 2026-03-13
|
||||
|
||||
### 测试
|
||||
- 将测试文件从集中式 `tests/` 目录迁移至与源码同级
|
||||
- 将测试从 `.mjs` 转换为 TypeScript(`.test.ts`),使用 `tsx` 运行器
|
||||
- 新增 npm workspaces 配置,CI 工作流添加 npm 缓存
|
||||
|
||||
## 1.66.0 - 2026-03-13
|
||||
|
||||
### 新功能
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
# CLAUDE.md
|
||||
|
||||
Claude Code marketplace plugin providing AI-powered content generation skills. Version: **1.66.0**.
|
||||
Claude Code marketplace plugin providing AI-powered content generation skills. Version: **1.84.0**.
|
||||
|
||||
## Architecture
|
||||
|
||||
Skills organized into three categories in `.claude-plugin/marketplace.json` (defines plugin metadata, version, and skill paths):
|
||||
Skills are exposed through the single `baoyu-skills` plugin in `.claude-plugin/marketplace.json` (which defines plugin metadata, version, and skill paths). The repo docs still group them into three logical areas:
|
||||
|
||||
| Category | Description |
|
||||
|----------|-------------|
|
||||
| `content-skills` | Generate or publish content (images, slides, comics, posts) |
|
||||
| `ai-generation-skills` | AI generation backends |
|
||||
| `utility-skills` | Content processing (conversion, compression, translation) |
|
||||
| Group | Description |
|
||||
|-------|-------------|
|
||||
| Content Skills | Generate or publish content (images, slides, comics, posts) |
|
||||
| AI Generation Skills | AI generation backends |
|
||||
| Utility Skills | Content processing (conversion, compression, translation) |
|
||||
|
||||
Each skill contains `SKILL.md` (YAML front matter + docs), optional `scripts/`, `references/`, `prompts/`.
|
||||
|
||||
@@ -31,7 +31,7 @@ Execute: `${BUN_X} skills/<skill>/scripts/main.ts [options]`
|
||||
|
||||
- **Bun**: TypeScript runtime (`bun` preferred, fallback `npx -y bun`)
|
||||
- **Chrome**: Required for CDP-based skills (gemini-web, post-to-x/wechat/weibo, url-to-markdown). All CDP skills share a single profile, override via `BAOYU_CHROME_PROFILE_DIR` env var. Platform paths: [docs/chrome-profile.md](docs/chrome-profile.md)
|
||||
- **Image generation APIs**: `baoyu-image-gen` requires API key (OpenAI, Google, OpenRouter, DashScope, or Replicate) configured in EXTEND.md
|
||||
- **Image generation APIs**: `baoyu-imagine` requires API key (OpenAI, Azure OpenAI, Google, OpenRouter, DashScope, or Replicate) configured in EXTEND.md
|
||||
- **Gemini Web auth**: Browser cookies (first run opens Chrome for login, `--login` to refresh)
|
||||
|
||||
## Security
|
||||
@@ -46,7 +46,7 @@ Execute: `${BUN_X} skills/<skill>/scripts/main.ts [options]`
|
||||
| Rule | Description |
|
||||
|------|-------------|
|
||||
| **Load project skills first** | Project skills override system/user-level skills with same name |
|
||||
| **Default image generation** | Use `skills/baoyu-image-gen/SKILL.md` unless user specifies otherwise |
|
||||
| **Default image generation** | Use `skills/baoyu-imagine/SKILL.md` unless user specifies otherwise |
|
||||
|
||||
Priority: project `skills/` → `$HOME/.baoyu-skills/` → system-level.
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ This repository now supports publishing each `skills/baoyu-*` directory as an in
|
||||
ClawHub installs skills individually, not as one marketplace bundle. After publishing, users can install specific skills such as:
|
||||
|
||||
```bash
|
||||
clawhub install baoyu-image-gen
|
||||
clawhub install baoyu-imagine
|
||||
clawhub install baoyu-markdown-to-html
|
||||
```
|
||||
|
||||
@@ -52,16 +52,14 @@ Run the following command in Claude Code:
|
||||
|
||||
1. Select **Browse and install plugins**
|
||||
2. Select **baoyu-skills**
|
||||
3. Select the plugin(s) you want to install
|
||||
3. Select the **baoyu-skills** plugin
|
||||
4. Select **Install now**
|
||||
|
||||
**Option 2: Direct Install**
|
||||
|
||||
```bash
|
||||
# Install specific plugin
|
||||
/plugin install content-skills@baoyu-skills
|
||||
/plugin install ai-generation-skills@baoyu-skills
|
||||
/plugin install utility-skills@baoyu-skills
|
||||
# Install the marketplace's single plugin
|
||||
/plugin install baoyu-skills@baoyu-skills
|
||||
```
|
||||
|
||||
**Option 3: Ask the Agent**
|
||||
@@ -70,13 +68,13 @@ Simply tell Claude Code:
|
||||
|
||||
> Please install Skills from github.com/JimLiu/baoyu-skills
|
||||
|
||||
### Available Plugins
|
||||
### Available Plugin
|
||||
|
||||
| Plugin | Description | Skills |
|
||||
|--------|-------------|--------|
|
||||
| **content-skills** | Content generation and publishing | [xhs-images](#baoyu-xhs-images), [infographic](#baoyu-infographic), [cover-image](#baoyu-cover-image), [slide-deck](#baoyu-slide-deck), [comic](#baoyu-comic), [article-illustrator](#baoyu-article-illustrator), [post-to-x](#baoyu-post-to-x), [post-to-wechat](#baoyu-post-to-wechat), [post-to-weibo](#baoyu-post-to-weibo) |
|
||||
| **ai-generation-skills** | AI-powered generation backends | [image-gen](#baoyu-image-gen), [danger-gemini-web](#baoyu-danger-gemini-web) |
|
||||
| **utility-skills** | Utility tools for content processing | [url-to-markdown](#baoyu-url-to-markdown), [danger-x-to-markdown](#baoyu-danger-x-to-markdown), [compress-image](#baoyu-compress-image), [format-markdown](#baoyu-format-markdown), [markdown-to-html](#baoyu-markdown-to-html), [translate](#baoyu-translate) |
|
||||
The marketplace now exposes a single plugin so each skill is registered exactly once.
|
||||
|
||||
| Plugin | Description | Includes |
|
||||
|--------|-------------|----------|
|
||||
| **baoyu-skills** | Content generation, AI backends, and utility tools for daily work efficiency | All skills in this repository, organized below as Content Skills, AI Generation Skills, and Utility Skills |
|
||||
|
||||
## Update Skills
|
||||
|
||||
@@ -663,40 +661,58 @@ Post content to Weibo (微博). Supports regular posts with text, images, and vi
|
||||
|
||||
AI-powered generation backends.
|
||||
|
||||
#### baoyu-image-gen
|
||||
#### baoyu-imagine
|
||||
|
||||
AI SDK-based image generation using OpenAI, Google, OpenRouter, DashScope (Aliyun Tongyi Wanxiang), Jimeng (即梦), Seedream (豆包), and Replicate APIs. Supports text-to-image, reference images, aspect ratios, and quality presets.
|
||||
AI SDK-based image generation using OpenAI, Azure OpenAI, Google, OpenRouter, DashScope (Aliyun Tongyi Wanxiang), MiniMax, Jimeng (即梦), Seedream (豆包), and Replicate APIs. Supports text-to-image, reference images, aspect ratios, custom sizes, batch generation, and quality presets.
|
||||
|
||||
```bash
|
||||
# Basic generation (auto-detect provider)
|
||||
/baoyu-image-gen --prompt "A cute cat" --image cat.png
|
||||
/baoyu-imagine --prompt "A cute cat" --image cat.png
|
||||
|
||||
# With aspect ratio
|
||||
/baoyu-image-gen --prompt "A landscape" --image landscape.png --ar 16:9
|
||||
/baoyu-imagine --prompt "A landscape" --image landscape.png --ar 16:9
|
||||
|
||||
# High quality (2k)
|
||||
/baoyu-image-gen --prompt "A banner" --image banner.png --quality 2k
|
||||
/baoyu-imagine --prompt "A banner" --image banner.png --quality 2k
|
||||
|
||||
# Specific provider
|
||||
/baoyu-image-gen --prompt "A cat" --image cat.png --provider openai
|
||||
/baoyu-imagine --prompt "A cat" --image cat.png --provider openai
|
||||
|
||||
# Azure OpenAI (model = deployment name)
|
||||
/baoyu-imagine --prompt "A cat" --image cat.png --provider azure --model gpt-image-1.5
|
||||
|
||||
# OpenRouter
|
||||
/baoyu-image-gen --prompt "A cat" --image cat.png --provider openrouter
|
||||
/baoyu-imagine --prompt "A cat" --image cat.png --provider openrouter
|
||||
|
||||
# OpenRouter with reference images
|
||||
/baoyu-imagine --prompt "Make it blue" --image out.png --provider openrouter --model google/gemini-3.1-flash-image-preview --ref source.png
|
||||
|
||||
# DashScope (Aliyun Tongyi Wanxiang)
|
||||
/baoyu-image-gen --prompt "一只可爱的猫" --image cat.png --provider dashscope
|
||||
/baoyu-imagine --prompt "一只可爱的猫" --image cat.png --provider dashscope
|
||||
|
||||
# DashScope with custom size
|
||||
/baoyu-imagine --prompt "为咖啡品牌设计一张 21:9 横幅海报,包含清晰中文标题" --image banner.png --provider dashscope --model qwen-image-2.0-pro --size 2048x872
|
||||
|
||||
# MiniMax
|
||||
/baoyu-imagine --prompt "A fashion editorial portrait by a bright studio window" --image out.jpg --provider minimax
|
||||
|
||||
# MiniMax with subject reference
|
||||
/baoyu-imagine --prompt "A girl stands by the library window, cinematic lighting" --image out.jpg --provider minimax --model image-01 --ref portrait.png --ar 16:9
|
||||
|
||||
# Replicate
|
||||
/baoyu-image-gen --prompt "A cat" --image cat.png --provider replicate
|
||||
/baoyu-imagine --prompt "A cat" --image cat.png --provider replicate
|
||||
|
||||
# Jimeng (即梦)
|
||||
/baoyu-image-gen --prompt "一只可爱的猫" --image cat.png --provider jimeng
|
||||
/baoyu-imagine --prompt "一只可爱的猫" --image cat.png --provider jimeng
|
||||
|
||||
# Seedream (豆包)
|
||||
/baoyu-image-gen --prompt "一只可爱的猫" --image cat.png --provider seedream
|
||||
/baoyu-imagine --prompt "一只可爱的猫" --image cat.png --provider seedream
|
||||
|
||||
# With reference images (Google, OpenAI, OpenRouter, or Replicate)
|
||||
/baoyu-image-gen --prompt "Make it blue" --image out.png --ref source.png
|
||||
# With reference images (Google, OpenAI, Azure OpenAI, OpenRouter, Replicate, MiniMax, or Seedream 5.0/4.5/4.0)
|
||||
/baoyu-imagine --prompt "Make it blue" --image out.png --ref source.png
|
||||
|
||||
# Batch mode
|
||||
/baoyu-imagine --batchfile batch.json --jobs 4 --json
|
||||
```
|
||||
|
||||
**Options**:
|
||||
@@ -705,44 +721,73 @@ AI SDK-based image generation using OpenAI, Google, OpenRouter, DashScope (Aliyu
|
||||
| `--prompt`, `-p` | Prompt text |
|
||||
| `--promptfiles` | Read prompt from files (concatenated) |
|
||||
| `--image` | Output image path (required) |
|
||||
| `--provider` | `google`, `openai`, `openrouter`, `dashscope`, `jimeng`, `seedream` or `replicate` (default: auto-detect; prefers google) |
|
||||
| `--model`, `-m` | Model ID |
|
||||
| `--batchfile` | JSON batch file for multi-image generation |
|
||||
| `--jobs` | Worker count for batch mode |
|
||||
| `--provider` | `google`, `openai`, `azure`, `openrouter`, `dashscope`, `minimax`, `jimeng`, `seedream`, or `replicate` |
|
||||
| `--model`, `-m` | Model ID or deployment name. Azure uses deployment name; OpenRouter uses full model IDs; MiniMax uses `image-01` / `image-01-live` |
|
||||
| `--ar` | Aspect ratio (e.g., `16:9`, `1:1`, `4:3`) |
|
||||
| `--size` | Size (e.g., `1024x1024`) |
|
||||
| `--quality` | `normal` or `2k` (default: `2k`) |
|
||||
| `--ref` | Reference images (Google, OpenAI, OpenRouter or Replicate) |
|
||||
| `--imageSize` | `1K`, `2K`, or `4K` for Google/OpenRouter |
|
||||
| `--ref` | Reference images (Google, OpenAI, Azure OpenAI, OpenRouter, Replicate, MiniMax, or Seedream 5.0/4.5/4.0) |
|
||||
| `--n` | Number of images per request |
|
||||
| `--json` | JSON output |
|
||||
|
||||
**Environment Variables** (see [Environment Configuration](#environment-configuration) for setup):
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `OPENAI_API_KEY` | OpenAI API key | - |
|
||||
| `AZURE_OPENAI_API_KEY` | Azure OpenAI API key | - |
|
||||
| `OPENROUTER_API_KEY` | OpenRouter API key | - |
|
||||
| `GOOGLE_API_KEY` | Google API key | - |
|
||||
| `GEMINI_API_KEY` | Alias for `GOOGLE_API_KEY` | - |
|
||||
| `DASHSCOPE_API_KEY` | DashScope API key (Aliyun) | - |
|
||||
| `MINIMAX_API_KEY` | MiniMax API key | - |
|
||||
| `REPLICATE_API_TOKEN` | Replicate API token | - |
|
||||
| `JIMENG_ACCESS_KEY_ID` | Jimeng Volcengine access key | - |
|
||||
| `JIMENG_SECRET_ACCESS_KEY` | Jimeng Volcengine secret key | - |
|
||||
| `ARK_API_KEY` | Seedream Volcengine ARK API key | - |
|
||||
| `OPENAI_IMAGE_MODEL` | OpenAI model | `gpt-image-1.5` |
|
||||
| `AZURE_OPENAI_DEPLOYMENT` | Azure default deployment name | - |
|
||||
| `AZURE_OPENAI_IMAGE_MODEL` | Backward-compatible Azure deployment/model alias | `gpt-image-1.5` |
|
||||
| `OPENROUTER_IMAGE_MODEL` | OpenRouter model | `google/gemini-3.1-flash-image-preview` |
|
||||
| `GOOGLE_IMAGE_MODEL` | Google model | `gemini-3-pro-image-preview` |
|
||||
| `DASHSCOPE_IMAGE_MODEL` | DashScope model | `z-image-turbo` |
|
||||
| `DASHSCOPE_IMAGE_MODEL` | DashScope model | `qwen-image-2.0-pro` |
|
||||
| `MINIMAX_IMAGE_MODEL` | MiniMax model | `image-01` |
|
||||
| `REPLICATE_IMAGE_MODEL` | Replicate model | `google/nano-banana-pro` |
|
||||
| `JIMENG_IMAGE_MODEL` | Jimeng model | `jimeng_t2i_v40` |
|
||||
| `SEEDREAM_IMAGE_MODEL` | Seedream model | `doubao-seedream-5-0-260128` |
|
||||
| `OPENAI_BASE_URL` | Custom OpenAI endpoint | - |
|
||||
| `OPENAI_IMAGE_USE_CHAT` | Use `/chat/completions` for OpenAI image generation | `false` |
|
||||
| `AZURE_OPENAI_BASE_URL` | Azure resource or deployment endpoint | - |
|
||||
| `AZURE_API_VERSION` | Azure image API version | `2025-04-01-preview` |
|
||||
| `OPENROUTER_BASE_URL` | Custom OpenRouter endpoint | `https://openrouter.ai/api/v1` |
|
||||
| `OPENROUTER_HTTP_REFERER` | Optional app/site URL for OpenRouter attribution | - |
|
||||
| `OPENROUTER_TITLE` | Optional app name for OpenRouter attribution | - |
|
||||
| `GOOGLE_BASE_URL` | Custom Google endpoint | - |
|
||||
| `DASHSCOPE_BASE_URL` | Custom DashScope endpoint | - |
|
||||
| `MINIMAX_BASE_URL` | Custom MiniMax endpoint | `https://api.minimax.io` |
|
||||
| `REPLICATE_BASE_URL` | Custom Replicate endpoint | - |
|
||||
| `JIMENG_BASE_URL` | Custom Jimeng endpoint | `https://visual.volcengineapi.com` |
|
||||
| `JIMENG_REGION` | Jimeng region | `cn-north-1` |
|
||||
| `SEEDREAM_BASE_URL` | Custom Seedream endpoint | `https://ark.cn-beijing.volces.com/api/v3` |
|
||||
| `BAOYU_IMAGE_GEN_MAX_WORKERS` | Override batch worker cap | `10` |
|
||||
| `BAOYU_IMAGE_GEN_<PROVIDER>_CONCURRENCY` | Override provider concurrency | provider-specific |
|
||||
| `BAOYU_IMAGE_GEN_<PROVIDER>_START_INTERVAL_MS` | Override provider request start gap | provider-specific |
|
||||
|
||||
**Provider Notes**:
|
||||
- Azure OpenAI: `--model` means Azure deployment name, not the underlying model family.
|
||||
- DashScope: `qwen-image-2.0-pro` is the recommended default for custom `--size`, `21:9`, and strong Chinese/English text rendering.
|
||||
- MiniMax: `image-01` supports documented custom `width` / `height`; `image-01-live` is lower latency and works best with `--ar`.
|
||||
- MiniMax reference images are sent as `subject_reference`; the current API is specialized toward character / portrait consistency.
|
||||
- Jimeng does not support reference images.
|
||||
- Seedream reference images are supported by Seedream 5.0 / 4.5 / 4.0, not Seedream 3.0.
|
||||
|
||||
**Provider Auto-Selection**:
|
||||
1. If `--provider` specified → use it
|
||||
2. If only one API key available → use that provider
|
||||
3. If multiple available → default to Google
|
||||
1. If `--provider` is specified → use it
|
||||
2. If `--ref` is provided and no provider is specified → try Google, then OpenAI, Azure, OpenRouter, Replicate, Seedream, and finally MiniMax
|
||||
3. If only one API key is available → use that provider
|
||||
4. If multiple providers are available → default to Google
|
||||
|
||||
#### baoyu-danger-gemini-web
|
||||
|
||||
@@ -766,6 +811,40 @@ Interacts with Gemini Web to generate text and images.
|
||||
|
||||
Utility tools for content processing.
|
||||
|
||||
#### baoyu-youtube-transcript
|
||||
|
||||
Download YouTube video transcripts/subtitles and cover images. Supports multiple languages, translation, chapters, and speaker identification. Caches raw data for fast re-formatting.
|
||||
|
||||
```bash
|
||||
# Default: markdown with timestamps
|
||||
/baoyu-youtube-transcript https://www.youtube.com/watch?v=VIDEO_ID
|
||||
|
||||
# Specify languages (priority order)
|
||||
/baoyu-youtube-transcript https://youtu.be/VIDEO_ID --languages zh,en,ja
|
||||
|
||||
# With chapters and speaker identification
|
||||
/baoyu-youtube-transcript https://youtu.be/VIDEO_ID --chapters --speakers
|
||||
|
||||
# SRT subtitle format
|
||||
/baoyu-youtube-transcript https://youtu.be/VIDEO_ID --format srt
|
||||
|
||||
# List available transcripts
|
||||
/baoyu-youtube-transcript https://youtu.be/VIDEO_ID --list
|
||||
```
|
||||
|
||||
**Options**:
|
||||
| Option | Description | Default |
|
||||
|--------|-------------|---------|
|
||||
| `<url-or-id>` | YouTube URL or video ID | Required |
|
||||
| `--languages <codes>` | Language codes, comma-separated | `en` |
|
||||
| `--format <fmt>` | Output format: `text`, `srt` | `text` |
|
||||
| `--translate <code>` | Translate to specified language | |
|
||||
| `--chapters` | Chapter segmentation from video description | |
|
||||
| `--speakers` | Speaker identification (requires AI post-processing) | |
|
||||
| `--no-timestamps` | Disable timestamps | |
|
||||
| `--list` | List available transcripts | |
|
||||
| `--refresh` | Force re-fetch, ignore cache | |
|
||||
|
||||
#### baoyu-url-to-markdown
|
||||
|
||||
Fetch any URL via Chrome CDP and convert to clean markdown. Saves rendered HTML snapshot alongside the markdown, and automatically falls back to a legacy extractor when Defuddle fails.
|
||||
@@ -966,7 +1045,7 @@ Custom style descriptions are also accepted, e.g., `--style "poetic and lyrical"
|
||||
Some skills require API keys or custom configuration. Environment variables can be set in `.env` files:
|
||||
|
||||
**Load Priority** (higher priority overrides lower):
|
||||
1. CLI environment variables (e.g., `OPENAI_API_KEY=xxx /baoyu-image-gen ...`)
|
||||
1. CLI environment variables (e.g., `OPENAI_API_KEY=xxx /baoyu-imagine ...`)
|
||||
2. `process.env` (system environment)
|
||||
3. `<cwd>/.baoyu-skills/.env` (project-level)
|
||||
4. `~/.baoyu-skills/.env` (user-level)
|
||||
@@ -983,11 +1062,20 @@ cat > ~/.baoyu-skills/.env << 'EOF'
|
||||
OPENAI_API_KEY=sk-xxx
|
||||
OPENAI_IMAGE_MODEL=gpt-image-1.5
|
||||
# OPENAI_BASE_URL=https://api.openai.com/v1
|
||||
# OPENAI_IMAGE_USE_CHAT=false
|
||||
|
||||
# Azure OpenAI
|
||||
AZURE_OPENAI_API_KEY=xxx
|
||||
AZURE_OPENAI_BASE_URL=https://your-resource.openai.azure.com
|
||||
AZURE_OPENAI_DEPLOYMENT=gpt-image-1.5
|
||||
# AZURE_API_VERSION=2025-04-01-preview
|
||||
|
||||
# OpenRouter
|
||||
OPENROUTER_API_KEY=sk-or-xxx
|
||||
OPENROUTER_IMAGE_MODEL=google/gemini-3.1-flash-image-preview
|
||||
# OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
|
||||
# OPENROUTER_HTTP_REFERER=https://your-app.example.com
|
||||
# OPENROUTER_TITLE=Your App Name
|
||||
|
||||
# Google
|
||||
GOOGLE_API_KEY=xxx
|
||||
@@ -996,9 +1084,14 @@ GOOGLE_IMAGE_MODEL=gemini-3-pro-image-preview
|
||||
|
||||
# DashScope (Aliyun Tongyi Wanxiang)
|
||||
DASHSCOPE_API_KEY=sk-xxx
|
||||
DASHSCOPE_IMAGE_MODEL=z-image-turbo
|
||||
DASHSCOPE_IMAGE_MODEL=qwen-image-2.0-pro
|
||||
# DASHSCOPE_BASE_URL=https://dashscope.aliyuncs.com/api/v1
|
||||
|
||||
# MiniMax
|
||||
MINIMAX_API_KEY=xxx
|
||||
MINIMAX_IMAGE_MODEL=image-01
|
||||
# MINIMAX_BASE_URL=https://api.minimax.io
|
||||
|
||||
# Replicate
|
||||
REPLICATE_API_TOKEN=r8_xxx
|
||||
REPLICATE_IMAGE_MODEL=google/nano-banana-pro
|
||||
|
||||
+124
-31
@@ -32,7 +32,7 @@ npx skills add jimliu/baoyu-skills
|
||||
ClawHub 按“单个 skill”安装,不是把整个 marketplace 一次性装进去。发布后,用户可以按需安装:
|
||||
|
||||
```bash
|
||||
clawhub install baoyu-image-gen
|
||||
clawhub install baoyu-imagine
|
||||
clawhub install baoyu-markdown-to-html
|
||||
```
|
||||
|
||||
@@ -52,16 +52,14 @@ clawhub install baoyu-markdown-to-html
|
||||
|
||||
1. 选择 **Browse and install plugins**
|
||||
2. 选择 **baoyu-skills**
|
||||
3. 选择要安装的插件
|
||||
3. 选择 **baoyu-skills** 插件
|
||||
4. 选择 **Install now**
|
||||
|
||||
**方式二:直接安装**
|
||||
|
||||
```bash
|
||||
# 安装指定插件
|
||||
/plugin install content-skills@baoyu-skills
|
||||
/plugin install ai-generation-skills@baoyu-skills
|
||||
/plugin install utility-skills@baoyu-skills
|
||||
# 安装 marketplace 中唯一的插件
|
||||
/plugin install baoyu-skills@baoyu-skills
|
||||
```
|
||||
|
||||
**方式三:告诉 Agent**
|
||||
@@ -72,11 +70,11 @@ clawhub install baoyu-markdown-to-html
|
||||
|
||||
### 可用插件
|
||||
|
||||
| 插件 | 说明 | 包含技能 |
|
||||
现在 marketplace 只暴露一个插件,这样每个 skill 只会注册一次。
|
||||
|
||||
| 插件 | 说明 | 包含内容 |
|
||||
|------|------|----------|
|
||||
| **content-skills** | 内容生成和发布 | [xhs-images](#baoyu-xhs-images), [infographic](#baoyu-infographic), [cover-image](#baoyu-cover-image), [slide-deck](#baoyu-slide-deck), [comic](#baoyu-comic), [article-illustrator](#baoyu-article-illustrator), [post-to-x](#baoyu-post-to-x), [post-to-wechat](#baoyu-post-to-wechat), [post-to-weibo](#baoyu-post-to-weibo) |
|
||||
| **ai-generation-skills** | AI 生成后端 | [image-gen](#baoyu-image-gen), [danger-gemini-web](#baoyu-danger-gemini-web) |
|
||||
| **utility-skills** | 内容处理工具 | [url-to-markdown](#baoyu-url-to-markdown), [danger-x-to-markdown](#baoyu-danger-x-to-markdown), [compress-image](#baoyu-compress-image), [format-markdown](#baoyu-format-markdown), [markdown-to-html](#baoyu-markdown-to-html), [translate](#baoyu-translate) |
|
||||
| **baoyu-skills** | 提供内容生成、AI 后端和日常效率工具技能 | 仓库中的全部 skills,仍按下方的内容技能、AI 生成技能、工具技能三个分类展示 |
|
||||
|
||||
## 更新技能
|
||||
|
||||
@@ -663,40 +661,58 @@ accounts:
|
||||
|
||||
AI 驱动的生成后端。
|
||||
|
||||
#### baoyu-image-gen
|
||||
#### baoyu-imagine
|
||||
|
||||
基于 AI SDK 的图像生成,支持 OpenAI、Google、OpenRouter、DashScope(阿里通义万相)、即梦(Jimeng)、豆包(Seedream)和 Replicate API。支持文生图、参考图、宽高比和质量预设。
|
||||
基于 AI SDK 的图像生成,支持 OpenAI、Azure OpenAI、Google、OpenRouter、DashScope(阿里通义万相)、MiniMax、即梦(Jimeng)、豆包(Seedream)和 Replicate API。支持文生图、参考图、宽高比、自定义尺寸、批量生成和质量预设。
|
||||
|
||||
```bash
|
||||
# 基础生成(自动检测服务商)
|
||||
/baoyu-image-gen --prompt "一只可爱的猫" --image cat.png
|
||||
/baoyu-imagine --prompt "一只可爱的猫" --image cat.png
|
||||
|
||||
# 指定宽高比
|
||||
/baoyu-image-gen --prompt "风景图" --image landscape.png --ar 16:9
|
||||
/baoyu-imagine --prompt "风景图" --image landscape.png --ar 16:9
|
||||
|
||||
# 高质量(2k 分辨率)
|
||||
/baoyu-image-gen --prompt "横幅图" --image banner.png --quality 2k
|
||||
/baoyu-imagine --prompt "横幅图" --image banner.png --quality 2k
|
||||
|
||||
# 指定服务商
|
||||
/baoyu-image-gen --prompt "一只猫" --image cat.png --provider openai
|
||||
/baoyu-imagine --prompt "一只猫" --image cat.png --provider openai
|
||||
|
||||
# Azure OpenAI(model 为部署名称)
|
||||
/baoyu-imagine --prompt "一只猫" --image cat.png --provider azure --model gpt-image-1.5
|
||||
|
||||
# OpenRouter
|
||||
/baoyu-image-gen --prompt "一只猫" --image cat.png --provider openrouter
|
||||
/baoyu-imagine --prompt "一只猫" --image cat.png --provider openrouter
|
||||
|
||||
# OpenRouter + 参考图
|
||||
/baoyu-imagine --prompt "把它变成蓝色" --image out.png --provider openrouter --model google/gemini-3.1-flash-image-preview --ref source.png
|
||||
|
||||
# DashScope(阿里通义万相)
|
||||
/baoyu-image-gen --prompt "一只可爱的猫" --image cat.png --provider dashscope
|
||||
/baoyu-imagine --prompt "一只可爱的猫" --image cat.png --provider dashscope
|
||||
|
||||
# DashScope 自定义尺寸
|
||||
/baoyu-imagine --prompt "为咖啡品牌设计一张 21:9 横幅海报,包含清晰中文标题" --image banner.png --provider dashscope --model qwen-image-2.0-pro --size 2048x872
|
||||
|
||||
# MiniMax
|
||||
/baoyu-imagine --prompt "A fashion editorial portrait by a bright studio window" --image out.jpg --provider minimax
|
||||
|
||||
# MiniMax + 角色参考图
|
||||
/baoyu-imagine --prompt "A girl stands by the library window, cinematic lighting" --image out.jpg --provider minimax --model image-01 --ref portrait.png --ar 16:9
|
||||
|
||||
# Replicate
|
||||
/baoyu-image-gen --prompt "一只猫" --image cat.png --provider replicate
|
||||
/baoyu-imagine --prompt "一只猫" --image cat.png --provider replicate
|
||||
|
||||
# 即梦(Jimeng)
|
||||
/baoyu-image-gen --prompt "一只可爱的猫" --image cat.png --provider jimeng
|
||||
/baoyu-imagine --prompt "一只可爱的猫" --image cat.png --provider jimeng
|
||||
|
||||
# 豆包(Seedream)
|
||||
/baoyu-image-gen --prompt "一只可爱的猫" --image cat.png --provider seedream
|
||||
/baoyu-imagine --prompt "一只可爱的猫" --image cat.png --provider seedream
|
||||
|
||||
# 带参考图(Google、OpenAI、OpenRouter 或 Replicate)
|
||||
/baoyu-image-gen --prompt "把它变成蓝色" --image out.png --ref source.png
|
||||
# 带参考图(Google、OpenAI、Azure OpenAI、OpenRouter、Replicate、MiniMax 或 Seedream 5.0/4.5/4.0)
|
||||
/baoyu-imagine --prompt "把它变成蓝色" --image out.png --ref source.png
|
||||
|
||||
# 批量模式
|
||||
/baoyu-imagine --batchfile batch.json --jobs 4 --json
|
||||
```
|
||||
|
||||
**选项**:
|
||||
@@ -705,44 +721,73 @@ AI 驱动的生成后端。
|
||||
| `--prompt`, `-p` | 提示词文本 |
|
||||
| `--promptfiles` | 从文件读取提示词(多文件拼接) |
|
||||
| `--image` | 输出图片路径(必需) |
|
||||
| `--provider` | `google`、`openai`、`openrouter`、`dashscope`、`jimeng`、`seedream` 或 `replicate`(默认:自动检测,优先 google) |
|
||||
| `--model`, `-m` | 模型 ID |
|
||||
| `--batchfile` | 多图批量生成的 JSON 文件 |
|
||||
| `--jobs` | 批量模式的并发 worker 数 |
|
||||
| `--provider` | `google`、`openai`、`azure`、`openrouter`、`dashscope`、`minimax`、`jimeng`、`seedream` 或 `replicate` |
|
||||
| `--model`, `-m` | 模型 ID 或部署名。Azure 使用部署名;OpenRouter 使用完整模型 ID;MiniMax 使用 `image-01` / `image-01-live` |
|
||||
| `--ar` | 宽高比(如 `16:9`、`1:1`、`4:3`) |
|
||||
| `--size` | 尺寸(如 `1024x1024`) |
|
||||
| `--quality` | `normal` 或 `2k`(默认:`2k`) |
|
||||
| `--ref` | 参考图片(Google、OpenAI、OpenRouter 或 Replicate) |
|
||||
| `--imageSize` | Google/OpenRouter 使用的 `1K`、`2K`、`4K` |
|
||||
| `--ref` | 参考图片(Google、OpenAI、Azure OpenAI、OpenRouter、Replicate、MiniMax 或 Seedream 5.0/4.5/4.0) |
|
||||
| `--n` | 单次请求生成图片数量 |
|
||||
| `--json` | 输出 JSON 结果 |
|
||||
|
||||
**环境变量**(配置方法见[环境配置](#环境配置)):
|
||||
| 变量 | 说明 | 默认值 |
|
||||
|------|------|--------|
|
||||
| `OPENAI_API_KEY` | OpenAI API 密钥 | - |
|
||||
| `AZURE_OPENAI_API_KEY` | Azure OpenAI API 密钥 | - |
|
||||
| `OPENROUTER_API_KEY` | OpenRouter API 密钥 | - |
|
||||
| `GOOGLE_API_KEY` | Google API 密钥 | - |
|
||||
| `GEMINI_API_KEY` | `GOOGLE_API_KEY` 的别名 | - |
|
||||
| `DASHSCOPE_API_KEY` | DashScope API 密钥(阿里云) | - |
|
||||
| `MINIMAX_API_KEY` | MiniMax API 密钥 | - |
|
||||
| `REPLICATE_API_TOKEN` | Replicate API Token | - |
|
||||
| `JIMENG_ACCESS_KEY_ID` | 即梦火山引擎 Access Key | - |
|
||||
| `JIMENG_SECRET_ACCESS_KEY` | 即梦火山引擎 Secret Key | - |
|
||||
| `ARK_API_KEY` | 豆包火山引擎 ARK API 密钥 | - |
|
||||
| `OPENAI_IMAGE_MODEL` | OpenAI 模型 | `gpt-image-1.5` |
|
||||
| `AZURE_OPENAI_DEPLOYMENT` | Azure 默认部署名 | - |
|
||||
| `AZURE_OPENAI_IMAGE_MODEL` | 兼容旧配置的 Azure 部署/模型别名 | `gpt-image-1.5` |
|
||||
| `OPENROUTER_IMAGE_MODEL` | OpenRouter 模型 | `google/gemini-3.1-flash-image-preview` |
|
||||
| `GOOGLE_IMAGE_MODEL` | Google 模型 | `gemini-3-pro-image-preview` |
|
||||
| `DASHSCOPE_IMAGE_MODEL` | DashScope 模型 | `z-image-turbo` |
|
||||
| `DASHSCOPE_IMAGE_MODEL` | DashScope 模型 | `qwen-image-2.0-pro` |
|
||||
| `MINIMAX_IMAGE_MODEL` | MiniMax 模型 | `image-01` |
|
||||
| `REPLICATE_IMAGE_MODEL` | Replicate 模型 | `google/nano-banana-pro` |
|
||||
| `JIMENG_IMAGE_MODEL` | 即梦模型 | `jimeng_t2i_v40` |
|
||||
| `SEEDREAM_IMAGE_MODEL` | 豆包模型 | `doubao-seedream-5-0-260128` |
|
||||
| `OPENAI_BASE_URL` | 自定义 OpenAI 端点 | - |
|
||||
| `OPENAI_IMAGE_USE_CHAT` | OpenAI 改走 `/chat/completions` | `false` |
|
||||
| `AZURE_OPENAI_BASE_URL` | Azure 资源或部署端点 | - |
|
||||
| `AZURE_API_VERSION` | Azure 图像 API 版本 | `2025-04-01-preview` |
|
||||
| `OPENROUTER_BASE_URL` | 自定义 OpenRouter 端点 | `https://openrouter.ai/api/v1` |
|
||||
| `OPENROUTER_HTTP_REFERER` | OpenRouter 归因用站点 URL | - |
|
||||
| `OPENROUTER_TITLE` | OpenRouter 归因用应用名 | - |
|
||||
| `GOOGLE_BASE_URL` | 自定义 Google 端点 | - |
|
||||
| `DASHSCOPE_BASE_URL` | 自定义 DashScope 端点 | - |
|
||||
| `MINIMAX_BASE_URL` | 自定义 MiniMax 端点 | `https://api.minimax.io` |
|
||||
| `REPLICATE_BASE_URL` | 自定义 Replicate 端点 | - |
|
||||
| `JIMENG_BASE_URL` | 自定义即梦端点 | `https://visual.volcengineapi.com` |
|
||||
| `JIMENG_REGION` | 即梦区域 | `cn-north-1` |
|
||||
| `SEEDREAM_BASE_URL` | 自定义豆包端点 | `https://ark.cn-beijing.volces.com/api/v3` |
|
||||
| `BAOYU_IMAGE_GEN_MAX_WORKERS` | 批量模式最大 worker 数 | `10` |
|
||||
| `BAOYU_IMAGE_GEN_<PROVIDER>_CONCURRENCY` | 覆盖 provider 并发数 | provider 默认值 |
|
||||
| `BAOYU_IMAGE_GEN_<PROVIDER>_START_INTERVAL_MS` | 覆盖 provider 请求启动间隔 | provider 默认值 |
|
||||
|
||||
**Provider 说明**:
|
||||
- Azure OpenAI:`--model` 表示 Azure deployment name,不是底层模型家族名。
|
||||
- DashScope:`qwen-image-2.0-pro` 是自定义 `--size`、`21:9` 和中英文排版的推荐默认模型。
|
||||
- MiniMax:`image-01` 支持官方文档里的自定义 `width` / `height`;`image-01-live` 更偏低延迟,适合配合 `--ar` 使用。
|
||||
- MiniMax 参考图会走 `subject_reference`,当前能力更偏角色 / 人像一致性。
|
||||
- 即梦不支持参考图。
|
||||
- 豆包参考图能力仅适用于 Seedream 5.0 / 4.5 / 4.0,不适用于 Seedream 3.0。
|
||||
|
||||
**服务商自动选择**:
|
||||
1. 如果指定了 `--provider` → 使用指定的
|
||||
2. 如果只有一个 API 密钥 → 使用对应服务商
|
||||
3. 如果多个可用 → 默认使用 Google
|
||||
2. 如果传了 `--ref` 且未指定 provider → 依次尝试 Google、OpenAI、Azure、OpenRouter、Replicate、Seedream,最后是 MiniMax
|
||||
3. 如果只有一个 API 密钥 → 使用对应服务商
|
||||
4. 如果多个可用 → 默认使用 Google
|
||||
|
||||
#### baoyu-danger-gemini-web
|
||||
|
||||
@@ -766,6 +811,40 @@ AI 驱动的生成后端。
|
||||
|
||||
内容处理工具。
|
||||
|
||||
#### baoyu-youtube-transcript
|
||||
|
||||
下载 YouTube 视频字幕/转录文本和封面图片。支持多语言、翻译、章节分段和说话人识别。缓存原始数据以便快速重新格式化。
|
||||
|
||||
```bash
|
||||
# 默认:带时间戳的 Markdown
|
||||
/baoyu-youtube-transcript https://www.youtube.com/watch?v=VIDEO_ID
|
||||
|
||||
# 指定语言(按优先级排列)
|
||||
/baoyu-youtube-transcript https://youtu.be/VIDEO_ID --languages zh,en,ja
|
||||
|
||||
# 章节分段 + 说话人识别
|
||||
/baoyu-youtube-transcript https://youtu.be/VIDEO_ID --chapters --speakers
|
||||
|
||||
# SRT 字幕格式
|
||||
/baoyu-youtube-transcript https://youtu.be/VIDEO_ID --format srt
|
||||
|
||||
# 列出可用字幕
|
||||
/baoyu-youtube-transcript https://youtu.be/VIDEO_ID --list
|
||||
```
|
||||
|
||||
**选项**:
|
||||
| 选项 | 说明 | 默认值 |
|
||||
|------|------|--------|
|
||||
| `<url-or-id>` | YouTube URL 或视频 ID | 必填 |
|
||||
| `--languages <codes>` | 语言代码,逗号分隔 | `en` |
|
||||
| `--format <fmt>` | 输出格式:`text`、`srt` | `text` |
|
||||
| `--translate <code>` | 翻译为指定语言 | |
|
||||
| `--chapters` | 根据视频描述进行章节分段 | |
|
||||
| `--speakers` | 说话人识别(需 AI 后处理) | |
|
||||
| `--no-timestamps` | 禁用时间戳 | |
|
||||
| `--list` | 列出可用字幕 | |
|
||||
| `--refresh` | 强制重新获取,忽略缓存 | |
|
||||
|
||||
#### baoyu-url-to-markdown
|
||||
|
||||
通过 Chrome CDP 抓取任意 URL 并转换为 Markdown。同时保存渲染后的 HTML 快照,Defuddle 失败时自动回退到旧版提取器。
|
||||
@@ -966,7 +1045,7 @@ AI 驱动的生成后端。
|
||||
部分技能需要 API 密钥或自定义配置。环境变量可以在 `.env` 文件中设置:
|
||||
|
||||
**加载优先级**(高优先级覆盖低优先级):
|
||||
1. 命令行环境变量(如 `OPENAI_API_KEY=xxx /baoyu-image-gen ...`)
|
||||
1. 命令行环境变量(如 `OPENAI_API_KEY=xxx /baoyu-imagine ...`)
|
||||
2. `process.env`(系统环境变量)
|
||||
3. `<cwd>/.baoyu-skills/.env`(项目级)
|
||||
4. `~/.baoyu-skills/.env`(用户级)
|
||||
@@ -983,11 +1062,20 @@ cat > ~/.baoyu-skills/.env << 'EOF'
|
||||
OPENAI_API_KEY=sk-xxx
|
||||
OPENAI_IMAGE_MODEL=gpt-image-1.5
|
||||
# OPENAI_BASE_URL=https://api.openai.com/v1
|
||||
# OPENAI_IMAGE_USE_CHAT=false
|
||||
|
||||
# Azure OpenAI
|
||||
AZURE_OPENAI_API_KEY=xxx
|
||||
AZURE_OPENAI_BASE_URL=https://your-resource.openai.azure.com
|
||||
AZURE_OPENAI_DEPLOYMENT=gpt-image-1.5
|
||||
# AZURE_API_VERSION=2025-04-01-preview
|
||||
|
||||
# OpenRouter
|
||||
OPENROUTER_API_KEY=sk-or-xxx
|
||||
OPENROUTER_IMAGE_MODEL=google/gemini-3.1-flash-image-preview
|
||||
# OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
|
||||
# OPENROUTER_HTTP_REFERER=https://your-app.example.com
|
||||
# OPENROUTER_TITLE=你的应用名
|
||||
|
||||
# Google
|
||||
GOOGLE_API_KEY=xxx
|
||||
@@ -996,9 +1084,14 @@ GOOGLE_IMAGE_MODEL=gemini-3-pro-image-preview
|
||||
|
||||
# DashScope(阿里通义万相)
|
||||
DASHSCOPE_API_KEY=sk-xxx
|
||||
DASHSCOPE_IMAGE_MODEL=z-image-turbo
|
||||
DASHSCOPE_IMAGE_MODEL=qwen-image-2.0-pro
|
||||
# DASHSCOPE_BASE_URL=https://dashscope.aliyuncs.com/api/v1
|
||||
|
||||
# MiniMax
|
||||
MINIMAX_API_KEY=xxx
|
||||
MINIMAX_IMAGE_MODEL=image-01
|
||||
# MINIMAX_BASE_URL=https://api.minimax.io
|
||||
|
||||
# Replicate
|
||||
REPLICATE_API_TOKEN=r8_xxx
|
||||
REPLICATE_IMAGE_MODEL=google/nano-banana-pro
|
||||
|
||||
+11
-9
@@ -34,20 +34,22 @@ metadata:
|
||||
1. Create `skills/baoyu-<name>/SKILL.md` with YAML front matter
|
||||
2. Add TypeScript in `skills/baoyu-<name>/scripts/` (if applicable)
|
||||
3. Add prompt templates in `skills/baoyu-<name>/prompts/` if needed
|
||||
4. Register in `marketplace.json` under appropriate category
|
||||
4. Register the skill in `.claude-plugin/marketplace.json` under the `baoyu-skills` plugin entry
|
||||
5. Add Script Directory section to SKILL.md if skill has scripts
|
||||
6. Add openclaw metadata to frontmatter
|
||||
|
||||
## Category Selection
|
||||
## Skill Grouping
|
||||
|
||||
| If your skill... | Use category |
|
||||
|------------------|--------------|
|
||||
| Generates visual content (images, slides, comics) | `content-skills` |
|
||||
| Publishes to platforms (X, WeChat, Weibo) | `content-skills` |
|
||||
| Provides AI generation backend | `ai-generation-skills` |
|
||||
| Converts or processes content | `utility-skills` |
|
||||
All skills are registered under the single `baoyu-skills` plugin. Use these logical groups when deciding where the skill should appear in the docs:
|
||||
|
||||
New category: add plugin object to `marketplace.json` with `name`, `description`, `skills[]`.
|
||||
| If your skill... | Use group |
|
||||
|------------------|-----------|
|
||||
| Generates visual content (images, slides, comics) | Content Skills |
|
||||
| Publishes to platforms (X, WeChat, Weibo) | Content Skills |
|
||||
| Provides AI generation backend | AI Generation Skills |
|
||||
| Converts or processes content | Utility Skills |
|
||||
|
||||
If you add a new logical group, update the docs that present grouped skills, but keep the skill registered under the single `baoyu-skills` plugin entry.
|
||||
|
||||
## Writing Descriptions
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ Skills that require image generation MUST delegate to available image generation
|
||||
|
||||
## Skill Selection
|
||||
|
||||
**Default**: `skills/baoyu-image-gen/SKILL.md` (unless user specifies otherwise).
|
||||
**Default**: `skills/baoyu-imagine/SKILL.md` (unless user specifies otherwise).
|
||||
|
||||
1. Read skill's SKILL.md for parameters and capabilities
|
||||
2. If user requests different skill, check `skills/` for alternatives
|
||||
@@ -16,7 +16,7 @@ Skills that require image generation MUST delegate to available image generation
|
||||
### Step N: Generate Images
|
||||
|
||||
**Skill Selection**:
|
||||
1. Check available skills (`baoyu-image-gen` default, or `baoyu-danger-gemini-web`)
|
||||
1. Check available skills (`baoyu-imagine` default, or `baoyu-danger-gemini-web`)
|
||||
2. Read selected skill's SKILL.md for parameters
|
||||
3. If multiple skills available, ask user to choose
|
||||
|
||||
@@ -27,7 +27,7 @@ Skills that require image generation MUST delegate to available image generation
|
||||
4. On failure, auto-retry once before reporting error
|
||||
```
|
||||
|
||||
**Batch Parallel** (`baoyu-image-gen` only): concurrent workers with per-provider throttling via `batch.max_workers` in EXTEND.md.
|
||||
**Batch Parallel** (`baoyu-imagine` only): concurrent workers with per-provider throttling via `batch.max_workers` in EXTEND.md.
|
||||
|
||||
## Output Path Convention
|
||||
|
||||
|
||||
Generated
+2062
File diff suppressed because it is too large
Load Diff
+12
-2
@@ -2,8 +2,18 @@
|
||||
"name": "baoyu-skills",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
"scripts": {
|
||||
"test": "node --test",
|
||||
"test:coverage": "node --experimental-test-coverage --test"
|
||||
"test": "node --import tsx --test",
|
||||
"test:coverage": "node --import tsx --experimental-test-coverage --test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@mozilla/readability": "^0.6.0",
|
||||
"linkedom": "^0.18.12",
|
||||
"turndown": "^7.2.2",
|
||||
"turndown-plugin-gfm": "^1.0.2",
|
||||
"tsx": "^4.20.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import http from "node:http";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import test, { type TestContext } from "node:test";
|
||||
|
||||
import {
|
||||
discoverRunningChromeDebugPort,
|
||||
findChromeExecutable,
|
||||
findExistingChromeDebugPort,
|
||||
getFreePort,
|
||||
openPageSession,
|
||||
resolveSharedChromeProfileDir,
|
||||
waitForChromeDebugPort,
|
||||
} from "./index.ts";
|
||||
|
||||
function useEnv(
|
||||
t: TestContext,
|
||||
values: Record<string, string | null>,
|
||||
): void {
|
||||
const previous = new Map<string, string | undefined>();
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
previous.set(key, process.env[key]);
|
||||
if (value == null) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
t.after(() => {
|
||||
for (const [key, value] of previous.entries()) {
|
||||
if (value == null) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function makeTempDir(prefix: string): Promise<string> {
|
||||
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
async function startDebugServer(port: number): Promise<http.Server> {
|
||||
const server = http.createServer((req, res) => {
|
||||
if (req.url === "/json/version") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({
|
||||
webSocketDebuggerUrl: `ws://127.0.0.1:${port}/devtools/browser/demo`,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(port, "127.0.0.1", () => resolve());
|
||||
});
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
async function closeServer(server: http.Server): Promise<void> {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error) reject(error);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function shellPathForPlatform(): string | null {
|
||||
if (process.platform === "win32") return null;
|
||||
return "/bin/bash";
|
||||
}
|
||||
|
||||
async function startFakeChromiumProcess(port: number): Promise<ChildProcess | null> {
|
||||
const shell = shellPathForPlatform();
|
||||
if (!shell) return null;
|
||||
|
||||
const child = spawn(
|
||||
shell,
|
||||
[
|
||||
"-lc",
|
||||
`exec -a chromium-mock ${JSON.stringify(process.execPath)} -e 'setInterval(() => {}, 1000)' -- --remote-debugging-port=${port}`,
|
||||
],
|
||||
{ stdio: "ignore" },
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
return child;
|
||||
}
|
||||
|
||||
async function stopProcess(child: ChildProcess | null): Promise<void> {
|
||||
if (!child) return;
|
||||
if (child.exitCode !== null || child.signalCode !== null) return;
|
||||
|
||||
child.kill("SIGTERM");
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
|
||||
if (child.exitCode !== null || child.signalCode !== null) return;
|
||||
await new Promise((resolve) => child.once("exit", resolve));
|
||||
}
|
||||
|
||||
test("getFreePort honors a fixed environment override and otherwise allocates a TCP port", async (t) => {
|
||||
useEnv(t, { TEST_FIXED_PORT: "45678" });
|
||||
assert.equal(await getFreePort("TEST_FIXED_PORT"), 45678);
|
||||
|
||||
const dynamicPort = await getFreePort();
|
||||
assert.ok(Number.isInteger(dynamicPort));
|
||||
assert.ok(dynamicPort > 0);
|
||||
});
|
||||
|
||||
test("findChromeExecutable prefers env overrides and falls back to candidate paths", async (t) => {
|
||||
const root = await makeTempDir("baoyu-chrome-bin-");
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
|
||||
const envChrome = path.join(root, "env-chrome");
|
||||
const fallbackChrome = path.join(root, "fallback-chrome");
|
||||
await fs.writeFile(envChrome, "");
|
||||
await fs.writeFile(fallbackChrome, "");
|
||||
|
||||
useEnv(t, { BAOYU_CHROME_PATH: envChrome });
|
||||
assert.equal(
|
||||
findChromeExecutable({
|
||||
envNames: ["BAOYU_CHROME_PATH"],
|
||||
candidates: { default: [fallbackChrome] },
|
||||
}),
|
||||
envChrome,
|
||||
);
|
||||
|
||||
useEnv(t, { BAOYU_CHROME_PATH: null });
|
||||
assert.equal(
|
||||
findChromeExecutable({
|
||||
envNames: ["BAOYU_CHROME_PATH"],
|
||||
candidates: { default: [fallbackChrome] },
|
||||
}),
|
||||
fallbackChrome,
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveSharedChromeProfileDir supports env overrides, WSL paths, and default suffixes", (t) => {
|
||||
useEnv(t, { BAOYU_SHARED_PROFILE: "/tmp/custom-profile" });
|
||||
assert.equal(
|
||||
resolveSharedChromeProfileDir({
|
||||
envNames: ["BAOYU_SHARED_PROFILE"],
|
||||
appDataDirName: "demo-app",
|
||||
profileDirName: "demo-profile",
|
||||
}),
|
||||
path.resolve("/tmp/custom-profile"),
|
||||
);
|
||||
|
||||
useEnv(t, { BAOYU_SHARED_PROFILE: null });
|
||||
assert.equal(
|
||||
resolveSharedChromeProfileDir({
|
||||
wslWindowsHome: "/mnt/c/Users/demo",
|
||||
appDataDirName: "demo-app",
|
||||
profileDirName: "demo-profile",
|
||||
}),
|
||||
path.join("/mnt/c/Users/demo", ".local", "share", "demo-app", "demo-profile"),
|
||||
);
|
||||
|
||||
const fallback = resolveSharedChromeProfileDir({
|
||||
appDataDirName: "demo-app",
|
||||
profileDirName: "demo-profile",
|
||||
});
|
||||
assert.match(fallback, /demo-app[\\/]demo-profile$/);
|
||||
});
|
||||
|
||||
test("findExistingChromeDebugPort reads DevToolsActivePort and validates it against a live endpoint", async (t) => {
|
||||
const root = await makeTempDir("baoyu-cdp-profile-");
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
|
||||
const port = await getFreePort();
|
||||
const server = await startDebugServer(port);
|
||||
t.after(() => closeServer(server));
|
||||
|
||||
await fs.writeFile(path.join(root, "DevToolsActivePort"), `${port}\n/devtools/browser/demo\n`);
|
||||
|
||||
const found = await findExistingChromeDebugPort({ profileDir: root, timeoutMs: 1000 });
|
||||
assert.equal(found, port);
|
||||
});
|
||||
|
||||
test("discoverRunningChromeDebugPort reads DevToolsActivePort from the provided user-data dir", async (t) => {
|
||||
const root = await makeTempDir("baoyu-cdp-user-data-");
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
|
||||
const port = await getFreePort();
|
||||
const server = await startDebugServer(port);
|
||||
t.after(() => closeServer(server));
|
||||
|
||||
await fs.writeFile(path.join(root, "DevToolsActivePort"), `${port}\n/devtools/browser/demo\n`);
|
||||
|
||||
const found = await discoverRunningChromeDebugPort({
|
||||
userDataDirs: [root],
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
assert.deepEqual(found, {
|
||||
port,
|
||||
wsUrl: `ws://127.0.0.1:${port}/devtools/browser/demo`,
|
||||
});
|
||||
});
|
||||
|
||||
test("discoverRunningChromeDebugPort ignores unrelated debugging processes", async (t) => {
|
||||
if (process.platform === "win32") {
|
||||
t.skip("Process discovery fallback is not used on Windows.");
|
||||
return;
|
||||
}
|
||||
|
||||
const root = await makeTempDir("baoyu-cdp-user-data-");
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
|
||||
const port = await getFreePort();
|
||||
const server = await startDebugServer(port);
|
||||
t.after(() => closeServer(server));
|
||||
|
||||
const fakeChromium = await startFakeChromiumProcess(port);
|
||||
t.after(async () => { await stopProcess(fakeChromium); });
|
||||
|
||||
const found = await discoverRunningChromeDebugPort({
|
||||
userDataDirs: [root],
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
assert.equal(found, null);
|
||||
});
|
||||
|
||||
test("openPageSession reports whether it created a new target", async () => {
|
||||
const calls: string[] = [];
|
||||
const cdpExisting = {
|
||||
send: async <T>(method: string): Promise<T> => {
|
||||
calls.push(method);
|
||||
if (method === "Target.getTargets") {
|
||||
return {
|
||||
targetInfos: [{ targetId: "existing-target", type: "page", url: "https://gemini.google.com/app" }],
|
||||
} as T;
|
||||
}
|
||||
if (method === "Target.attachToTarget") return { sessionId: "session-existing" } as T;
|
||||
throw new Error(`Unexpected method: ${method}`);
|
||||
},
|
||||
};
|
||||
|
||||
const existing = await openPageSession({
|
||||
cdp: cdpExisting as never,
|
||||
reusing: false,
|
||||
url: "https://gemini.google.com/app",
|
||||
matchTarget: (target) => target.url.includes("gemini.google.com"),
|
||||
activateTarget: false,
|
||||
});
|
||||
|
||||
assert.deepEqual(existing, {
|
||||
sessionId: "session-existing",
|
||||
targetId: "existing-target",
|
||||
createdTarget: false,
|
||||
});
|
||||
assert.deepEqual(calls, ["Target.getTargets", "Target.attachToTarget"]);
|
||||
|
||||
const createCalls: string[] = [];
|
||||
const cdpCreated = {
|
||||
send: async <T>(method: string): Promise<T> => {
|
||||
createCalls.push(method);
|
||||
if (method === "Target.getTargets") return { targetInfos: [] } as T;
|
||||
if (method === "Target.createTarget") return { targetId: "created-target" } as T;
|
||||
if (method === "Target.attachToTarget") return { sessionId: "session-created" } as T;
|
||||
throw new Error(`Unexpected method: ${method}`);
|
||||
},
|
||||
};
|
||||
|
||||
const created = await openPageSession({
|
||||
cdp: cdpCreated as never,
|
||||
reusing: false,
|
||||
url: "https://gemini.google.com/app",
|
||||
matchTarget: (target) => target.url.includes("gemini.google.com"),
|
||||
activateTarget: false,
|
||||
});
|
||||
|
||||
assert.deepEqual(created, {
|
||||
sessionId: "session-created",
|
||||
targetId: "created-target",
|
||||
createdTarget: true,
|
||||
});
|
||||
assert.deepEqual(createCalls, ["Target.getTargets", "Target.createTarget", "Target.attachToTarget"]);
|
||||
});
|
||||
|
||||
test("waitForChromeDebugPort retries until the debug endpoint becomes available", async (t) => {
|
||||
const port = await getFreePort();
|
||||
|
||||
const serverPromise = (async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
const server = await startDebugServer(port);
|
||||
t.after(() => closeServer(server));
|
||||
})();
|
||||
|
||||
const websocketUrl = await waitForChromeDebugPort(port, 4000, {
|
||||
includeLastError: true,
|
||||
});
|
||||
await serverPromise;
|
||||
|
||||
assert.equal(websocketUrl, `ws://127.0.0.1:${port}/devtools/browser/demo`);
|
||||
});
|
||||
@@ -43,6 +43,19 @@ type FindExistingChromeDebugPortOptions = {
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
export type ChromeChannel = "stable" | "beta" | "canary" | "dev";
|
||||
|
||||
export type DiscoveredChrome = {
|
||||
port: number;
|
||||
wsUrl: string;
|
||||
};
|
||||
|
||||
type DiscoverRunningChromeOptions = {
|
||||
channels?: ChromeChannel[];
|
||||
userDataDirs?: string[];
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
type LaunchChromeOptions = {
|
||||
chromePath: string;
|
||||
profileDir: string;
|
||||
@@ -73,6 +86,7 @@ type OpenPageSessionOptions = {
|
||||
export type PageSession = {
|
||||
sessionId: string;
|
||||
targetId: string;
|
||||
createdTarget: boolean;
|
||||
};
|
||||
|
||||
export function sleep(ms: number): Promise<void> {
|
||||
@@ -173,16 +187,32 @@ async function isDebugPortReady(port: number, timeoutMs = 3_000): Promise<boolea
|
||||
}
|
||||
}
|
||||
|
||||
function isPortListening(port: number, timeoutMs = 3_000): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const socket = new net.Socket();
|
||||
const timer = setTimeout(() => { socket.destroy(); resolve(false); }, timeoutMs);
|
||||
socket.once("connect", () => { clearTimeout(timer); socket.destroy(); resolve(true); });
|
||||
socket.once("error", () => { clearTimeout(timer); resolve(false); });
|
||||
socket.connect(port, "127.0.0.1");
|
||||
});
|
||||
}
|
||||
|
||||
function parseDevToolsActivePort(filePath: string): { port: number; wsPath: string } | null {
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, "utf-8");
|
||||
const lines = content.split(/\r?\n/);
|
||||
const port = Number.parseInt(lines[0]?.trim() ?? "", 10);
|
||||
const wsPath = lines[1]?.trim();
|
||||
if (port > 0 && wsPath) return { port, wsPath };
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function findExistingChromeDebugPort(options: FindExistingChromeDebugPortOptions): Promise<number | null> {
|
||||
const timeoutMs = options.timeoutMs ?? 3_000;
|
||||
const portFile = path.join(options.profileDir, "DevToolsActivePort");
|
||||
const parsed = parseDevToolsActivePort(path.join(options.profileDir, "DevToolsActivePort"));
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(portFile, "utf-8");
|
||||
const [portLine] = content.split(/\r?\n/);
|
||||
const port = Number.parseInt(portLine?.trim() ?? "", 10);
|
||||
if (port > 0 && await isDebugPortReady(port, timeoutMs)) return port;
|
||||
} catch {}
|
||||
if (parsed && parsed.port > 0 && await isDebugPortReady(parsed.port, timeoutMs)) return parsed.port;
|
||||
|
||||
if (process.platform === "win32") return null;
|
||||
|
||||
@@ -204,6 +234,88 @@ export async function findExistingChromeDebugPort(options: FindExistingChromeDeb
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getDefaultChromeUserDataDirs(channels: ChromeChannel[] = ["stable"]): string[] {
|
||||
const home = os.homedir();
|
||||
const dirs: string[] = [];
|
||||
|
||||
const channelDirs: Record<string, { darwin: string; linux: string; win32: string }> = {
|
||||
stable: {
|
||||
darwin: path.join(home, "Library", "Application Support", "Google", "Chrome"),
|
||||
linux: path.join(home, ".config", "google-chrome"),
|
||||
win32: path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Google", "Chrome", "User Data"),
|
||||
},
|
||||
beta: {
|
||||
darwin: path.join(home, "Library", "Application Support", "Google", "Chrome Beta"),
|
||||
linux: path.join(home, ".config", "google-chrome-beta"),
|
||||
win32: path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Google", "Chrome Beta", "User Data"),
|
||||
},
|
||||
canary: {
|
||||
darwin: path.join(home, "Library", "Application Support", "Google", "Chrome Canary"),
|
||||
linux: path.join(home, ".config", "google-chrome-canary"),
|
||||
win32: path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Google", "Chrome SxS", "User Data"),
|
||||
},
|
||||
dev: {
|
||||
darwin: path.join(home, "Library", "Application Support", "Google", "Chrome Dev"),
|
||||
linux: path.join(home, ".config", "google-chrome-dev"),
|
||||
win32: path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Google", "Chrome Dev", "User Data"),
|
||||
},
|
||||
};
|
||||
|
||||
const platform = process.platform === "darwin" ? "darwin" : process.platform === "win32" ? "win32" : "linux";
|
||||
|
||||
for (const ch of channels) {
|
||||
const entry = channelDirs[ch];
|
||||
if (entry) dirs.push(entry[platform]);
|
||||
}
|
||||
|
||||
return dirs;
|
||||
}
|
||||
|
||||
// Best-effort reuse of an already-running local CDP session discovered from
|
||||
// known Chrome user-data dirs. This is distinct from Chrome DevTools MCP's
|
||||
// prompt-based --autoConnect flow.
|
||||
export async function discoverRunningChromeDebugPort(options: DiscoverRunningChromeOptions = {}): Promise<DiscoveredChrome | null> {
|
||||
const channels = options.channels ?? ["stable", "beta", "canary", "dev"];
|
||||
const timeoutMs = options.timeoutMs ?? 3_000;
|
||||
|
||||
const userDataDirs = (options.userDataDirs ?? getDefaultChromeUserDataDirs(channels))
|
||||
.map((dir) => path.resolve(dir));
|
||||
for (const dir of userDataDirs) {
|
||||
const parsed = parseDevToolsActivePort(path.join(dir, "DevToolsActivePort"));
|
||||
if (!parsed) continue;
|
||||
if (await isPortListening(parsed.port, timeoutMs)) {
|
||||
return { port: parsed.port, wsUrl: `ws://127.0.0.1:${parsed.port}${parsed.wsPath}` };
|
||||
}
|
||||
}
|
||||
|
||||
if (process.platform !== "win32") {
|
||||
try {
|
||||
const result = spawnSync("ps", ["aux"], { encoding: "utf-8", timeout: 5_000 });
|
||||
if (result.status === 0 && result.stdout) {
|
||||
const lines = result.stdout
|
||||
.split("\n")
|
||||
.filter((line) =>
|
||||
line.includes("--remote-debugging-port=") &&
|
||||
userDataDirs.some((dir) => line.includes(dir))
|
||||
);
|
||||
|
||||
for (const line of lines) {
|
||||
const portMatch = line.match(/--remote-debugging-port=(\d+)/);
|
||||
const port = Number.parseInt(portMatch?.[1] ?? "", 10);
|
||||
if (port > 0 && await isDebugPortReady(port, timeoutMs)) {
|
||||
try {
|
||||
const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(`http://127.0.0.1:${port}/json/version`, { timeoutMs });
|
||||
if (version.webSocketDebuggerUrl) return { port, wsUrl: version.webSocketDebuggerUrl };
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function waitForChromeDebugPort(
|
||||
port: number,
|
||||
timeoutMs: number,
|
||||
@@ -376,10 +488,12 @@ export function killChrome(chrome: ChildProcess): void {
|
||||
|
||||
export async function openPageSession(options: OpenPageSessionOptions): Promise<PageSession> {
|
||||
let targetId: string;
|
||||
let createdTarget = false;
|
||||
|
||||
if (options.reusing) {
|
||||
const created = await options.cdp.send<{ targetId: string }>("Target.createTarget", { url: options.url });
|
||||
targetId = created.targetId;
|
||||
createdTarget = true;
|
||||
} else {
|
||||
const targets = await options.cdp.send<{ targetInfos: ChromeTargetInfo[] }>("Target.getTargets");
|
||||
const existing = targets.targetInfos.find(options.matchTarget);
|
||||
@@ -388,6 +502,7 @@ export async function openPageSession(options: OpenPageSessionOptions): Promise<
|
||||
} else {
|
||||
const created = await options.cdp.send<{ targetId: string }>("Target.createTarget", { url: options.url });
|
||||
targetId = created.targetId;
|
||||
createdTarget = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -404,5 +519,5 @@ export async function openPageSession(options: OpenPageSessionOptions): Promise<
|
||||
if (options.enableDom) await options.cdp.send("DOM.enable", {}, { sessionId });
|
||||
if (options.enableNetwork) await options.cdp.send("Network.enable", {}, { sessionId });
|
||||
|
||||
return { sessionId, targetId };
|
||||
return { sessionId, targetId, createdTarget };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
extractSummaryFromBody,
|
||||
extractTitleFromMarkdown,
|
||||
parseFrontmatter,
|
||||
pickFirstString,
|
||||
serializeFrontmatter,
|
||||
stripWrappingQuotes,
|
||||
toFrontmatterString,
|
||||
} from "./content.ts";
|
||||
|
||||
test("parseFrontmatter extracts YAML fields and strips wrapping quotes", () => {
|
||||
const input = `---
|
||||
title: "Hello World"
|
||||
author: ‘Baoyu’
|
||||
summary: plain text
|
||||
---
|
||||
# Heading
|
||||
|
||||
Body`;
|
||||
|
||||
const result = parseFrontmatter(input);
|
||||
|
||||
assert.deepEqual(result.frontmatter, {
|
||||
title: "Hello World",
|
||||
author: "Baoyu",
|
||||
summary: "plain text",
|
||||
});
|
||||
assert.match(result.body, /^# Heading/);
|
||||
});
|
||||
|
||||
test("parseFrontmatter returns original content when no frontmatter exists", () => {
|
||||
const input = "# No frontmatter";
|
||||
assert.deepEqual(parseFrontmatter(input), {
|
||||
frontmatter: {},
|
||||
body: input,
|
||||
});
|
||||
});
|
||||
|
||||
test("serializeFrontmatter renders YAML only when fields exist", () => {
|
||||
assert.equal(serializeFrontmatter({}), "");
|
||||
assert.equal(
|
||||
serializeFrontmatter({ title: "Hello", author: "Baoyu" }),
|
||||
"---\ntitle: Hello\nauthor: Baoyu\n---\n",
|
||||
);
|
||||
});
|
||||
|
||||
test("quote and frontmatter string helpers normalize mixed scalar values", () => {
|
||||
assert.equal(stripWrappingQuotes(`" quoted "`), "quoted");
|
||||
assert.equal(stripWrappingQuotes("“ 中文标题 ”"), "中文标题");
|
||||
assert.equal(stripWrappingQuotes("plain"), "plain");
|
||||
|
||||
assert.equal(toFrontmatterString("'hello'"), "hello");
|
||||
assert.equal(toFrontmatterString(42), "42");
|
||||
assert.equal(toFrontmatterString(false), "false");
|
||||
assert.equal(toFrontmatterString({}), undefined);
|
||||
|
||||
assert.equal(
|
||||
pickFirstString({ summary: 123, title: "" }, ["title", "summary"]),
|
||||
"123",
|
||||
);
|
||||
});
|
||||
|
||||
test("markdown title and summary extraction skip non-body content and clean formatting", () => {
|
||||
const markdown = `
|
||||

|
||||
## “My Title”
|
||||
|
||||
Body paragraph
|
||||
`;
|
||||
assert.equal(extractTitleFromMarkdown(markdown), "My Title");
|
||||
|
||||
const summary = extractSummaryFromBody(
|
||||
`
|
||||
# Heading
|
||||
> quote
|
||||
- list
|
||||
1. ordered
|
||||
\`\`\`
|
||||
code
|
||||
\`\`\`
|
||||
This is **the first paragraph** with [a link](https://example.com) and \`inline code\` that should be summarized cleanly.
|
||||
`,
|
||||
70,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
summary,
|
||||
"This is the first paragraph with a link and inline code that should...",
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import test, { type TestContext } from "node:test";
|
||||
|
||||
import { COLOR_PRESETS, FONT_FAMILY_MAP } from "./constants.ts";
|
||||
import {
|
||||
buildMarkdownDocumentMeta,
|
||||
formatTimestamp,
|
||||
renderMarkdownDocument,
|
||||
resolveColorToken,
|
||||
resolveFontFamilyToken,
|
||||
resolveMarkdownStyle,
|
||||
resolveRenderOptions,
|
||||
} from "./document.ts";
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, `\\$&`);
|
||||
}
|
||||
|
||||
function findInlineStyle(html: string, tagName: string, text: string): string {
|
||||
const pattern = new RegExp(
|
||||
`<${tagName}[^>]*style="([^"]*)"[^>]*>${escapeRegExp(text)}</${tagName}>`,
|
||||
);
|
||||
const match = html.match(pattern);
|
||||
assert.ok(match, `Expected inline style for <${tagName}>${text}</${tagName}>`);
|
||||
return match![1]!;
|
||||
}
|
||||
|
||||
function useCwd(t: TestContext, cwd: string): void {
|
||||
const previous = process.cwd();
|
||||
process.chdir(cwd);
|
||||
t.after(() => {
|
||||
process.chdir(previous);
|
||||
});
|
||||
}
|
||||
|
||||
async function makeTempDir(prefix: string): Promise<string> {
|
||||
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
test("document token resolvers map known presets and allow passthrough values", () => {
|
||||
assert.equal(resolveColorToken("green"), COLOR_PRESETS.green);
|
||||
assert.equal(resolveColorToken("#123456"), "#123456");
|
||||
assert.equal(resolveColorToken(), undefined);
|
||||
|
||||
assert.equal(resolveFontFamilyToken("mono"), FONT_FAMILY_MAP.mono);
|
||||
assert.equal(resolveFontFamilyToken("Custom Font"), "Custom Font");
|
||||
assert.equal(resolveFontFamilyToken(), undefined);
|
||||
});
|
||||
|
||||
test("formatTimestamp uses compact sortable datetime output", () => {
|
||||
const date = new Date("2026-03-13T21:04:05.000Z");
|
||||
const pad = (value: number) => String(value).padStart(2, "0");
|
||||
const expected = `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(
|
||||
date.getDate(),
|
||||
)}${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`;
|
||||
|
||||
assert.equal(formatTimestamp(date), expected);
|
||||
});
|
||||
|
||||
test("buildMarkdownDocumentMeta prefers frontmatter and falls back to markdown title and summary", () => {
|
||||
const metaFromYaml = buildMarkdownDocumentMeta(
|
||||
"# Markdown Title\n\nBody summary paragraph that should be ignored.",
|
||||
{
|
||||
title: `" YAML Title "`,
|
||||
author: "'Baoyu'",
|
||||
summary: `" YAML Summary "`,
|
||||
},
|
||||
"fallback",
|
||||
);
|
||||
|
||||
assert.deepEqual(metaFromYaml, {
|
||||
title: "YAML Title",
|
||||
author: "Baoyu",
|
||||
description: "YAML Summary",
|
||||
});
|
||||
|
||||
const metaFromMarkdown = buildMarkdownDocumentMeta(
|
||||
`## “Markdown Title”\n\nThis is the first body paragraph that should become the summary because it is long enough.`,
|
||||
{},
|
||||
"fallback",
|
||||
);
|
||||
|
||||
assert.equal(metaFromMarkdown.title, "Markdown Title");
|
||||
assert.match(metaFromMarkdown.description ?? "", /^This is the first body paragraph/);
|
||||
});
|
||||
|
||||
test("resolveMarkdownStyle merges theme defaults with explicit overrides", () => {
|
||||
const style = resolveMarkdownStyle({
|
||||
theme: "modern",
|
||||
primaryColor: "#112233",
|
||||
fontFamily: "Custom Sans",
|
||||
});
|
||||
|
||||
assert.equal(style.primaryColor, "#112233");
|
||||
assert.equal(style.fontFamily, "Custom Sans");
|
||||
assert.equal(style.fontSize, "15px");
|
||||
assert.equal(style.containerBg, "rgba(250, 249, 245, 1)");
|
||||
});
|
||||
|
||||
test("resolveRenderOptions loads workspace EXTEND settings and lets explicit options win", async (t) => {
|
||||
const root = await makeTempDir("baoyu-md-render-options-");
|
||||
useCwd(t, root);
|
||||
|
||||
const extendPath = path.join(
|
||||
root,
|
||||
".baoyu-skills",
|
||||
"baoyu-markdown-to-html",
|
||||
"EXTEND.md",
|
||||
);
|
||||
await fs.mkdir(path.dirname(extendPath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
extendPath,
|
||||
`---
|
||||
default_theme: modern
|
||||
default_color: green
|
||||
default_font_family: mono
|
||||
default_font_size: 17
|
||||
default_code_theme: nord
|
||||
mac_code_block: false
|
||||
show_line_number: true
|
||||
cite: true
|
||||
count: true
|
||||
legend: title-alt
|
||||
keep_title: true
|
||||
---
|
||||
`,
|
||||
);
|
||||
|
||||
const fromExtend = resolveRenderOptions();
|
||||
assert.equal(fromExtend.theme, "modern");
|
||||
assert.equal(fromExtend.primaryColor, COLOR_PRESETS.green);
|
||||
assert.equal(fromExtend.fontFamily, FONT_FAMILY_MAP.mono);
|
||||
assert.equal(fromExtend.fontSize, "17px");
|
||||
assert.equal(fromExtend.codeTheme, "nord");
|
||||
assert.equal(fromExtend.isMacCodeBlock, false);
|
||||
assert.equal(fromExtend.isShowLineNumber, true);
|
||||
assert.equal(fromExtend.citeStatus, true);
|
||||
assert.equal(fromExtend.countStatus, true);
|
||||
assert.equal(fromExtend.legend, "title-alt");
|
||||
assert.equal(fromExtend.keepTitle, true);
|
||||
|
||||
const explicit = resolveRenderOptions({
|
||||
theme: "simple",
|
||||
fontSize: "18px",
|
||||
keepTitle: false,
|
||||
});
|
||||
assert.equal(explicit.theme, "simple");
|
||||
assert.equal(explicit.fontSize, "18px");
|
||||
assert.equal(explicit.keepTitle, false);
|
||||
});
|
||||
|
||||
test("renderMarkdownDocument layers default rules into grace theme before CSS inlining", async () => {
|
||||
const { html } = await renderMarkdownDocument(
|
||||
`## Section\n\nParagraph with **bold** text.`,
|
||||
{ keepTitle: true, theme: "grace" },
|
||||
);
|
||||
|
||||
const h2Style = findInlineStyle(html, "h2", "Section");
|
||||
assert.match(h2Style, /background: #92617E/);
|
||||
assert.match(h2Style, /box-shadow: 0 4px 6px rgba\(0, 0, 0, 0\.1\)/);
|
||||
|
||||
const pMatch = html.match(/<p[^>]*style="([^"]*)"[^>]*>/);
|
||||
assert.ok(pMatch, "Expected inline style on <p> tag");
|
||||
assert.match(pMatch![1]!, /color:/);
|
||||
|
||||
const strongPattern = /<strong[^>]*style="([^"]*)"[^>]*>bold<\/strong>/;
|
||||
const strongMatch = html.match(strongPattern);
|
||||
assert.ok(strongMatch, "Expected inline style for <strong>bold</strong>");
|
||||
assert.match(strongMatch![1]!, /font-weight:/);
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { DEFAULT_STYLE } from "./constants.ts";
|
||||
import {
|
||||
buildCss,
|
||||
buildHtmlDocument,
|
||||
modifyHtmlStructure,
|
||||
normalizeCssText,
|
||||
normalizeInlineCss,
|
||||
removeFirstHeading,
|
||||
} from "./html-builder.ts";
|
||||
|
||||
test("buildCss injects style variables and concatenates base and theme CSS", () => {
|
||||
const css = buildCss("body { color: red; }", ".theme { color: blue; }");
|
||||
|
||||
assert.match(css, /--md-primary-color: #0F4C81;/);
|
||||
assert.match(css, /body \{ color: red; \}/);
|
||||
assert.match(css, /\.theme \{ color: blue; \}/);
|
||||
});
|
||||
|
||||
test("buildHtmlDocument includes optional meta tags and code theme CSS", () => {
|
||||
const html = buildHtmlDocument(
|
||||
{
|
||||
title: "Doc",
|
||||
author: "Baoyu",
|
||||
description: "Summary",
|
||||
},
|
||||
"body { color: red; }",
|
||||
"<article>Hello</article>",
|
||||
".hljs { color: blue; }",
|
||||
);
|
||||
|
||||
assert.match(html, /<title>Doc<\/title>/);
|
||||
assert.match(html, /meta name="author" content="Baoyu"/);
|
||||
assert.match(html, /meta name="description" content="Summary"/);
|
||||
assert.match(html, /<style>body \{ color: red; \}<\/style>/);
|
||||
assert.match(html, /<style>\.hljs \{ color: blue; \}<\/style>/);
|
||||
assert.match(html, /<article>Hello<\/article>/);
|
||||
});
|
||||
|
||||
test("normalizeCssText and normalizeInlineCss replace variables and strip declarations", () => {
|
||||
const rawCss = `
|
||||
:root { --md-primary-color: #000; --md-font-size: 12px; --foreground: 0 0% 5%; }
|
||||
.box { color: var(--md-primary-color); font-size: var(--md-font-size); background: hsl(var(--foreground)); }
|
||||
`;
|
||||
|
||||
const normalizedCss = normalizeCssText(rawCss, DEFAULT_STYLE);
|
||||
assert.match(normalizedCss, /color: #0F4C81/);
|
||||
assert.match(normalizedCss, /font-size: 16px/);
|
||||
assert.match(normalizedCss, /background: #3f3f3f/);
|
||||
assert.doesNotMatch(normalizedCss, /--md-primary-color/);
|
||||
|
||||
const normalizedHtml = normalizeInlineCss(
|
||||
`<style>${rawCss}</style><div style="color: var(--md-primary-color)"></div>`,
|
||||
DEFAULT_STYLE,
|
||||
);
|
||||
assert.match(normalizedHtml, /color: #0F4C81/);
|
||||
assert.doesNotMatch(normalizedHtml, /var\(--md-primary-color\)/);
|
||||
});
|
||||
|
||||
test("normalizeInlineCss removes quoted custom property values without leaving fragments behind", () => {
|
||||
const normalizedHtml = normalizeInlineCss(
|
||||
`<html style="--md-font-family: Menlo, Monaco, 'Courier New', monospace; color: var(--md-primary-color)"></html>`,
|
||||
DEFAULT_STYLE,
|
||||
);
|
||||
|
||||
assert.match(normalizedHtml, /style=" color: #0F4C81"/);
|
||||
assert.doesNotMatch(normalizedHtml, /Courier New/);
|
||||
assert.doesNotMatch(normalizedHtml, /--md-font-family/);
|
||||
});
|
||||
|
||||
test("HTML structure helpers hoist nested lists and remove the first heading", () => {
|
||||
const nestedList = `<ul><li>Parent<ul><li>Child</li></ul></li></ul>`;
|
||||
assert.equal(
|
||||
modifyHtmlStructure(nestedList),
|
||||
`<ul><li>Parent</li><ul><li>Child</li></ul></ul>`,
|
||||
);
|
||||
|
||||
const html = `<h1>Title</h1><p>Intro</p><h2>Sub</h2>`;
|
||||
assert.equal(removeFirstHeading(html), `<p>Intro</p><h2>Sub</h2>`);
|
||||
});
|
||||
@@ -100,13 +100,13 @@ export function normalizeCssText(cssText: string, style: StyleConfig = DEFAULT_S
|
||||
.replace(/var\(--md-accent-color\)/g, style.accentColor)
|
||||
.replace(/var\(--md-container-bg\)/g, style.containerBg)
|
||||
.replace(/hsl\(var\(--foreground\)\)/g, "#3f3f3f")
|
||||
.replace(/--md-primary-color:\s*[^;"']+;?/g, "")
|
||||
.replace(/--md-font-family:\s*[^;"']+;?/g, "")
|
||||
.replace(/--md-font-size:\s*[^;"']+;?/g, "")
|
||||
.replace(/--blockquote-background:\s*[^;"']+;?/g, "")
|
||||
.replace(/--md-accent-color:\s*[^;"']+;?/g, "")
|
||||
.replace(/--md-container-bg:\s*[^;"']+;?/g, "")
|
||||
.replace(/--foreground:\s*[^;"']+;?/g, "");
|
||||
.replace(/--md-primary-color:\s*[^;]+;?/g, "")
|
||||
.replace(/--md-font-family:\s*[^;]+;?/g, "")
|
||||
.replace(/--md-font-size:\s*[^;]+;?/g, "")
|
||||
.replace(/--blockquote-background:\s*[^;]+;?/g, "")
|
||||
.replace(/--md-accent-color:\s*[^;]+;?/g, "")
|
||||
.replace(/--md-container-bg:\s*[^;]+;?/g, "")
|
||||
.replace(/--foreground:\s*[^;]+;?/g, "");
|
||||
}
|
||||
|
||||
export function normalizeInlineCss(html: string, style: StyleConfig = DEFAULT_STYLE): string {
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
getImageExtension,
|
||||
replaceMarkdownImagesWithPlaceholders,
|
||||
resolveContentImages,
|
||||
resolveImagePath,
|
||||
} from "./images.ts";
|
||||
|
||||
async function makeTempDir(prefix: string): Promise<string> {
|
||||
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
test("replaceMarkdownImagesWithPlaceholders rewrites markdown and tracks image metadata", () => {
|
||||
const result = replaceMarkdownImagesWithPlaceholders(
|
||||
`\n\nText\n\n`,
|
||||
"IMG_",
|
||||
);
|
||||
|
||||
assert.equal(result.markdown, `IMG_1\n\nText\n\nIMG_2`);
|
||||
assert.deepEqual(result.images, [
|
||||
{ alt: "cover", originalPath: "images/cover.png", placeholder: "IMG_1" },
|
||||
{ alt: "diagram", originalPath: "images/diagram.webp", placeholder: "IMG_2" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("image extension and local fallback resolution handle common path variants", async (t) => {
|
||||
assert.equal(getImageExtension("https://example.com/a.jpeg?x=1"), "jpeg");
|
||||
assert.equal(getImageExtension("/tmp/figure"), "png");
|
||||
|
||||
const root = await makeTempDir("baoyu-md-images-");
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
|
||||
const baseDir = path.join(root, "article");
|
||||
const tempDir = path.join(root, "tmp");
|
||||
await fs.mkdir(baseDir, { recursive: true });
|
||||
await fs.mkdir(tempDir, { recursive: true });
|
||||
await fs.writeFile(path.join(baseDir, "figure.webp"), "webp");
|
||||
|
||||
const resolved = await resolveImagePath("figure.png", baseDir, tempDir, "test");
|
||||
assert.equal(resolved, path.join(baseDir, "figure.webp"));
|
||||
});
|
||||
|
||||
test("resolveContentImages resolves image placeholders against the content directory", async (t) => {
|
||||
const root = await makeTempDir("baoyu-md-content-images-");
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
|
||||
const baseDir = path.join(root, "article");
|
||||
const tempDir = path.join(root, "tmp");
|
||||
await fs.mkdir(baseDir, { recursive: true });
|
||||
await fs.mkdir(tempDir, { recursive: true });
|
||||
await fs.writeFile(path.join(baseDir, "cover.png"), "png");
|
||||
|
||||
const resolved = await resolveContentImages(
|
||||
[
|
||||
{
|
||||
alt: "cover",
|
||||
originalPath: "cover.png",
|
||||
placeholder: "IMG_1",
|
||||
},
|
||||
],
|
||||
baseDir,
|
||||
tempDir,
|
||||
"test",
|
||||
);
|
||||
|
||||
assert.deepEqual(resolved, [
|
||||
{
|
||||
alt: "cover",
|
||||
originalPath: "cover.png",
|
||||
placeholder: "IMG_1",
|
||||
localPath: path.join(baseDir, "cover.png"),
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { initRenderer, renderMarkdown } from "./renderer.ts";
|
||||
|
||||
const render = (md: string) => {
|
||||
const r = initRenderer();
|
||||
return renderMarkdown(md, r).html;
|
||||
};
|
||||
|
||||
test("bold with inline code (no underscore)", () => {
|
||||
const html = render("**算出 `logits`,算出 `loss`。**");
|
||||
assert.match(html, /<code[^>]*>logits<\/code>/);
|
||||
assert.match(html, /<code[^>]*>loss<\/code>/);
|
||||
});
|
||||
|
||||
test("bold with inline code (contains underscore)", () => {
|
||||
const html = render("**变成 `input_ids`。**");
|
||||
assert.match(html, /<code[^>]*>input_ids<\/code>/);
|
||||
});
|
||||
|
||||
test("emphasis with inline code", () => {
|
||||
const html = render("*查看 `hidden_states`*");
|
||||
assert.match(html, /<code[^>]*>hidden_states<\/code>/);
|
||||
});
|
||||
|
||||
test("plain inline code (regression)", () => {
|
||||
const html = render("`lm_head`");
|
||||
assert.match(html, /<code[^>]*>lm_head<\/code>/);
|
||||
});
|
||||
|
||||
test("bold without code (regression)", () => {
|
||||
const html = render("**纯粗体文本**");
|
||||
assert.match(html, /<strong[^>]*>纯粗体文本<\/strong>/);
|
||||
assert.doesNotMatch(html, /<code/);
|
||||
});
|
||||
|
||||
test("bold with inline code containing backticks", () => {
|
||||
const html = render("**``a`b``**");
|
||||
assert.match(html, /<code[^>]*>a`b<\/code>/);
|
||||
});
|
||||
|
||||
test("emphasis with inline code containing backticks", () => {
|
||||
const html = render("*``a`b``*");
|
||||
assert.match(html, /<em[^>]*><code[^>]*>a`b<\/code><\/em>/);
|
||||
});
|
||||
|
||||
test("bold with inline code containing consecutive backticks", () => {
|
||||
const html = render("**```a``b```**");
|
||||
assert.match(html, /<code[^>]*>a``b<\/code>/);
|
||||
});
|
||||
|
||||
test("bold with inline code containing only backticks", () => {
|
||||
const html = render("**```` `` ````**");
|
||||
assert.match(html, /<code[^>]*>``<\/code>/);
|
||||
});
|
||||
|
||||
test("bold with inline code containing only spaces", () => {
|
||||
const oneSpace = render("**`` ``**");
|
||||
assert.match(oneSpace, /<code[^>]*> <\/code>/);
|
||||
|
||||
const twoSpaces = render("**`` ``**");
|
||||
assert.match(twoSpaces, /<code[^>]*> <\/code>/);
|
||||
});
|
||||
@@ -109,6 +109,13 @@ function parseFrontMatterAndContent(markdownText: string): ParseResult {
|
||||
}
|
||||
}
|
||||
|
||||
function wrapInlineCode(value: string): string {
|
||||
const runs = value.match(/`+/g);
|
||||
const fence = "`".repeat(Math.max(...(runs?.map((run) => run.length) ?? [0])) + 1);
|
||||
const padding = /^ *$/.test(value) ? "" : " ";
|
||||
return `${fence}${padding}${value}${padding}${fence}`;
|
||||
}
|
||||
|
||||
export function initRenderer(opts: IOpts = {}): RendererAPI {
|
||||
const footnotes: [number, string, string][] = [];
|
||||
let footnoteIndex = 0;
|
||||
@@ -369,6 +376,7 @@ function preprocessCjkEmphasis(markdown: string): string {
|
||||
const tree = processor.parse(markdown);
|
||||
const extractText = (node: any): string => {
|
||||
if (node.type === "text") return node.value;
|
||||
if (node.type === "inlineCode") return wrapInlineCode(node.value);
|
||||
if (node.children) return node.children.map(extractText).join("");
|
||||
return "";
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { ThemeName } from "./types.js";
|
||||
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
||||
export const THEME_DIR = path.resolve(SCRIPT_DIR, "themes");
|
||||
const FALLBACK_THEMES: ThemeName[] = ["default", "grace", "simple"];
|
||||
const THEMES_EXTENDING_DEFAULT = new Set<ThemeName>(["grace", "simple"]);
|
||||
|
||||
function stripOutputScope(cssContent: string): string {
|
||||
let css = cssContent;
|
||||
@@ -41,6 +42,7 @@ export function loadThemeCss(theme: ThemeName): {
|
||||
themeCss: string;
|
||||
} {
|
||||
const basePath = path.join(THEME_DIR, "base.css");
|
||||
const defaultThemePath = path.join(THEME_DIR, "default.css");
|
||||
const themePath = path.join(THEME_DIR, `${theme}.css`);
|
||||
|
||||
if (!fs.existsSync(basePath)) {
|
||||
@@ -51,9 +53,18 @@ export function loadThemeCss(theme: ThemeName): {
|
||||
throw new Error(`Missing theme CSS for "${theme}": ${themePath}`);
|
||||
}
|
||||
|
||||
const layeredThemeCss: string[] = [];
|
||||
if (theme !== "default" && THEMES_EXTENDING_DEFAULT.has(theme)) {
|
||||
if (!fs.existsSync(defaultThemePath)) {
|
||||
throw new Error(`Missing default theme CSS: ${defaultThemePath}`);
|
||||
}
|
||||
layeredThemeCss.push(fs.readFileSync(defaultThemePath, "utf-8"));
|
||||
}
|
||||
layeredThemeCss.push(fs.readFileSync(themePath, "utf-8"));
|
||||
|
||||
return {
|
||||
baseCss: fs.readFileSync(basePath, "utf-8"),
|
||||
themeCss: fs.readFileSync(themePath, "utf-8"),
|
||||
themeCss: layeredThemeCss.join("\n"),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -7,18 +7,18 @@ import test from "node:test";
|
||||
import {
|
||||
listReleaseFiles,
|
||||
validateSelfContainedRelease,
|
||||
} from "../scripts/lib/release-files.mjs";
|
||||
} from "./release-files.mjs";
|
||||
|
||||
async function makeTempDir(prefix) {
|
||||
async function makeTempDir(prefix: string): Promise<string> {
|
||||
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
async function writeFile(filePath, contents = "") {
|
||||
async function writeFile(filePath: string, contents = ""): Promise<void> {
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
await fs.writeFile(filePath, contents);
|
||||
}
|
||||
|
||||
async function writeJson(filePath, value) {
|
||||
async function writeJson(filePath: string, value: unknown): Promise<void> {
|
||||
await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`);
|
||||
}
|
||||
|
||||
@@ -4,18 +4,18 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
import { syncSharedSkillPackages } from "../scripts/lib/shared-skill-packages.mjs";
|
||||
import { syncSharedSkillPackages } from "./shared-skill-packages.mjs";
|
||||
|
||||
async function makeTempDir(prefix) {
|
||||
async function makeTempDir(prefix: string): Promise<string> {
|
||||
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
async function writeFile(filePath, contents = "") {
|
||||
async function writeFile(filePath: string, contents = ""): Promise<void> {
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
await fs.writeFile(filePath, contents);
|
||||
}
|
||||
|
||||
async function writeJson(filePath, value) {
|
||||
async function writeJson(filePath: string, value: unknown): Promise<void> {
|
||||
await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`);
|
||||
}
|
||||
|
||||
@@ -53,13 +53,13 @@ test("syncSharedSkillPackages vendors workspace packages into skill scripts", as
|
||||
|
||||
const updatedPackageJson = JSON.parse(
|
||||
await fs.readFile(path.join(consumerDir, "package.json"), "utf8"),
|
||||
);
|
||||
) as { dependencies: Record<string, string> };
|
||||
assert.equal(updatedPackageJson.dependencies["baoyu-md"], "file:./vendor/baoyu-md");
|
||||
assert.equal(updatedPackageJson.dependencies.kleur, "^4.1.5");
|
||||
|
||||
const vendoredPackageJson = JSON.parse(
|
||||
await fs.readFile(path.join(consumerDir, "vendor", "baoyu-md", "package.json"), "utf8"),
|
||||
);
|
||||
) as { name: string };
|
||||
assert.equal(vendoredPackageJson.name, "baoyu-md");
|
||||
|
||||
const vendoredFile = await fs.readFile(
|
||||
+24
-11
@@ -151,6 +151,9 @@ async function main() {
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
let succeeded = 0;
|
||||
const failed = [];
|
||||
|
||||
for (const candidate of actionable) {
|
||||
const version =
|
||||
candidate.status === "new"
|
||||
@@ -158,20 +161,30 @@ async function main() {
|
||||
: bumpSemver(candidate.latestVersion, options.bump);
|
||||
|
||||
console.log(`Publishing ${candidate.slug}@${version}`);
|
||||
const files = await listTextFiles(candidate.folder);
|
||||
await publishSkill({
|
||||
registry,
|
||||
token: config.token,
|
||||
skill: candidate,
|
||||
files,
|
||||
version,
|
||||
changelog: options.changelog,
|
||||
tags,
|
||||
});
|
||||
try {
|
||||
const files = await listTextFiles(candidate.folder);
|
||||
await publishSkill({
|
||||
registry,
|
||||
token: config.token,
|
||||
skill: candidate,
|
||||
files,
|
||||
version,
|
||||
changelog: options.changelog,
|
||||
tags,
|
||||
});
|
||||
succeeded++;
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error(`SKIPPED ${candidate.slug}: ${msg}`);
|
||||
failed.push(candidate.slug);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("");
|
||||
console.log(`Uploaded ${actionable.length} skill(s).`);
|
||||
console.log(`Uploaded ${succeeded}/${actionable.length} skill(s).`);
|
||||
if (failed.length > 0) {
|
||||
console.log(`Failed (${failed.length}): ${failed.join(", ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
|
||||
@@ -118,7 +118,7 @@ Full template: [references/workflow.md](references/workflow.md#step-4-generate-o
|
||||
|
||||
⛔ **BLOCKING: Prompt files MUST be saved before ANY image generation.**
|
||||
|
||||
**Execution strategy**: When multiple illustrations have saved prompt files and the task is now plain generation, prefer `baoyu-image-gen` batch mode (`build-batch.ts` → `--batchfile`) over spawning subagents. Use subagents only when each image still needs separate prompt iteration or creative exploration.
|
||||
**Execution strategy**: When multiple illustrations have saved prompt files and the task is now plain generation, prefer `baoyu-imagine` batch mode (`build-batch.ts` → `--batchfile`) over spawning subagents. Use subagents only when each image still needs separate prompt iteration or creative exploration.
|
||||
|
||||
1. For each illustration, create a prompt file per [references/prompt-construction.md](references/prompt-construction.md)
|
||||
2. Save to `prompts/NN-{type}-{slug}.md` with YAML frontmatter
|
||||
@@ -133,7 +133,7 @@ Full procedures: [references/workflow.md](references/workflow.md#step-5-generate
|
||||
|
||||
### Step 6: Finalize
|
||||
|
||||
Insert `` after paragraphs.
|
||||
Insert `` after paragraphs. Path computed relative to article file based on output directory setting.
|
||||
|
||||
```
|
||||
Article Illustration Complete!
|
||||
@@ -143,15 +143,27 @@ Images: X/N generated
|
||||
|
||||
## Output Directory
|
||||
|
||||
Output directory is determined by `default_output_dir` in EXTEND.md (set during first-time setup):
|
||||
|
||||
| `default_output_dir` | Output Path | Markdown Insert Path |
|
||||
|----------------------|-------------|----------------------|
|
||||
| `imgs-subdir` (default) | `{article-dir}/imgs/` | `imgs/NN-{type}-{slug}.png` |
|
||||
| `same-dir` | `{article-dir}/` | `NN-{type}-{slug}.png` |
|
||||
| `illustrations-subdir` | `{article-dir}/illustrations/` | `illustrations/NN-{type}-{slug}.png` |
|
||||
| `independent` | `illustrations/{topic-slug}/` | `illustrations/{topic-slug}/NN-{type}-{slug}.png` (relative to cwd) |
|
||||
|
||||
All auxiliary files (outline, prompts) are saved inside the output directory:
|
||||
|
||||
```
|
||||
illustrations/{topic-slug}/
|
||||
├── source-{slug}.{ext}
|
||||
├── references/ # if provided
|
||||
{output-dir}/
|
||||
├── outline.md
|
||||
├── prompts/
|
||||
│ └── NN-{type}-{slug}.md
|
||||
└── NN-{type}-{slug}.png
|
||||
```
|
||||
|
||||
When input is **pasted content** (no file path), always uses `illustrations/{topic-slug}/` with `source-{slug}.{ext}` saved alongside.
|
||||
|
||||
**Slug**: 2-4 words, kebab-case. **Conflict**: append `-YYYYMMDD-HHMMSS`.
|
||||
|
||||
## Modification
|
||||
|
||||
@@ -69,7 +69,23 @@ options:
|
||||
description: "Friendly, approachable, personal"
|
||||
```
|
||||
|
||||
### Question 3: Save Location
|
||||
### Question 3: Output Directory
|
||||
|
||||
```
|
||||
header: "Output Directory"
|
||||
question: "Where to save generated illustrations when illustrating a file?"
|
||||
options:
|
||||
- label: "imgs-subdir (Recommended)"
|
||||
description: "{article-dir}/imgs/ — images in a subdirectory next to the article"
|
||||
- label: "same-dir"
|
||||
description: "{article-dir}/ — images alongside the article file"
|
||||
- label: "illustrations-subdir"
|
||||
description: "{article-dir}/illustrations/ — separate illustrations subdirectory"
|
||||
- label: "independent"
|
||||
description: "illustrations/{topic-slug}/ — standalone directory in cwd"
|
||||
```
|
||||
|
||||
### Question 4: Save Location
|
||||
|
||||
```
|
||||
header: "Save"
|
||||
@@ -108,6 +124,7 @@ watermark:
|
||||
preferred_style:
|
||||
name: [selected style or null]
|
||||
description: ""
|
||||
default_output_dir: imgs-subdir # same-dir | imgs-subdir | illustrations-subdir | independent
|
||||
language: null
|
||||
custom_styles: []
|
||||
---
|
||||
|
||||
@@ -280,5 +280,5 @@ TEXTURE: Halftone transitions between sides
|
||||
If watermark enabled in preferences, append:
|
||||
|
||||
```
|
||||
Include a subtle watermark "[content]" positioned at [position] with approximately [opacity*100]% visibility.
|
||||
Include a subtle watermark "[content]" positioned at [position].
|
||||
```
|
||||
|
||||
@@ -55,7 +55,7 @@ Reference Style Extracted (no file):
|
||||
|
||||
| Input | Output Directory | Next |
|
||||
|-------|------------------|------|
|
||||
| File path | Ask user (1.2) | → 1.2 |
|
||||
| File path | EXTEND.md `default_output_dir` (default: `imgs-subdir`). If not configured, confirm in 1.2. | → 1.2 |
|
||||
| Pasted content | `illustrations/{topic-slug}/` | → 1.4 |
|
||||
|
||||
**Backup rule for pasted content**: If `source.md` exists in target directory, rename to `source-backup-YYYYMMDD-HHMMSS.md` before saving.
|
||||
@@ -68,7 +68,7 @@ Check preferences and existing state, then ask ALL needed questions in ONE AskUs
|
||||
|
||||
| Question | When to Ask | Options |
|
||||
|----------|-------------|---------|
|
||||
| Output directory | No `default_output_dir` in EXTEND.md | `{article-dir}/`, `{article-dir}/imgs/` (Recommended), `{article-dir}/illustrations/`, `illustrations/{topic-slug}/` |
|
||||
| Output directory | No `default_output_dir` in EXTEND.md | `{article-dir}/imgs/` (Recommended), `{article-dir}/`, `{article-dir}/illustrations/`, `illustrations/{topic-slug}/` |
|
||||
| Existing images | Target dir has `.png/.jpg/.webp` files | `supplement`, `overwrite`, `regenerate` |
|
||||
| Article update | Always (file path input) | `update`, `copy` |
|
||||
|
||||
@@ -237,7 +237,7 @@ Reference Images:
|
||||
|
||||
## Step 4: Generate Outline
|
||||
|
||||
Save as `outline.md`:
|
||||
Save as `{output-dir}/outline.md` (all paths below are relative to the output directory determined in Step 1.1/1.2):
|
||||
|
||||
```yaml
|
||||
---
|
||||
@@ -285,7 +285,7 @@ references: # Only if references provided
|
||||
|
||||
For each illustration in the outline:
|
||||
|
||||
1. **Create prompt file**: `prompts/NN-{type}-{slug}.md`
|
||||
1. **Create prompt file**: `{output-dir}/prompts/NN-{type}-{slug}.md`
|
||||
2. **Include YAML frontmatter**:
|
||||
```yaml
|
||||
---
|
||||
@@ -316,7 +316,7 @@ Prompt Files:
|
||||
**DO NOT** pass ad-hoc inline text to `--prompt` without first saving prompt files. The generation command should either use `--promptfiles prompts/NN-{type}-{slug}.md` or read the saved file content for `--prompt`.
|
||||
|
||||
**Execution choice**:
|
||||
- If multiple illustrations already have saved prompt files and the task is now plain generation, prefer `baoyu-image-gen` batch mode (`build-batch.ts` -> `main.ts --batchfile`)
|
||||
- If multiple illustrations already have saved prompt files and the task is now plain generation, prefer `baoyu-imagine` batch mode (`build-batch.ts` -> `main.ts --batchfile`)
|
||||
- Use subagents only when each illustration still needs separate prompt rewriting, style exploration, or other per-image reasoning before generation
|
||||
|
||||
**CRITICAL - References in Frontmatter**:
|
||||
@@ -352,7 +352,7 @@ Check available skills. If multiple, ask user.
|
||||
|
||||
| Skill Supports `--ref` | Action |
|
||||
|------------------------|--------|
|
||||
| Yes (e.g., baoyu-image-gen with Google) | Pass reference images via `--ref` |
|
||||
| Yes (e.g., baoyu-imagine with Google) | Pass reference images via `--ref` |
|
||||
| No | Convert to text description, append to prompt |
|
||||
|
||||
**Verification**: Before generating, confirm reference processing:
|
||||
@@ -381,10 +381,14 @@ Add: `Include a subtle watermark "[content]" at [position].`
|
||||
|
||||
### 6.1 Update Article
|
||||
|
||||
Insert after corresponding paragraph:
|
||||
```markdown
|
||||

|
||||
```
|
||||
Insert after corresponding paragraph, using path relative to article file:
|
||||
|
||||
| `default_output_dir` | Insert Path |
|
||||
|----------------------|-------------|
|
||||
| `imgs-subdir` | `` |
|
||||
| `same-dir` | `` |
|
||||
| `illustrations-subdir` | `` |
|
||||
| `independent` | `` (relative to cwd) |
|
||||
|
||||
Alt text: concise description in article's language.
|
||||
|
||||
|
||||
@@ -29,8 +29,8 @@ Options:
|
||||
--prompts <path> Path to prompts directory
|
||||
--output <path> Path to output batch.json
|
||||
--images-dir <path> Directory for generated images
|
||||
--provider <name> Provider for baoyu-image-gen batch tasks (default: replicate)
|
||||
--model <id> Model for baoyu-image-gen batch tasks (default: google/nano-banana-pro)
|
||||
--provider <name> Provider for baoyu-imagine batch tasks (default: replicate)
|
||||
--model <id> Model for baoyu-imagine batch tasks (default: google/nano-banana-pro)
|
||||
--ar <ratio> Aspect ratio for all tasks (default: 16:9)
|
||||
--quality <level> Quality for all tasks (default: 2k)
|
||||
--jobs <count> Recommended worker count metadata (optional)
|
||||
|
||||
@@ -216,7 +216,7 @@ Analyze → [Check Existing?] → [Confirm: Style + Reviews] → Storyboard →
|
||||
|
||||
**7.1 Generate character sheet first**:
|
||||
- **Backup rule**: If `characters/characters.png` exists, rename to `characters/characters-backup-YYYYMMDD-HHMMSS.png`
|
||||
- Invoke an installed image generation skill such as `baoyu-image-gen`
|
||||
- Invoke an installed image generation skill such as `baoyu-imagine`
|
||||
- Read that skill's `SKILL.md` and follow its documented interface rather than calling its scripts directly
|
||||
- Use `characters/characters.md` as the prompt-file input
|
||||
- Save output to `characters/characters.png`
|
||||
|
||||
@@ -278,7 +278,7 @@ Create storyboard and character definitions using the confirmed style from Step
|
||||
| Role | Character | Visual Description |
|
||||
|------|-----------|-------------------|
|
||||
| Student | 大雄 (Nobita) | Japanese boy, 10yo, round glasses, black hair parted in middle, yellow shirt, navy shorts |
|
||||
| Mentor | 哆啦A梦 (Doraemon) | Round blue robot cat, big white eyes, red nose, whiskers, white belly with 4D pocket, golden bell, no ears |
|
||||
| Mentor | 哆啦 A 梦 (Doraemon) | Round blue robot cat, big white eyes, red nose, whiskers, white belly with 4D pocket, golden bell, no ears |
|
||||
| Challenge | 胖虎 (Gian) | Stocky boy, rough features, small eyes, orange shirt |
|
||||
| Support | 静香 (Shizuka) | Cute girl, black short hair, pink dress, gentle expression |
|
||||
|
||||
@@ -359,8 +359,7 @@ Art: [art style] | Tone: [tone] | Layout: [layout type]
|
||||
**Watermark Application** (if enabled in preferences):
|
||||
Add to each prompt:
|
||||
```
|
||||
Include a subtle watermark "[content]" positioned at [position]
|
||||
with approximately [opacity*100]% visibility. The watermark should
|
||||
Include a subtle watermark "[content]" positioned at [position]. The watermark should
|
||||
be legible but not distracting from the comic panels and storytelling.
|
||||
Ensure watermark does not overlap speech bubbles or key action.
|
||||
```
|
||||
@@ -434,7 +433,7 @@ With confirmed prompts from Step 5/6:
|
||||
| Supports `--ref` | **Strategy A** | Pass `characters/characters.png` with EVERY page |
|
||||
| Does NOT support `--ref` | **Strategy B** | Prepend character descriptions to EVERY prompt |
|
||||
|
||||
**Strategy A: Using `--ref` parameter** (e.g., baoyu-image-gen)
|
||||
**Strategy A: Using `--ref` parameter** (e.g., baoyu-imagine)
|
||||
|
||||
- Read the chosen image generation skill's `SKILL.md`
|
||||
- Invoke that installed skill via its documented interface, not by calling its scripts directly
|
||||
@@ -452,8 +451,8 @@ When skill does NOT support reference images, create combined prompt files:
|
||||
|
||||
## Character Reference (maintain consistency)
|
||||
[Copy relevant sections from characters/characters.md here]
|
||||
- 大雄: Japanese boy, round glasses, yellow shirt, navy shorts...
|
||||
- 哆啦A梦: Round blue robot cat, white belly, red nose, golden bell...
|
||||
- 大雄:Japanese boy, round glasses, yellow shirt, navy shorts...
|
||||
- 哆啦 A 梦:Round blue robot cat, white belly, red nose, golden bell...
|
||||
|
||||
## Page Content
|
||||
[Original page prompt here]
|
||||
|
||||
@@ -162,9 +162,20 @@ if (Test-Path "$HOME/.baoyu-skills/baoyu-cover-image/EXTEND.md") { "user" }
|
||||
5. **Detect language**: Compare source, user input, EXTEND.md preference
|
||||
6. **Determine output directory**: Per File Structure rules
|
||||
|
||||
**⚠️ People in Reference Images:**
|
||||
|
||||
If reference images contain **people** who should appear in the cover:
|
||||
|
||||
- **Model supports `--ref`** (default): Copy image to `refs/`, pass via `--ref` at generation. No description file needed — the model sees the face directly.
|
||||
- **Model does NOT support `--ref`** (Jimeng, Seedream 3.0): Create `refs/ref-NN-{slug}.md` with per-character description (hair, glasses, skin tone, clothing). Embed as MUST/REQUIRED instructions in prompt text.
|
||||
|
||||
See [reference-images.md](references/workflow/reference-images.md) for full decision table.
|
||||
|
||||
### Step 2: Confirm Options ⚠️
|
||||
|
||||
Full confirmation flow: [references/workflow/confirm-options.md](references/workflow/confirm-options.md)
|
||||
**MUST use `AskUserQuestion` tool** to present options as interactive selection — NOT plain text tables. Present up to 4 questions in a single `AskUserQuestion` call (Type, Palette, Rendering, Font + Settings). Each question shows the recommended option first with reason, followed by alternatives.
|
||||
|
||||
Full confirmation flow and question format: [references/workflow/confirm-options.md](references/workflow/confirm-options.md)
|
||||
|
||||
| Condition | Skipped | Still Asked |
|
||||
|-----------|---------|-------------|
|
||||
|
||||
@@ -83,12 +83,17 @@ Full library: [references/visual-elements.md](visual-elements.md)
|
||||
|
||||
### Character Handling
|
||||
|
||||
When people are needed:
|
||||
**Default (no reference with people)**:
|
||||
- Use simplified silhouettes or abstract stick figures
|
||||
- Symbolic representations (head + shoulders outline)
|
||||
- NO realistic faces, detailed anatomy, or photographic representations
|
||||
- Cartoon/icon style consistent with rendering choice
|
||||
|
||||
**When reference images contain people**:
|
||||
- Reference image is passed to model (`usage: direct`) — model must visually reference it to preserve character likeness
|
||||
- Stylize to match chosen rendering (cartoon/vector), preserving distinctive features (hair, clothing, pose)
|
||||
- NEVER photorealistic
|
||||
|
||||
## Mood Application
|
||||
|
||||
Apply mood adjustments to the base palette:
|
||||
|
||||
@@ -201,6 +201,12 @@ CRITICAL: The generated cover MUST visually reference the provided images. The c
|
||||
- [Typography]: [Specific treatment, e.g., "Uppercase text with wide letter-spacing"]
|
||||
- [Layout element]: [Specific spatial element, e.g., "Bottom banner strip in dark color"]
|
||||
|
||||
## From Ref 1 ([filename]) — Characters (if people present):
|
||||
- **Character 1**: [Appearance, e.g., "Woman, long wavy blonde hair"] → MUST stylize: [e.g., "flat-vector, simplified face, keep blonde hair, label: 'Nicole Forsgren'"]
|
||||
- **Character 2**: [Appearance, e.g., "Man, short dark hair, stubble"] → MUST stylize: [e.g., "flat-vector, simplified face, keep dark hair, label: 'Gergely Orosz'"]
|
||||
- **Placement**: [e.g., "Right third, side by side, facing left toward main visual"]
|
||||
- **Style**: Match rendering style, NOT photorealistic
|
||||
|
||||
## From Ref 2 ([filename]) — REQUIRED elements:
|
||||
[Same detailed breakdown]
|
||||
|
||||
|
||||
@@ -16,23 +16,30 @@ Guide for processing user-provided reference images in cover generation.
|
||||
|
||||
**If user provides file path**:
|
||||
1. Copy to `refs/ref-NN-{slug}.{ext}` (NN = 01, 02, ...)
|
||||
2. Create description: `refs/ref-NN-{slug}.md`
|
||||
3. Verify files exist before proceeding
|
||||
2. **Only** create description file `refs/ref-NN-{slug}.md` when model does NOT support `--ref` (see below)
|
||||
3. Verify image file exists before proceeding
|
||||
|
||||
**Description File Format**:
|
||||
**When to create description file**:
|
||||
|
||||
| Situation | Action |
|
||||
|-----------|--------|
|
||||
| Model supports `--ref` (Google, OpenAI, OpenRouter, Replicate, Seedream 4.0+) | Copy image only. **No description file needed.** Pass via `--ref` at generation. |
|
||||
| Model does NOT support `--ref` (Jimeng, Seedream 3.0) | Copy image + create description file. Embed description in prompt text. |
|
||||
|
||||
**Description File Format** (only when needed):
|
||||
```yaml
|
||||
---
|
||||
ref_id: NN
|
||||
filename: ref-NN-{slug}.{ext}
|
||||
usage: direct | style | palette
|
||||
---
|
||||
[User's description or auto-generated description]
|
||||
[Character or style description to embed in prompt]
|
||||
```
|
||||
|
||||
| Usage | When to Use |
|
||||
|-------|-------------|
|
||||
| `direct` | Reference matches desired output closely |
|
||||
| `style` | Extract visual style characteristics only |
|
||||
| `direct` | Model sees reference image directly; required if people must appear in output |
|
||||
| `style` | Extract visual style only (not for people who must appear) |
|
||||
| `palette` | Extract color scheme only |
|
||||
|
||||
## Verbal Extraction (No File)
|
||||
@@ -59,6 +66,19 @@ References are high-priority inputs. Extract **specific, concrete, reproducible*
|
||||
|
||||
**Output format**: List each element as bullet that can be copy-pasted into prompt as mandatory instruction.
|
||||
|
||||
### Character Analysis ⚠️ If Reference Contains People
|
||||
|
||||
Use `usage: direct` so model sees the reference image. Additionally describe per character: **appearance**, **pose**, **clothing** → with **transformation rules** (stylize to match rendering).
|
||||
|
||||
| Extract | Good | Bad |
|
||||
|---------|------|-----|
|
||||
| Appearance | "Woman: long wavy blonde hair, friendly smile" | "A woman" |
|
||||
| Pose | "Standing, facing camera, confident posture" | "Standing" |
|
||||
| Clothing | "Dark T-shirt, business casual" | "Formal" |
|
||||
| Transform | "Flat-vector cartoon, keep hair color & clothing" | "Make cartoon" |
|
||||
|
||||
Use `usage: direct`. Output each character as MUST/REQUIRED prompt instruction.
|
||||
|
||||
## Verification Output
|
||||
|
||||
**For saved files**:
|
||||
|
||||
@@ -139,6 +139,10 @@ ${BUN_X} {baseDir}/scripts/main.ts "Hello" --json
|
||||
|
||||
First run opens browser for Google auth. Cookies cached automatically.
|
||||
|
||||
When no explicit profile dir is set, cookie refresh may reuse an already-running local Chrome/Chromium debugging session tied to a standard user-data dir.
|
||||
Set `--profile-dir` or `GEMINI_WEB_CHROME_PROFILE_DIR` to force a dedicated profile and skip existing-session reuse.
|
||||
This is a best-effort CDP session reuse path, not the Chrome DevTools MCP prompt-based `--autoConnect` flow described in Chrome's official docs.
|
||||
|
||||
Supported browsers (auto-detected): Chrome, Chrome Canary/Beta, Chromium, Edge.
|
||||
|
||||
Force refresh: `--login` flag. Override browser: `GEMINI_WEB_CHROME_PATH` env var.
|
||||
|
||||
@@ -2,6 +2,7 @@ import process from 'node:process';
|
||||
|
||||
import {
|
||||
CdpConnection,
|
||||
discoverRunningChromeDebugPort,
|
||||
findChromeExecutable as findChromeExecutableBase,
|
||||
findExistingChromeDebugPort,
|
||||
getFreePort,
|
||||
@@ -97,6 +98,87 @@ async function is_gemini_session_ready(cookies: CookieMap, verbose: boolean): Pr
|
||||
}
|
||||
}
|
||||
|
||||
async function fetch_cookies_from_existing_chrome(
|
||||
timeoutMs: number,
|
||||
verbose: boolean,
|
||||
): Promise<CookieMap | null> {
|
||||
const discovered = await discoverRunningChromeDebugPort();
|
||||
if (discovered === null) return null;
|
||||
|
||||
if (verbose) logger.info(`Found reusable Chrome debugging session on port ${discovered.port}. Connecting via WebSocket...`);
|
||||
|
||||
let cdp: CdpConnection | null = null;
|
||||
let targetId: string | null = null;
|
||||
let createdTarget = false;
|
||||
try {
|
||||
const connectStart = Date.now();
|
||||
const connectTimeout = 30_000;
|
||||
let lastConnErr: unknown = null;
|
||||
while (Date.now() - connectStart < connectTimeout) {
|
||||
try {
|
||||
cdp = await CdpConnection.connect(discovered.wsUrl, 5_000);
|
||||
break;
|
||||
} catch (e) {
|
||||
lastConnErr = e;
|
||||
if (verbose) logger.debug(`WebSocket connect attempt failed: ${e instanceof Error ? e.message : String(e)}, retrying...`);
|
||||
await sleep(1000);
|
||||
}
|
||||
}
|
||||
if (!cdp) {
|
||||
if (verbose) logger.debug(`Could not connect to Chrome after ${connectTimeout / 1000}s: ${lastConnErr instanceof Error ? lastConnErr.message : String(lastConnErr)}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const page = await openPageSession({
|
||||
cdp,
|
||||
reusing: false,
|
||||
url: GEMINI_APP_URL,
|
||||
matchTarget: (target) => target.type === 'page' && target.url.includes('gemini.google.com'),
|
||||
enableNetwork: true,
|
||||
activateTarget: false,
|
||||
});
|
||||
const { sessionId } = page;
|
||||
targetId = page.targetId;
|
||||
createdTarget = page.createdTarget;
|
||||
|
||||
if (verbose) logger.debug(createdTarget ? 'No Gemini tab found, creating new tab...' : 'Found existing Gemini tab, attaching...');
|
||||
|
||||
const start = Date.now();
|
||||
let last: CookieMap = {};
|
||||
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
const { cookies } = await cdp.send<{ cookies: Array<{ name: string; value: string }> }>(
|
||||
'Network.getCookies',
|
||||
{ urls: ['https://gemini.google.com/', 'https://accounts.google.com/', 'https://www.google.com/'] },
|
||||
{ sessionId, timeoutMs: 10_000 },
|
||||
);
|
||||
|
||||
const cookieMap: CookieMap = {};
|
||||
for (const cookie of cookies) {
|
||||
if (cookie?.name && typeof cookie.value === 'string') cookieMap[cookie.name] = cookie.value;
|
||||
}
|
||||
|
||||
last = cookieMap;
|
||||
if (await is_gemini_session_ready(cookieMap, verbose)) return cookieMap;
|
||||
|
||||
await sleep(1000);
|
||||
}
|
||||
|
||||
if (verbose) logger.debug(`Existing Chrome did not yield valid cookies. Last keys: ${Object.keys(last).join(', ')}`);
|
||||
return null;
|
||||
} catch (e) {
|
||||
if (verbose) logger.debug(`Failed to connect to existing Chrome debugging session: ${e instanceof Error ? e.message : String(e)}`);
|
||||
return null;
|
||||
} finally {
|
||||
if (cdp) {
|
||||
if (createdTarget && targetId) {
|
||||
try { await cdp.send('Target.closeTarget', { targetId }, { timeoutMs: 5_000 }); } catch {}
|
||||
}
|
||||
cdp.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function fetch_google_cookies_via_cdp(
|
||||
profileDir: string,
|
||||
timeoutMs: number,
|
||||
@@ -178,6 +260,19 @@ export async function load_browser_cookies(domain_name: string = '', verbose: bo
|
||||
if (cached) return { chrome: cached };
|
||||
}
|
||||
|
||||
const hasExplicitProfile = !!(process.env.GEMINI_WEB_CHROME_PROFILE_DIR?.trim() || process.env.BAOYU_CHROME_PROFILE_DIR?.trim());
|
||||
const existingCookies = hasExplicitProfile ? null : await fetch_cookies_from_existing_chrome(30_000, verbose);
|
||||
if (existingCookies) {
|
||||
const filtered: CookieMap = {};
|
||||
for (const [key, value] of Object.entries(existingCookies)) {
|
||||
if (typeof value === 'string' && value.length > 0) filtered[key] = value;
|
||||
}
|
||||
|
||||
await write_cookie_file(filtered, resolveGeminiWebCookiePath(), 'cdp-existing');
|
||||
void domain_name;
|
||||
return { chrome: filtered };
|
||||
}
|
||||
|
||||
const profileDir = process.env.GEMINI_WEB_CHROME_PROFILE_DIR?.trim() || resolveGeminiWebChromeProfileDir();
|
||||
const cookies = await fetch_google_cookies_via_cdp(profileDir, 120_000, verbose);
|
||||
|
||||
|
||||
@@ -101,7 +101,12 @@ Options:
|
||||
-h, --help Show help
|
||||
|
||||
Env overrides:
|
||||
GEMINI_WEB_DATA_DIR, GEMINI_WEB_COOKIE_PATH, GEMINI_WEB_CHROME_PROFILE_DIR, GEMINI_WEB_CHROME_PATH`);
|
||||
GEMINI_WEB_DATA_DIR, GEMINI_WEB_COOKIE_PATH, GEMINI_WEB_CHROME_PROFILE_DIR, GEMINI_WEB_CHROME_PATH
|
||||
|
||||
Notes:
|
||||
By default cookie refresh may reuse an already-running local Chrome/Chromium debugging session.
|
||||
Set --profile-dir or GEMINI_WEB_CHROME_PROFILE_DIR to force a dedicated profile and skip existing-session reuse.
|
||||
This reuse path is separate from Chrome DevTools MCP's prompt-based --autoConnect flow.`);
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): CliArgs {
|
||||
|
||||
+307
@@ -0,0 +1,307 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import http from "node:http";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import test, { type TestContext } from "node:test";
|
||||
|
||||
import {
|
||||
discoverRunningChromeDebugPort,
|
||||
findChromeExecutable,
|
||||
findExistingChromeDebugPort,
|
||||
getFreePort,
|
||||
openPageSession,
|
||||
resolveSharedChromeProfileDir,
|
||||
waitForChromeDebugPort,
|
||||
} from "./index.ts";
|
||||
|
||||
function useEnv(
|
||||
t: TestContext,
|
||||
values: Record<string, string | null>,
|
||||
): void {
|
||||
const previous = new Map<string, string | undefined>();
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
previous.set(key, process.env[key]);
|
||||
if (value == null) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
t.after(() => {
|
||||
for (const [key, value] of previous.entries()) {
|
||||
if (value == null) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function makeTempDir(prefix: string): Promise<string> {
|
||||
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
async function startDebugServer(port: number): Promise<http.Server> {
|
||||
const server = http.createServer((req, res) => {
|
||||
if (req.url === "/json/version") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({
|
||||
webSocketDebuggerUrl: `ws://127.0.0.1:${port}/devtools/browser/demo`,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(port, "127.0.0.1", () => resolve());
|
||||
});
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
async function closeServer(server: http.Server): Promise<void> {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error) reject(error);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function shellPathForPlatform(): string | null {
|
||||
if (process.platform === "win32") return null;
|
||||
return "/bin/bash";
|
||||
}
|
||||
|
||||
async function startFakeChromiumProcess(port: number): Promise<ChildProcess | null> {
|
||||
const shell = shellPathForPlatform();
|
||||
if (!shell) return null;
|
||||
|
||||
const child = spawn(
|
||||
shell,
|
||||
[
|
||||
"-lc",
|
||||
`exec -a chromium-mock ${JSON.stringify(process.execPath)} -e 'setInterval(() => {}, 1000)' -- --remote-debugging-port=${port}`,
|
||||
],
|
||||
{ stdio: "ignore" },
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
return child;
|
||||
}
|
||||
|
||||
async function stopProcess(child: ChildProcess | null): Promise<void> {
|
||||
if (!child) return;
|
||||
if (child.exitCode !== null || child.signalCode !== null) return;
|
||||
|
||||
child.kill("SIGTERM");
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
|
||||
if (child.exitCode !== null || child.signalCode !== null) return;
|
||||
await new Promise((resolve) => child.once("exit", resolve));
|
||||
}
|
||||
|
||||
test("getFreePort honors a fixed environment override and otherwise allocates a TCP port", async (t) => {
|
||||
useEnv(t, { TEST_FIXED_PORT: "45678" });
|
||||
assert.equal(await getFreePort("TEST_FIXED_PORT"), 45678);
|
||||
|
||||
const dynamicPort = await getFreePort();
|
||||
assert.ok(Number.isInteger(dynamicPort));
|
||||
assert.ok(dynamicPort > 0);
|
||||
});
|
||||
|
||||
test("findChromeExecutable prefers env overrides and falls back to candidate paths", async (t) => {
|
||||
const root = await makeTempDir("baoyu-chrome-bin-");
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
|
||||
const envChrome = path.join(root, "env-chrome");
|
||||
const fallbackChrome = path.join(root, "fallback-chrome");
|
||||
await fs.writeFile(envChrome, "");
|
||||
await fs.writeFile(fallbackChrome, "");
|
||||
|
||||
useEnv(t, { BAOYU_CHROME_PATH: envChrome });
|
||||
assert.equal(
|
||||
findChromeExecutable({
|
||||
envNames: ["BAOYU_CHROME_PATH"],
|
||||
candidates: { default: [fallbackChrome] },
|
||||
}),
|
||||
envChrome,
|
||||
);
|
||||
|
||||
useEnv(t, { BAOYU_CHROME_PATH: null });
|
||||
assert.equal(
|
||||
findChromeExecutable({
|
||||
envNames: ["BAOYU_CHROME_PATH"],
|
||||
candidates: { default: [fallbackChrome] },
|
||||
}),
|
||||
fallbackChrome,
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveSharedChromeProfileDir supports env overrides, WSL paths, and default suffixes", (t) => {
|
||||
useEnv(t, { BAOYU_SHARED_PROFILE: "/tmp/custom-profile" });
|
||||
assert.equal(
|
||||
resolveSharedChromeProfileDir({
|
||||
envNames: ["BAOYU_SHARED_PROFILE"],
|
||||
appDataDirName: "demo-app",
|
||||
profileDirName: "demo-profile",
|
||||
}),
|
||||
path.resolve("/tmp/custom-profile"),
|
||||
);
|
||||
|
||||
useEnv(t, { BAOYU_SHARED_PROFILE: null });
|
||||
assert.equal(
|
||||
resolveSharedChromeProfileDir({
|
||||
wslWindowsHome: "/mnt/c/Users/demo",
|
||||
appDataDirName: "demo-app",
|
||||
profileDirName: "demo-profile",
|
||||
}),
|
||||
path.join("/mnt/c/Users/demo", ".local", "share", "demo-app", "demo-profile"),
|
||||
);
|
||||
|
||||
const fallback = resolveSharedChromeProfileDir({
|
||||
appDataDirName: "demo-app",
|
||||
profileDirName: "demo-profile",
|
||||
});
|
||||
assert.match(fallback, /demo-app[\\/]demo-profile$/);
|
||||
});
|
||||
|
||||
test("findExistingChromeDebugPort reads DevToolsActivePort and validates it against a live endpoint", async (t) => {
|
||||
const root = await makeTempDir("baoyu-cdp-profile-");
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
|
||||
const port = await getFreePort();
|
||||
const server = await startDebugServer(port);
|
||||
t.after(() => closeServer(server));
|
||||
|
||||
await fs.writeFile(path.join(root, "DevToolsActivePort"), `${port}\n/devtools/browser/demo\n`);
|
||||
|
||||
const found = await findExistingChromeDebugPort({ profileDir: root, timeoutMs: 1000 });
|
||||
assert.equal(found, port);
|
||||
});
|
||||
|
||||
test("discoverRunningChromeDebugPort reads DevToolsActivePort from the provided user-data dir", async (t) => {
|
||||
const root = await makeTempDir("baoyu-cdp-user-data-");
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
|
||||
const port = await getFreePort();
|
||||
const server = await startDebugServer(port);
|
||||
t.after(() => closeServer(server));
|
||||
|
||||
await fs.writeFile(path.join(root, "DevToolsActivePort"), `${port}\n/devtools/browser/demo\n`);
|
||||
|
||||
const found = await discoverRunningChromeDebugPort({
|
||||
userDataDirs: [root],
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
assert.deepEqual(found, {
|
||||
port,
|
||||
wsUrl: `ws://127.0.0.1:${port}/devtools/browser/demo`,
|
||||
});
|
||||
});
|
||||
|
||||
test("discoverRunningChromeDebugPort ignores unrelated debugging processes", async (t) => {
|
||||
if (process.platform === "win32") {
|
||||
t.skip("Process discovery fallback is not used on Windows.");
|
||||
return;
|
||||
}
|
||||
|
||||
const root = await makeTempDir("baoyu-cdp-user-data-");
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
|
||||
const port = await getFreePort();
|
||||
const server = await startDebugServer(port);
|
||||
t.after(() => closeServer(server));
|
||||
|
||||
const fakeChromium = await startFakeChromiumProcess(port);
|
||||
t.after(async () => { await stopProcess(fakeChromium); });
|
||||
|
||||
const found = await discoverRunningChromeDebugPort({
|
||||
userDataDirs: [root],
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
assert.equal(found, null);
|
||||
});
|
||||
|
||||
test("openPageSession reports whether it created a new target", async () => {
|
||||
const calls: string[] = [];
|
||||
const cdpExisting = {
|
||||
send: async <T>(method: string): Promise<T> => {
|
||||
calls.push(method);
|
||||
if (method === "Target.getTargets") {
|
||||
return {
|
||||
targetInfos: [{ targetId: "existing-target", type: "page", url: "https://gemini.google.com/app" }],
|
||||
} as T;
|
||||
}
|
||||
if (method === "Target.attachToTarget") return { sessionId: "session-existing" } as T;
|
||||
throw new Error(`Unexpected method: ${method}`);
|
||||
},
|
||||
};
|
||||
|
||||
const existing = await openPageSession({
|
||||
cdp: cdpExisting as never,
|
||||
reusing: false,
|
||||
url: "https://gemini.google.com/app",
|
||||
matchTarget: (target) => target.url.includes("gemini.google.com"),
|
||||
activateTarget: false,
|
||||
});
|
||||
|
||||
assert.deepEqual(existing, {
|
||||
sessionId: "session-existing",
|
||||
targetId: "existing-target",
|
||||
createdTarget: false,
|
||||
});
|
||||
assert.deepEqual(calls, ["Target.getTargets", "Target.attachToTarget"]);
|
||||
|
||||
const createCalls: string[] = [];
|
||||
const cdpCreated = {
|
||||
send: async <T>(method: string): Promise<T> => {
|
||||
createCalls.push(method);
|
||||
if (method === "Target.getTargets") return { targetInfos: [] } as T;
|
||||
if (method === "Target.createTarget") return { targetId: "created-target" } as T;
|
||||
if (method === "Target.attachToTarget") return { sessionId: "session-created" } as T;
|
||||
throw new Error(`Unexpected method: ${method}`);
|
||||
},
|
||||
};
|
||||
|
||||
const created = await openPageSession({
|
||||
cdp: cdpCreated as never,
|
||||
reusing: false,
|
||||
url: "https://gemini.google.com/app",
|
||||
matchTarget: (target) => target.url.includes("gemini.google.com"),
|
||||
activateTarget: false,
|
||||
});
|
||||
|
||||
assert.deepEqual(created, {
|
||||
sessionId: "session-created",
|
||||
targetId: "created-target",
|
||||
createdTarget: true,
|
||||
});
|
||||
assert.deepEqual(createCalls, ["Target.getTargets", "Target.createTarget", "Target.attachToTarget"]);
|
||||
});
|
||||
|
||||
test("waitForChromeDebugPort retries until the debug endpoint becomes available", async (t) => {
|
||||
const port = await getFreePort();
|
||||
|
||||
const serverPromise = (async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
const server = await startDebugServer(port);
|
||||
t.after(() => closeServer(server));
|
||||
})();
|
||||
|
||||
const websocketUrl = await waitForChromeDebugPort(port, 4000, {
|
||||
includeLastError: true,
|
||||
});
|
||||
await serverPromise;
|
||||
|
||||
assert.equal(websocketUrl, `ws://127.0.0.1:${port}/devtools/browser/demo`);
|
||||
});
|
||||
+123
-8
@@ -43,6 +43,19 @@ type FindExistingChromeDebugPortOptions = {
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
export type ChromeChannel = "stable" | "beta" | "canary" | "dev";
|
||||
|
||||
export type DiscoveredChrome = {
|
||||
port: number;
|
||||
wsUrl: string;
|
||||
};
|
||||
|
||||
type DiscoverRunningChromeOptions = {
|
||||
channels?: ChromeChannel[];
|
||||
userDataDirs?: string[];
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
type LaunchChromeOptions = {
|
||||
chromePath: string;
|
||||
profileDir: string;
|
||||
@@ -73,6 +86,7 @@ type OpenPageSessionOptions = {
|
||||
export type PageSession = {
|
||||
sessionId: string;
|
||||
targetId: string;
|
||||
createdTarget: boolean;
|
||||
};
|
||||
|
||||
export function sleep(ms: number): Promise<void> {
|
||||
@@ -173,16 +187,32 @@ async function isDebugPortReady(port: number, timeoutMs = 3_000): Promise<boolea
|
||||
}
|
||||
}
|
||||
|
||||
function isPortListening(port: number, timeoutMs = 3_000): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const socket = new net.Socket();
|
||||
const timer = setTimeout(() => { socket.destroy(); resolve(false); }, timeoutMs);
|
||||
socket.once("connect", () => { clearTimeout(timer); socket.destroy(); resolve(true); });
|
||||
socket.once("error", () => { clearTimeout(timer); resolve(false); });
|
||||
socket.connect(port, "127.0.0.1");
|
||||
});
|
||||
}
|
||||
|
||||
function parseDevToolsActivePort(filePath: string): { port: number; wsPath: string } | null {
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, "utf-8");
|
||||
const lines = content.split(/\r?\n/);
|
||||
const port = Number.parseInt(lines[0]?.trim() ?? "", 10);
|
||||
const wsPath = lines[1]?.trim();
|
||||
if (port > 0 && wsPath) return { port, wsPath };
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function findExistingChromeDebugPort(options: FindExistingChromeDebugPortOptions): Promise<number | null> {
|
||||
const timeoutMs = options.timeoutMs ?? 3_000;
|
||||
const portFile = path.join(options.profileDir, "DevToolsActivePort");
|
||||
const parsed = parseDevToolsActivePort(path.join(options.profileDir, "DevToolsActivePort"));
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(portFile, "utf-8");
|
||||
const [portLine] = content.split(/\r?\n/);
|
||||
const port = Number.parseInt(portLine?.trim() ?? "", 10);
|
||||
if (port > 0 && await isDebugPortReady(port, timeoutMs)) return port;
|
||||
} catch {}
|
||||
if (parsed && parsed.port > 0 && await isDebugPortReady(parsed.port, timeoutMs)) return parsed.port;
|
||||
|
||||
if (process.platform === "win32") return null;
|
||||
|
||||
@@ -204,6 +234,88 @@ export async function findExistingChromeDebugPort(options: FindExistingChromeDeb
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getDefaultChromeUserDataDirs(channels: ChromeChannel[] = ["stable"]): string[] {
|
||||
const home = os.homedir();
|
||||
const dirs: string[] = [];
|
||||
|
||||
const channelDirs: Record<string, { darwin: string; linux: string; win32: string }> = {
|
||||
stable: {
|
||||
darwin: path.join(home, "Library", "Application Support", "Google", "Chrome"),
|
||||
linux: path.join(home, ".config", "google-chrome"),
|
||||
win32: path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Google", "Chrome", "User Data"),
|
||||
},
|
||||
beta: {
|
||||
darwin: path.join(home, "Library", "Application Support", "Google", "Chrome Beta"),
|
||||
linux: path.join(home, ".config", "google-chrome-beta"),
|
||||
win32: path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Google", "Chrome Beta", "User Data"),
|
||||
},
|
||||
canary: {
|
||||
darwin: path.join(home, "Library", "Application Support", "Google", "Chrome Canary"),
|
||||
linux: path.join(home, ".config", "google-chrome-canary"),
|
||||
win32: path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Google", "Chrome SxS", "User Data"),
|
||||
},
|
||||
dev: {
|
||||
darwin: path.join(home, "Library", "Application Support", "Google", "Chrome Dev"),
|
||||
linux: path.join(home, ".config", "google-chrome-dev"),
|
||||
win32: path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Google", "Chrome Dev", "User Data"),
|
||||
},
|
||||
};
|
||||
|
||||
const platform = process.platform === "darwin" ? "darwin" : process.platform === "win32" ? "win32" : "linux";
|
||||
|
||||
for (const ch of channels) {
|
||||
const entry = channelDirs[ch];
|
||||
if (entry) dirs.push(entry[platform]);
|
||||
}
|
||||
|
||||
return dirs;
|
||||
}
|
||||
|
||||
// Best-effort reuse of an already-running local CDP session discovered from
|
||||
// known Chrome user-data dirs. This is distinct from Chrome DevTools MCP's
|
||||
// prompt-based --autoConnect flow.
|
||||
export async function discoverRunningChromeDebugPort(options: DiscoverRunningChromeOptions = {}): Promise<DiscoveredChrome | null> {
|
||||
const channels = options.channels ?? ["stable", "beta", "canary", "dev"];
|
||||
const timeoutMs = options.timeoutMs ?? 3_000;
|
||||
|
||||
const userDataDirs = (options.userDataDirs ?? getDefaultChromeUserDataDirs(channels))
|
||||
.map((dir) => path.resolve(dir));
|
||||
for (const dir of userDataDirs) {
|
||||
const parsed = parseDevToolsActivePort(path.join(dir, "DevToolsActivePort"));
|
||||
if (!parsed) continue;
|
||||
if (await isPortListening(parsed.port, timeoutMs)) {
|
||||
return { port: parsed.port, wsUrl: `ws://127.0.0.1:${parsed.port}${parsed.wsPath}` };
|
||||
}
|
||||
}
|
||||
|
||||
if (process.platform !== "win32") {
|
||||
try {
|
||||
const result = spawnSync("ps", ["aux"], { encoding: "utf-8", timeout: 5_000 });
|
||||
if (result.status === 0 && result.stdout) {
|
||||
const lines = result.stdout
|
||||
.split("\n")
|
||||
.filter((line) =>
|
||||
line.includes("--remote-debugging-port=") &&
|
||||
userDataDirs.some((dir) => line.includes(dir))
|
||||
);
|
||||
|
||||
for (const line of lines) {
|
||||
const portMatch = line.match(/--remote-debugging-port=(\d+)/);
|
||||
const port = Number.parseInt(portMatch?.[1] ?? "", 10);
|
||||
if (port > 0 && await isDebugPortReady(port, timeoutMs)) {
|
||||
try {
|
||||
const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(`http://127.0.0.1:${port}/json/version`, { timeoutMs });
|
||||
if (version.webSocketDebuggerUrl) return { port, wsUrl: version.webSocketDebuggerUrl };
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function waitForChromeDebugPort(
|
||||
port: number,
|
||||
timeoutMs: number,
|
||||
@@ -376,10 +488,12 @@ export function killChrome(chrome: ChildProcess): void {
|
||||
|
||||
export async function openPageSession(options: OpenPageSessionOptions): Promise<PageSession> {
|
||||
let targetId: string;
|
||||
let createdTarget = false;
|
||||
|
||||
if (options.reusing) {
|
||||
const created = await options.cdp.send<{ targetId: string }>("Target.createTarget", { url: options.url });
|
||||
targetId = created.targetId;
|
||||
createdTarget = true;
|
||||
} else {
|
||||
const targets = await options.cdp.send<{ targetInfos: ChromeTargetInfo[] }>("Target.getTargets");
|
||||
const existing = targets.targetInfos.find(options.matchTarget);
|
||||
@@ -388,6 +502,7 @@ export async function openPageSession(options: OpenPageSessionOptions): Promise<
|
||||
} else {
|
||||
const created = await options.cdp.send<{ targetId: string }>("Target.createTarget", { url: options.url });
|
||||
targetId = created.targetId;
|
||||
createdTarget = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -404,5 +519,5 @@ export async function openPageSession(options: OpenPageSessionOptions): Promise<
|
||||
if (options.enableDom) await options.cdp.send("DOM.enable", {}, { sessionId });
|
||||
if (options.enableNetwork) await options.cdp.send("Network.enable", {}, { sessionId });
|
||||
|
||||
return { sessionId, targetId };
|
||||
return { sessionId, targetId, createdTarget };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { formatArticleMarkdown } from "./markdown.js";
|
||||
|
||||
test("formatArticleMarkdown renders MARKDOWN entities from atomic blocks", () => {
|
||||
const article = {
|
||||
title: "Atomic Markdown Example",
|
||||
content_state: {
|
||||
blocks: [
|
||||
{
|
||||
type: "unstyled",
|
||||
text: "Before the snippet.",
|
||||
entityRanges: [],
|
||||
},
|
||||
{
|
||||
type: "atomic",
|
||||
text: " ",
|
||||
entityRanges: [{ key: 0, offset: 0, length: 1 }],
|
||||
},
|
||||
{
|
||||
type: "unstyled",
|
||||
text: "After the snippet.",
|
||||
entityRanges: [],
|
||||
},
|
||||
],
|
||||
entityMap: {
|
||||
"0": {
|
||||
key: "5",
|
||||
value: {
|
||||
type: "MARKDOWN",
|
||||
mutability: "Mutable",
|
||||
data: {
|
||||
markdown: "```python\nprint('hello from x article')\n```\n",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const { markdown } = formatArticleMarkdown(article);
|
||||
|
||||
assert.ok(markdown.includes("Before the snippet."));
|
||||
assert.ok(markdown.includes("```python\nprint('hello from x article')\n```"));
|
||||
assert.ok(markdown.includes("After the snippet."));
|
||||
assert.strictEqual(markdown, `# Atomic Markdown Example
|
||||
|
||||
Before the snippet.
|
||||
|
||||
\`\`\`python
|
||||
print('hello from x article')
|
||||
\`\`\`
|
||||
|
||||
After the snippet.`);
|
||||
});
|
||||
|
||||
test("formatArticleMarkdown renders article video media as poster plus video link", () => {
|
||||
const posterUrl = "https://pbs.twimg.com/amplify_video_thumb/123/img/poster.jpg";
|
||||
const videoUrl = "https://video.twimg.com/amplify_video/123/vid/avc1/720x720/demo.mp4?tag=21";
|
||||
const article = {
|
||||
title: "Video Example",
|
||||
content_state: {
|
||||
blocks: [
|
||||
{
|
||||
type: "unstyled",
|
||||
text: "Intro text.",
|
||||
entityRanges: [],
|
||||
},
|
||||
{
|
||||
type: "atomic",
|
||||
text: " ",
|
||||
entityRanges: [{ key: 0, offset: 0, length: 1 }],
|
||||
},
|
||||
],
|
||||
entityMap: {
|
||||
"0": {
|
||||
key: "0",
|
||||
value: {
|
||||
type: "MEDIA",
|
||||
mutability: "Immutable",
|
||||
data: {
|
||||
caption: "Demo reel",
|
||||
mediaItems: [{ mediaId: "vid-1" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
media_entities: [
|
||||
{
|
||||
media_id: "vid-1",
|
||||
media_info: {
|
||||
__typename: "ApiVideo",
|
||||
preview_image: {
|
||||
original_img_url: posterUrl,
|
||||
},
|
||||
variants: [
|
||||
{
|
||||
content_type: "video/mp4",
|
||||
bit_rate: 256000,
|
||||
url: videoUrl,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const { markdown } = formatArticleMarkdown(article);
|
||||
|
||||
assert.ok(markdown.includes("Intro text."));
|
||||
assert.ok(markdown.includes(``));
|
||||
assert.ok(markdown.includes(`[video](${videoUrl})`));
|
||||
assert.ok(!markdown.includes(``));
|
||||
assert.ok(!markdown.includes("## Media"));
|
||||
});
|
||||
|
||||
test("formatArticleMarkdown renders unused article videos in trailing media section", () => {
|
||||
const posterUrl = "https://pbs.twimg.com/amplify_video_thumb/456/img/poster.jpg";
|
||||
const videoUrl = "https://video.twimg.com/amplify_video/456/vid/avc1/1080x1080/demo.mp4?tag=21";
|
||||
const article = {
|
||||
title: "Trailing Media Example",
|
||||
plain_text: "Body text.",
|
||||
media_entities: [
|
||||
{
|
||||
media_id: "vid-2",
|
||||
media_info: {
|
||||
__typename: "ApiVideo",
|
||||
preview_image: {
|
||||
original_img_url: posterUrl,
|
||||
},
|
||||
variants: [
|
||||
{
|
||||
content_type: "video/mp4",
|
||||
bit_rate: 832000,
|
||||
url: videoUrl,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const { markdown, coverUrl } = formatArticleMarkdown(article);
|
||||
|
||||
assert.strictEqual(coverUrl, null);
|
||||
assert.ok(markdown.includes("## Media"));
|
||||
assert.ok(markdown.includes(``));
|
||||
assert.ok(markdown.includes(`[video](${videoUrl})`));
|
||||
});
|
||||
|
||||
test("formatArticleMarkdown keeps coverUrl as preview image for video cover media", () => {
|
||||
const posterUrl = "https://pbs.twimg.com/amplify_video_thumb/789/img/poster.jpg";
|
||||
const videoUrl = "https://video.twimg.com/amplify_video/789/vid/avc1/720x720/demo.mp4?tag=21";
|
||||
const article = {
|
||||
title: "Video Cover Example",
|
||||
plain_text: "Body text.",
|
||||
cover_media: {
|
||||
media_info: {
|
||||
__typename: "ApiVideo",
|
||||
preview_image: {
|
||||
original_img_url: posterUrl,
|
||||
},
|
||||
variants: [
|
||||
{
|
||||
content_type: "video/mp4",
|
||||
bit_rate: 1280000,
|
||||
url: videoUrl,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const { coverUrl } = formatArticleMarkdown(article);
|
||||
|
||||
assert.strictEqual(coverUrl, posterUrl);
|
||||
});
|
||||
@@ -18,6 +18,17 @@ export type FormatArticleOptions = {
|
||||
referencedTweets?: Map<string, ReferencedTweetInfo>;
|
||||
};
|
||||
|
||||
type ResolvedMediaAsset =
|
||||
| {
|
||||
kind: "image";
|
||||
url: string;
|
||||
}
|
||||
| {
|
||||
kind: "video";
|
||||
url: string;
|
||||
posterUrl?: string;
|
||||
};
|
||||
|
||||
function coerceArticleEntity(value: unknown): ArticleEntity | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const candidate = value as ArticleEntity;
|
||||
@@ -109,58 +120,127 @@ function resolveEntityEntry(
|
||||
return entityMap[String(entityKey)];
|
||||
}
|
||||
|
||||
function resolveMediaUrl(info?: ArticleMediaInfo): string | undefined {
|
||||
function resolveVideoUrl(info?: ArticleMediaInfo): string | undefined {
|
||||
if (!info) return undefined;
|
||||
if (info.original_img_url) return info.original_img_url;
|
||||
if (info.preview_image?.original_img_url) return info.preview_image.original_img_url;
|
||||
const variants = info.variants ?? [];
|
||||
const mp4 = variants
|
||||
.filter((variant) => variant?.content_type?.includes("video"))
|
||||
.sort((a, b) => (b.bit_rate ?? 0) - (a.bit_rate ?? 0))[0];
|
||||
return mp4?.url ?? variants[0]?.url;
|
||||
return mp4?.url ?? variants.find((variant) => typeof variant?.url === "string")?.url;
|
||||
}
|
||||
|
||||
function buildMediaById(article: ArticleEntity): Map<string, string> {
|
||||
const map = new Map<string, string>();
|
||||
function resolveMediaAsset(info?: ArticleMediaInfo): ResolvedMediaAsset | undefined {
|
||||
if (!info) return undefined;
|
||||
|
||||
const posterUrl = info.preview_image?.original_img_url ?? info.original_img_url;
|
||||
const videoUrl = resolveVideoUrl(info);
|
||||
if (videoUrl) {
|
||||
return {
|
||||
kind: "video",
|
||||
url: videoUrl,
|
||||
posterUrl,
|
||||
};
|
||||
}
|
||||
|
||||
const imageUrl = info.original_img_url ?? info.preview_image?.original_img_url;
|
||||
if (imageUrl) {
|
||||
return {
|
||||
kind: "image",
|
||||
url: imageUrl,
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolveFallbackMediaAsset(rawUrl?: string): ResolvedMediaAsset | undefined {
|
||||
if (!rawUrl) return undefined;
|
||||
|
||||
if (/^https:\/\/video\.twimg\.com\//i.test(rawUrl) || /\.(mp4|m4v|mov|webm)(?:$|[?#])/i.test(rawUrl)) {
|
||||
return {
|
||||
kind: "video",
|
||||
url: rawUrl,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "image",
|
||||
url: rawUrl,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveCoverUrl(info?: ArticleMediaInfo): string | undefined {
|
||||
if (!info) return undefined;
|
||||
return info.original_img_url ?? info.preview_image?.original_img_url;
|
||||
}
|
||||
|
||||
function buildMediaIdentity(asset: ResolvedMediaAsset): string {
|
||||
return asset.kind === "video"
|
||||
? `video:${asset.url}:${asset.posterUrl ?? ""}`
|
||||
: `image:${asset.url}`;
|
||||
}
|
||||
|
||||
function renderMediaLines(
|
||||
asset: ResolvedMediaAsset,
|
||||
altText: string,
|
||||
usedUrls: Set<string>
|
||||
): string[] {
|
||||
if (asset.kind === "video") {
|
||||
const lines: string[] = [];
|
||||
if (asset.posterUrl && !usedUrls.has(asset.posterUrl)) {
|
||||
usedUrls.add(asset.posterUrl);
|
||||
lines.push(``);
|
||||
}
|
||||
if (!usedUrls.has(asset.url)) {
|
||||
usedUrls.add(asset.url);
|
||||
lines.push(`[video](${asset.url})`);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
if (usedUrls.has(asset.url)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
usedUrls.add(asset.url);
|
||||
return [``];
|
||||
}
|
||||
|
||||
function buildMediaById(article: ArticleEntity): Map<string, ResolvedMediaAsset> {
|
||||
const map = new Map<string, ResolvedMediaAsset>();
|
||||
for (const entity of article.media_entities ?? []) {
|
||||
if (!entity?.media_id) continue;
|
||||
const url = resolveMediaUrl(entity.media_info);
|
||||
if (url) {
|
||||
map.set(entity.media_id, url);
|
||||
const asset = resolveMediaAsset(entity.media_info);
|
||||
if (asset) {
|
||||
map.set(entity.media_id, asset);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function collectMediaUrls(
|
||||
article: ArticleEntity,
|
||||
usedUrls: Set<string>,
|
||||
excludeUrl?: string
|
||||
): string[] {
|
||||
const urls: string[] = [];
|
||||
const addUrl = (url?: string) => {
|
||||
if (!url) return;
|
||||
if (excludeUrl && url === excludeUrl) {
|
||||
usedUrls.add(url);
|
||||
return;
|
||||
}
|
||||
if (usedUrls.has(url)) return;
|
||||
usedUrls.add(url);
|
||||
urls.push(url);
|
||||
function collectMediaAssets(article: ArticleEntity): ResolvedMediaAsset[] {
|
||||
const assets: ResolvedMediaAsset[] = [];
|
||||
const seen = new Set<string>();
|
||||
const addAsset = (asset?: ResolvedMediaAsset) => {
|
||||
if (!asset) return;
|
||||
const identity = buildMediaIdentity(asset);
|
||||
if (seen.has(identity)) return;
|
||||
seen.add(identity);
|
||||
assets.push(asset);
|
||||
};
|
||||
|
||||
for (const entity of article.media_entities ?? []) {
|
||||
addUrl(resolveMediaUrl(entity?.media_info));
|
||||
addAsset(resolveMediaAsset(entity?.media_info));
|
||||
}
|
||||
|
||||
return urls;
|
||||
return assets;
|
||||
}
|
||||
|
||||
function resolveEntityMediaLines(
|
||||
entityKey: number | undefined,
|
||||
entityMap: ArticleContentState["entityMap"] | undefined,
|
||||
entityLookup: EntityLookup,
|
||||
mediaById: Map<string, string>,
|
||||
mediaById: Map<string, ResolvedMediaAsset>,
|
||||
usedUrls: Set<string>
|
||||
): string[] {
|
||||
if (entityKey === undefined) return [];
|
||||
@@ -182,17 +262,16 @@ function resolveEntityMediaLines(
|
||||
: typeof item?.media_id === "string"
|
||||
? item.media_id
|
||||
: undefined;
|
||||
const url = mediaId ? mediaById.get(mediaId) : undefined;
|
||||
if (url && !usedUrls.has(url)) {
|
||||
usedUrls.add(url);
|
||||
lines.push(``);
|
||||
const asset = mediaId ? mediaById.get(mediaId) : undefined;
|
||||
if (asset) {
|
||||
lines.push(...renderMediaLines(asset, altText, usedUrls));
|
||||
}
|
||||
}
|
||||
|
||||
const fallbackUrl = typeof value.data?.url === "string" ? value.data.url : undefined;
|
||||
if (fallbackUrl && !usedUrls.has(fallbackUrl)) {
|
||||
usedUrls.add(fallbackUrl);
|
||||
lines.push(``);
|
||||
const fallbackAsset = resolveFallbackMediaAsset(fallbackUrl);
|
||||
if (fallbackAsset) {
|
||||
lines.push(...renderMediaLines(fallbackAsset, altText, usedUrls));
|
||||
}
|
||||
|
||||
return lines;
|
||||
@@ -237,6 +316,22 @@ function resolveEntityTweetLines(
|
||||
return lines;
|
||||
}
|
||||
|
||||
function resolveEntityMarkdownLines(
|
||||
entityKey: number | undefined,
|
||||
entityMap: ArticleContentState["entityMap"] | undefined,
|
||||
entityLookup: EntityLookup
|
||||
): string[] {
|
||||
if (entityKey === undefined) return [];
|
||||
const entry = resolveEntityEntry(entityKey, entityMap, entityLookup);
|
||||
const value = entry?.value;
|
||||
if (!value || value.type !== "MARKDOWN") return [];
|
||||
|
||||
const markdown = typeof value.data?.markdown === "string" ? value.data.markdown : "";
|
||||
const normalized = markdown.replace(/\r\n/g, "\n").trimEnd();
|
||||
if (!normalized) return [];
|
||||
return normalized.split("\n");
|
||||
}
|
||||
|
||||
function buildMediaLinkMap(
|
||||
entityMap: ArticleContentState["entityMap"] | undefined
|
||||
): Map<number, string> {
|
||||
@@ -330,7 +425,7 @@ function renderContentBlocks(
|
||||
blocks: ArticleBlock[],
|
||||
entityMap: ArticleContentState["entityMap"] | undefined,
|
||||
entityLookup: EntityLookup,
|
||||
mediaById: Map<string, string>,
|
||||
mediaById: Map<string, ResolvedMediaAsset>,
|
||||
usedUrls: Set<string>,
|
||||
mediaLinkMap: Map<number, string>,
|
||||
referencedTweets?: Map<string, ReferencedTweetInfo>
|
||||
@@ -397,6 +492,16 @@ function renderContentBlocks(
|
||||
return [...new Set(linkLines)];
|
||||
};
|
||||
|
||||
const collectMarkdownLines = (block: ArticleBlock): string[] => {
|
||||
const ranges = Array.isArray(block.entityRanges) ? block.entityRanges : [];
|
||||
const markdownLines: string[] = [];
|
||||
for (const range of ranges) {
|
||||
if (typeof range?.key !== "number") continue;
|
||||
markdownLines.push(...resolveEntityMarkdownLines(range.key, entityMap, entityLookup));
|
||||
}
|
||||
return markdownLines;
|
||||
};
|
||||
|
||||
const pushTrailingMedia = (mediaLines: string[]) => {
|
||||
if (mediaLines.length > 0) {
|
||||
pushBlock(mediaLines, "media");
|
||||
@@ -441,6 +546,11 @@ function renderContentBlocks(
|
||||
pushBlock(tweetLines, "quote");
|
||||
}
|
||||
|
||||
const markdownLines = collectMarkdownLines(block);
|
||||
if (markdownLines.length > 0) {
|
||||
pushBlock(markdownLines, "text");
|
||||
}
|
||||
|
||||
const mediaLines = collectMediaLines(block);
|
||||
if (mediaLines.length > 0) {
|
||||
pushBlock(mediaLines, "media");
|
||||
@@ -571,7 +681,7 @@ export function formatArticleMarkdown(
|
||||
lines.push(`# ${title}`);
|
||||
}
|
||||
|
||||
const coverUrl = resolveMediaUrl(candidate.cover_media?.media_info) ?? null;
|
||||
const coverUrl = resolveCoverUrl(candidate.cover_media?.media_info) ?? null;
|
||||
if (coverUrl) {
|
||||
usedUrls.add(coverUrl);
|
||||
}
|
||||
@@ -602,12 +712,13 @@ export function formatArticleMarkdown(
|
||||
lines.push(candidate.preview_text.trim());
|
||||
}
|
||||
|
||||
const mediaUrls = collectMediaUrls(candidate, usedUrls, coverUrl ?? undefined);
|
||||
if (mediaUrls.length > 0) {
|
||||
const trailingMediaLines: string[] = [];
|
||||
for (const asset of collectMediaAssets(candidate)) {
|
||||
trailingMediaLines.push(...renderMediaLines(asset, "", usedUrls));
|
||||
}
|
||||
if (trailingMediaLines.length > 0) {
|
||||
lines.push("", "## Media", "");
|
||||
for (const url of mediaUrls) {
|
||||
lines.push(``);
|
||||
}
|
||||
lines.push(...trailingMediaLines);
|
||||
}
|
||||
|
||||
return { markdown: lines.join("\n").trimEnd(), coverUrl };
|
||||
|
||||
@@ -202,6 +202,13 @@ function toHighResUrl(rawUrl: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
function isPlausibleMediaUrl(rawUrl: string): boolean {
|
||||
const ext = resolveExtensionFromUrl(rawUrl);
|
||||
if (ext && (IMAGE_EXTENSIONS.has(ext) || VIDEO_EXTENSIONS.has(ext))) return true;
|
||||
if (resolveKindFromHostname(rawUrl) !== undefined) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function collectMarkdownLinkCandidates(markdown: string): MarkdownLinkCandidate[] {
|
||||
const candidates: MarkdownLinkCandidate[] = [];
|
||||
const seen = new Set<string>();
|
||||
@@ -221,10 +228,12 @@ function collectMarkdownLinkCandidates(markdown: string): MarkdownLinkCandidate[
|
||||
const label = match[1] ?? "";
|
||||
const rawUrl = match[3] ?? "";
|
||||
if (!rawUrl || seen.has(rawUrl)) continue;
|
||||
const isImage = label.startsWith("![");
|
||||
if (!isImage && !isPlausibleMediaUrl(rawUrl)) continue;
|
||||
seen.add(rawUrl);
|
||||
candidates.push({
|
||||
url: rawUrl,
|
||||
hint: label.startsWith("![") ? "image" : "unknown",
|
||||
hint: isImage ? "image" : "unknown",
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ export type ArticleEntityMapEntry = {
|
||||
mutability?: string;
|
||||
data?: {
|
||||
caption?: string;
|
||||
markdown?: string;
|
||||
mediaItems?: ArticleEntityMapMediaItem[];
|
||||
url?: string;
|
||||
tweetId?: string;
|
||||
|
||||
+307
@@ -0,0 +1,307 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import http from "node:http";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import test, { type TestContext } from "node:test";
|
||||
|
||||
import {
|
||||
discoverRunningChromeDebugPort,
|
||||
findChromeExecutable,
|
||||
findExistingChromeDebugPort,
|
||||
getFreePort,
|
||||
openPageSession,
|
||||
resolveSharedChromeProfileDir,
|
||||
waitForChromeDebugPort,
|
||||
} from "./index.ts";
|
||||
|
||||
function useEnv(
|
||||
t: TestContext,
|
||||
values: Record<string, string | null>,
|
||||
): void {
|
||||
const previous = new Map<string, string | undefined>();
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
previous.set(key, process.env[key]);
|
||||
if (value == null) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
t.after(() => {
|
||||
for (const [key, value] of previous.entries()) {
|
||||
if (value == null) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function makeTempDir(prefix: string): Promise<string> {
|
||||
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
async function startDebugServer(port: number): Promise<http.Server> {
|
||||
const server = http.createServer((req, res) => {
|
||||
if (req.url === "/json/version") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({
|
||||
webSocketDebuggerUrl: `ws://127.0.0.1:${port}/devtools/browser/demo`,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(port, "127.0.0.1", () => resolve());
|
||||
});
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
async function closeServer(server: http.Server): Promise<void> {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error) reject(error);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function shellPathForPlatform(): string | null {
|
||||
if (process.platform === "win32") return null;
|
||||
return "/bin/bash";
|
||||
}
|
||||
|
||||
async function startFakeChromiumProcess(port: number): Promise<ChildProcess | null> {
|
||||
const shell = shellPathForPlatform();
|
||||
if (!shell) return null;
|
||||
|
||||
const child = spawn(
|
||||
shell,
|
||||
[
|
||||
"-lc",
|
||||
`exec -a chromium-mock ${JSON.stringify(process.execPath)} -e 'setInterval(() => {}, 1000)' -- --remote-debugging-port=${port}`,
|
||||
],
|
||||
{ stdio: "ignore" },
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
return child;
|
||||
}
|
||||
|
||||
async function stopProcess(child: ChildProcess | null): Promise<void> {
|
||||
if (!child) return;
|
||||
if (child.exitCode !== null || child.signalCode !== null) return;
|
||||
|
||||
child.kill("SIGTERM");
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
|
||||
if (child.exitCode !== null || child.signalCode !== null) return;
|
||||
await new Promise((resolve) => child.once("exit", resolve));
|
||||
}
|
||||
|
||||
test("getFreePort honors a fixed environment override and otherwise allocates a TCP port", async (t) => {
|
||||
useEnv(t, { TEST_FIXED_PORT: "45678" });
|
||||
assert.equal(await getFreePort("TEST_FIXED_PORT"), 45678);
|
||||
|
||||
const dynamicPort = await getFreePort();
|
||||
assert.ok(Number.isInteger(dynamicPort));
|
||||
assert.ok(dynamicPort > 0);
|
||||
});
|
||||
|
||||
test("findChromeExecutable prefers env overrides and falls back to candidate paths", async (t) => {
|
||||
const root = await makeTempDir("baoyu-chrome-bin-");
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
|
||||
const envChrome = path.join(root, "env-chrome");
|
||||
const fallbackChrome = path.join(root, "fallback-chrome");
|
||||
await fs.writeFile(envChrome, "");
|
||||
await fs.writeFile(fallbackChrome, "");
|
||||
|
||||
useEnv(t, { BAOYU_CHROME_PATH: envChrome });
|
||||
assert.equal(
|
||||
findChromeExecutable({
|
||||
envNames: ["BAOYU_CHROME_PATH"],
|
||||
candidates: { default: [fallbackChrome] },
|
||||
}),
|
||||
envChrome,
|
||||
);
|
||||
|
||||
useEnv(t, { BAOYU_CHROME_PATH: null });
|
||||
assert.equal(
|
||||
findChromeExecutable({
|
||||
envNames: ["BAOYU_CHROME_PATH"],
|
||||
candidates: { default: [fallbackChrome] },
|
||||
}),
|
||||
fallbackChrome,
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveSharedChromeProfileDir supports env overrides, WSL paths, and default suffixes", (t) => {
|
||||
useEnv(t, { BAOYU_SHARED_PROFILE: "/tmp/custom-profile" });
|
||||
assert.equal(
|
||||
resolveSharedChromeProfileDir({
|
||||
envNames: ["BAOYU_SHARED_PROFILE"],
|
||||
appDataDirName: "demo-app",
|
||||
profileDirName: "demo-profile",
|
||||
}),
|
||||
path.resolve("/tmp/custom-profile"),
|
||||
);
|
||||
|
||||
useEnv(t, { BAOYU_SHARED_PROFILE: null });
|
||||
assert.equal(
|
||||
resolveSharedChromeProfileDir({
|
||||
wslWindowsHome: "/mnt/c/Users/demo",
|
||||
appDataDirName: "demo-app",
|
||||
profileDirName: "demo-profile",
|
||||
}),
|
||||
path.join("/mnt/c/Users/demo", ".local", "share", "demo-app", "demo-profile"),
|
||||
);
|
||||
|
||||
const fallback = resolveSharedChromeProfileDir({
|
||||
appDataDirName: "demo-app",
|
||||
profileDirName: "demo-profile",
|
||||
});
|
||||
assert.match(fallback, /demo-app[\\/]demo-profile$/);
|
||||
});
|
||||
|
||||
test("findExistingChromeDebugPort reads DevToolsActivePort and validates it against a live endpoint", async (t) => {
|
||||
const root = await makeTempDir("baoyu-cdp-profile-");
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
|
||||
const port = await getFreePort();
|
||||
const server = await startDebugServer(port);
|
||||
t.after(() => closeServer(server));
|
||||
|
||||
await fs.writeFile(path.join(root, "DevToolsActivePort"), `${port}\n/devtools/browser/demo\n`);
|
||||
|
||||
const found = await findExistingChromeDebugPort({ profileDir: root, timeoutMs: 1000 });
|
||||
assert.equal(found, port);
|
||||
});
|
||||
|
||||
test("discoverRunningChromeDebugPort reads DevToolsActivePort from the provided user-data dir", async (t) => {
|
||||
const root = await makeTempDir("baoyu-cdp-user-data-");
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
|
||||
const port = await getFreePort();
|
||||
const server = await startDebugServer(port);
|
||||
t.after(() => closeServer(server));
|
||||
|
||||
await fs.writeFile(path.join(root, "DevToolsActivePort"), `${port}\n/devtools/browser/demo\n`);
|
||||
|
||||
const found = await discoverRunningChromeDebugPort({
|
||||
userDataDirs: [root],
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
assert.deepEqual(found, {
|
||||
port,
|
||||
wsUrl: `ws://127.0.0.1:${port}/devtools/browser/demo`,
|
||||
});
|
||||
});
|
||||
|
||||
test("discoverRunningChromeDebugPort ignores unrelated debugging processes", async (t) => {
|
||||
if (process.platform === "win32") {
|
||||
t.skip("Process discovery fallback is not used on Windows.");
|
||||
return;
|
||||
}
|
||||
|
||||
const root = await makeTempDir("baoyu-cdp-user-data-");
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
|
||||
const port = await getFreePort();
|
||||
const server = await startDebugServer(port);
|
||||
t.after(() => closeServer(server));
|
||||
|
||||
const fakeChromium = await startFakeChromiumProcess(port);
|
||||
t.after(async () => { await stopProcess(fakeChromium); });
|
||||
|
||||
const found = await discoverRunningChromeDebugPort({
|
||||
userDataDirs: [root],
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
assert.equal(found, null);
|
||||
});
|
||||
|
||||
test("openPageSession reports whether it created a new target", async () => {
|
||||
const calls: string[] = [];
|
||||
const cdpExisting = {
|
||||
send: async <T>(method: string): Promise<T> => {
|
||||
calls.push(method);
|
||||
if (method === "Target.getTargets") {
|
||||
return {
|
||||
targetInfos: [{ targetId: "existing-target", type: "page", url: "https://gemini.google.com/app" }],
|
||||
} as T;
|
||||
}
|
||||
if (method === "Target.attachToTarget") return { sessionId: "session-existing" } as T;
|
||||
throw new Error(`Unexpected method: ${method}`);
|
||||
},
|
||||
};
|
||||
|
||||
const existing = await openPageSession({
|
||||
cdp: cdpExisting as never,
|
||||
reusing: false,
|
||||
url: "https://gemini.google.com/app",
|
||||
matchTarget: (target) => target.url.includes("gemini.google.com"),
|
||||
activateTarget: false,
|
||||
});
|
||||
|
||||
assert.deepEqual(existing, {
|
||||
sessionId: "session-existing",
|
||||
targetId: "existing-target",
|
||||
createdTarget: false,
|
||||
});
|
||||
assert.deepEqual(calls, ["Target.getTargets", "Target.attachToTarget"]);
|
||||
|
||||
const createCalls: string[] = [];
|
||||
const cdpCreated = {
|
||||
send: async <T>(method: string): Promise<T> => {
|
||||
createCalls.push(method);
|
||||
if (method === "Target.getTargets") return { targetInfos: [] } as T;
|
||||
if (method === "Target.createTarget") return { targetId: "created-target" } as T;
|
||||
if (method === "Target.attachToTarget") return { sessionId: "session-created" } as T;
|
||||
throw new Error(`Unexpected method: ${method}`);
|
||||
},
|
||||
};
|
||||
|
||||
const created = await openPageSession({
|
||||
cdp: cdpCreated as never,
|
||||
reusing: false,
|
||||
url: "https://gemini.google.com/app",
|
||||
matchTarget: (target) => target.url.includes("gemini.google.com"),
|
||||
activateTarget: false,
|
||||
});
|
||||
|
||||
assert.deepEqual(created, {
|
||||
sessionId: "session-created",
|
||||
targetId: "created-target",
|
||||
createdTarget: true,
|
||||
});
|
||||
assert.deepEqual(createCalls, ["Target.getTargets", "Target.createTarget", "Target.attachToTarget"]);
|
||||
});
|
||||
|
||||
test("waitForChromeDebugPort retries until the debug endpoint becomes available", async (t) => {
|
||||
const port = await getFreePort();
|
||||
|
||||
const serverPromise = (async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
const server = await startDebugServer(port);
|
||||
t.after(() => closeServer(server));
|
||||
})();
|
||||
|
||||
const websocketUrl = await waitForChromeDebugPort(port, 4000, {
|
||||
includeLastError: true,
|
||||
});
|
||||
await serverPromise;
|
||||
|
||||
assert.equal(websocketUrl, `ws://127.0.0.1:${port}/devtools/browser/demo`);
|
||||
});
|
||||
+123
-8
@@ -43,6 +43,19 @@ type FindExistingChromeDebugPortOptions = {
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
export type ChromeChannel = "stable" | "beta" | "canary" | "dev";
|
||||
|
||||
export type DiscoveredChrome = {
|
||||
port: number;
|
||||
wsUrl: string;
|
||||
};
|
||||
|
||||
type DiscoverRunningChromeOptions = {
|
||||
channels?: ChromeChannel[];
|
||||
userDataDirs?: string[];
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
type LaunchChromeOptions = {
|
||||
chromePath: string;
|
||||
profileDir: string;
|
||||
@@ -73,6 +86,7 @@ type OpenPageSessionOptions = {
|
||||
export type PageSession = {
|
||||
sessionId: string;
|
||||
targetId: string;
|
||||
createdTarget: boolean;
|
||||
};
|
||||
|
||||
export function sleep(ms: number): Promise<void> {
|
||||
@@ -173,16 +187,32 @@ async function isDebugPortReady(port: number, timeoutMs = 3_000): Promise<boolea
|
||||
}
|
||||
}
|
||||
|
||||
function isPortListening(port: number, timeoutMs = 3_000): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const socket = new net.Socket();
|
||||
const timer = setTimeout(() => { socket.destroy(); resolve(false); }, timeoutMs);
|
||||
socket.once("connect", () => { clearTimeout(timer); socket.destroy(); resolve(true); });
|
||||
socket.once("error", () => { clearTimeout(timer); resolve(false); });
|
||||
socket.connect(port, "127.0.0.1");
|
||||
});
|
||||
}
|
||||
|
||||
function parseDevToolsActivePort(filePath: string): { port: number; wsPath: string } | null {
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, "utf-8");
|
||||
const lines = content.split(/\r?\n/);
|
||||
const port = Number.parseInt(lines[0]?.trim() ?? "", 10);
|
||||
const wsPath = lines[1]?.trim();
|
||||
if (port > 0 && wsPath) return { port, wsPath };
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function findExistingChromeDebugPort(options: FindExistingChromeDebugPortOptions): Promise<number | null> {
|
||||
const timeoutMs = options.timeoutMs ?? 3_000;
|
||||
const portFile = path.join(options.profileDir, "DevToolsActivePort");
|
||||
const parsed = parseDevToolsActivePort(path.join(options.profileDir, "DevToolsActivePort"));
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(portFile, "utf-8");
|
||||
const [portLine] = content.split(/\r?\n/);
|
||||
const port = Number.parseInt(portLine?.trim() ?? "", 10);
|
||||
if (port > 0 && await isDebugPortReady(port, timeoutMs)) return port;
|
||||
} catch {}
|
||||
if (parsed && parsed.port > 0 && await isDebugPortReady(parsed.port, timeoutMs)) return parsed.port;
|
||||
|
||||
if (process.platform === "win32") return null;
|
||||
|
||||
@@ -204,6 +234,88 @@ export async function findExistingChromeDebugPort(options: FindExistingChromeDeb
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getDefaultChromeUserDataDirs(channels: ChromeChannel[] = ["stable"]): string[] {
|
||||
const home = os.homedir();
|
||||
const dirs: string[] = [];
|
||||
|
||||
const channelDirs: Record<string, { darwin: string; linux: string; win32: string }> = {
|
||||
stable: {
|
||||
darwin: path.join(home, "Library", "Application Support", "Google", "Chrome"),
|
||||
linux: path.join(home, ".config", "google-chrome"),
|
||||
win32: path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Google", "Chrome", "User Data"),
|
||||
},
|
||||
beta: {
|
||||
darwin: path.join(home, "Library", "Application Support", "Google", "Chrome Beta"),
|
||||
linux: path.join(home, ".config", "google-chrome-beta"),
|
||||
win32: path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Google", "Chrome Beta", "User Data"),
|
||||
},
|
||||
canary: {
|
||||
darwin: path.join(home, "Library", "Application Support", "Google", "Chrome Canary"),
|
||||
linux: path.join(home, ".config", "google-chrome-canary"),
|
||||
win32: path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Google", "Chrome SxS", "User Data"),
|
||||
},
|
||||
dev: {
|
||||
darwin: path.join(home, "Library", "Application Support", "Google", "Chrome Dev"),
|
||||
linux: path.join(home, ".config", "google-chrome-dev"),
|
||||
win32: path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Google", "Chrome Dev", "User Data"),
|
||||
},
|
||||
};
|
||||
|
||||
const platform = process.platform === "darwin" ? "darwin" : process.platform === "win32" ? "win32" : "linux";
|
||||
|
||||
for (const ch of channels) {
|
||||
const entry = channelDirs[ch];
|
||||
if (entry) dirs.push(entry[platform]);
|
||||
}
|
||||
|
||||
return dirs;
|
||||
}
|
||||
|
||||
// Best-effort reuse of an already-running local CDP session discovered from
|
||||
// known Chrome user-data dirs. This is distinct from Chrome DevTools MCP's
|
||||
// prompt-based --autoConnect flow.
|
||||
export async function discoverRunningChromeDebugPort(options: DiscoverRunningChromeOptions = {}): Promise<DiscoveredChrome | null> {
|
||||
const channels = options.channels ?? ["stable", "beta", "canary", "dev"];
|
||||
const timeoutMs = options.timeoutMs ?? 3_000;
|
||||
|
||||
const userDataDirs = (options.userDataDirs ?? getDefaultChromeUserDataDirs(channels))
|
||||
.map((dir) => path.resolve(dir));
|
||||
for (const dir of userDataDirs) {
|
||||
const parsed = parseDevToolsActivePort(path.join(dir, "DevToolsActivePort"));
|
||||
if (!parsed) continue;
|
||||
if (await isPortListening(parsed.port, timeoutMs)) {
|
||||
return { port: parsed.port, wsUrl: `ws://127.0.0.1:${parsed.port}${parsed.wsPath}` };
|
||||
}
|
||||
}
|
||||
|
||||
if (process.platform !== "win32") {
|
||||
try {
|
||||
const result = spawnSync("ps", ["aux"], { encoding: "utf-8", timeout: 5_000 });
|
||||
if (result.status === 0 && result.stdout) {
|
||||
const lines = result.stdout
|
||||
.split("\n")
|
||||
.filter((line) =>
|
||||
line.includes("--remote-debugging-port=") &&
|
||||
userDataDirs.some((dir) => line.includes(dir))
|
||||
);
|
||||
|
||||
for (const line of lines) {
|
||||
const portMatch = line.match(/--remote-debugging-port=(\d+)/);
|
||||
const port = Number.parseInt(portMatch?.[1] ?? "", 10);
|
||||
if (port > 0 && await isDebugPortReady(port, timeoutMs)) {
|
||||
try {
|
||||
const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(`http://127.0.0.1:${port}/json/version`, { timeoutMs });
|
||||
if (version.webSocketDebuggerUrl) return { port, wsUrl: version.webSocketDebuggerUrl };
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function waitForChromeDebugPort(
|
||||
port: number,
|
||||
timeoutMs: number,
|
||||
@@ -376,10 +488,12 @@ export function killChrome(chrome: ChildProcess): void {
|
||||
|
||||
export async function openPageSession(options: OpenPageSessionOptions): Promise<PageSession> {
|
||||
let targetId: string;
|
||||
let createdTarget = false;
|
||||
|
||||
if (options.reusing) {
|
||||
const created = await options.cdp.send<{ targetId: string }>("Target.createTarget", { url: options.url });
|
||||
targetId = created.targetId;
|
||||
createdTarget = true;
|
||||
} else {
|
||||
const targets = await options.cdp.send<{ targetInfos: ChromeTargetInfo[] }>("Target.getTargets");
|
||||
const existing = targets.targetInfos.find(options.matchTarget);
|
||||
@@ -388,6 +502,7 @@ export async function openPageSession(options: OpenPageSessionOptions): Promise<
|
||||
} else {
|
||||
const created = await options.cdp.send<{ targetId: string }>("Target.createTarget", { url: options.url });
|
||||
targetId = created.targetId;
|
||||
createdTarget = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -404,5 +519,5 @@ export async function openPageSession(options: OpenPageSessionOptions): Promise<
|
||||
if (options.enableDom) await options.cdp.send("DOM.enable", {}, { sessionId });
|
||||
if (options.enableNetwork) await options.cdp.send("Network.enable", {}, { sessionId });
|
||||
|
||||
return { sessionId, targetId };
|
||||
return { sessionId, targetId, createdTarget };
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: baoyu-format-markdown
|
||||
description: Formats plain text or markdown files with frontmatter, titles, summaries, headings, bold, lists, and code blocks. Use when user asks to "format markdown", "beautify article", "add formatting", or improve article layout. Outputs to {filename}-formatted.md.
|
||||
version: 1.56.1
|
||||
version: 1.57.0
|
||||
metadata:
|
||||
openclaw:
|
||||
homepage: https://github.com/JimLiu/baoyu-skills#baoyu-format-markdown
|
||||
@@ -88,7 +88,7 @@ Read the user-specified file, then detect content type:
|
||||
| Has `**bold**`, `*italic*`, lists, code blocks, blockquotes | Markdown |
|
||||
| None of above | Plain text |
|
||||
|
||||
**If Markdown detected, ask user:**
|
||||
**If Markdown detected, use `AskUserQuestion` to ask:**
|
||||
|
||||
```
|
||||
Detected existing markdown formatting. What would you like to do?
|
||||
@@ -174,7 +174,8 @@ Check for YAML frontmatter (`---` block). Create if missing.
|
||||
|-------|------------|
|
||||
| `title` | See **Title Generation** below |
|
||||
| `slug` | Infer from file path or generate from title |
|
||||
| `summary` | See **Summary Generation** below |
|
||||
| `summary` | One-sentence concise summary (see **Summary Generation** below) |
|
||||
| `description` | Longer descriptive summary (see **Summary Generation** below) |
|
||||
| `coverImage` | Check if `imgs/cover.png` exists in same directory; if so, use relative path |
|
||||
|
||||
**Title Generation:**
|
||||
@@ -187,73 +188,52 @@ Whether or not a title already exists, always run the title optimization flow (u
|
||||
- Reader pain point or curiosity trigger
|
||||
- Most memorable metaphor or golden quote
|
||||
|
||||
**Generate 3-4 style-differentiated candidates:**
|
||||
**Generate titles** using formulas from `references/title-formulas.md`:
|
||||
|
||||
| Style | Characteristics | Example |
|
||||
|-------|----------------|---------|
|
||||
| Subversive | Deny common practice, create conflict | "All de-AI-flavor prompts are wrong" |
|
||||
| Solution | Give the answer directly, promise value | "One recipe to make AI write in your voice" |
|
||||
| Suspense | Reveal half, spark curiosity | "It took me six months to find how to remove AI flavor" |
|
||||
| Concrete number | Use numbers for credibility | "150 lines of docs taught AI my writing style" |
|
||||
1. Select the **2-3 best-matching hook formulas** based on the article's content, tone, and structure (see "When to pick each formula" in the reference)
|
||||
2. Generate **1-2 straightforward titles** (descriptive or declarative, no formula — clear and accurate)
|
||||
3. If the user specifies a direction (e.g., "make it suspenseful"), prioritize that direction
|
||||
4. Total: **4-5 candidates**
|
||||
|
||||
Present to user:
|
||||
Use `AskUserQuestion` to present candidates:
|
||||
|
||||
```
|
||||
Pick a title:
|
||||
|
||||
1. [Title A] — (recommended)
|
||||
2. [Title B] — [style note]
|
||||
3. [Title C] — [style note]
|
||||
1. [Hook title A] — (recommended) [formula name]
|
||||
2. [Hook title B] — [formula name]
|
||||
3. [Hook title C] — [formula name]
|
||||
4. [Straightforward title D] — straightforward
|
||||
5. [Straightforward title E] — straightforward
|
||||
|
||||
Enter number, or type a custom title:
|
||||
```
|
||||
|
||||
Put the strongest hook first and mark it (recommended).
|
||||
|
||||
**Title principles:**
|
||||
- **Hook in first 5 chars**: create information gap or cognitive conflict
|
||||
- **Specific > abstract**: "150 lines" beats "a document"
|
||||
- **Negation > affirmation**: "you're doing it wrong" beats "the right way"
|
||||
- **Conversational**: like chatting with a friend, not a paper title
|
||||
- **Max ~25 chars**: longer titles get truncated in feeds
|
||||
- **Accurate, not clickbait**: the article must deliver what the title promises
|
||||
|
||||
**Prohibited patterns:**
|
||||
- "浅谈 XX"、"关于 XX 的思考"、"XX 的探索与实践"
|
||||
- "震惊!"、"万字长文"、"建议收藏"
|
||||
- Pure questions without direction: "AI 写作的未来在哪里?"
|
||||
Put the strongest hook first and mark it (recommended). See `references/title-formulas.md` for title principles and prohibited patterns.
|
||||
|
||||
If first line is H1, extract to frontmatter and remove from body. If frontmatter already has `title`, include it as context but still generate fresh candidates.
|
||||
|
||||
**Summary Generation:**
|
||||
|
||||
Generate 3 candidate summaries with different angles. Present to user:
|
||||
Generate two versions directly (no user selection needed), both stored in frontmatter:
|
||||
|
||||
```
|
||||
Pick a summary:
|
||||
| Field | Length | Purpose |
|
||||
|-------|--------|---------|
|
||||
| `summary` | 1 sentence, ~50-80 chars | Concise hook — for feeds, social sharing, SEO meta |
|
||||
| `description` | 2-3 sentences, ~100-200 chars | Richer context — for article previews, newsletter blurbs |
|
||||
|
||||
1. [Summary A] — [focus note]
|
||||
2. [Summary B] — [focus note]
|
||||
3. [Summary C] — [focus note]
|
||||
|
||||
Enter number, or type a custom summary:
|
||||
```
|
||||
|
||||
**Summary principles:**
|
||||
- 80-150 characters, precise and information-rich
|
||||
**Principles:**
|
||||
- Convey **core value** to the reader, not just the topic
|
||||
- Vary angles: problem-driven, result-driven, insight-driven
|
||||
- **Hook the reader**: make them want to read the full article
|
||||
- Use concrete details (numbers, outcomes, specific methods) over vague descriptions
|
||||
- `summary` should be punchy and self-contained; `description` can expand with supporting details
|
||||
- If frontmatter already has `summary` or `description`, keep existing and only generate the missing one
|
||||
|
||||
**Prohibited patterns:**
|
||||
- "本文介绍了..."、"本文探讨了..."
|
||||
- "This article introduces...", "This article explores..."
|
||||
- Pure topic description without value proposition
|
||||
- Repeating the title in different words
|
||||
|
||||
If frontmatter already has `summary`, skip selection and use it.
|
||||
|
||||
**EXTEND.md skip behavior:** If `auto_select: true` is set in EXTEND.md, skip title and summary selection — generate the best candidate directly without asking. User can also set `auto_select_title: true` or `auto_select_summary: true` independently.
|
||||
**EXTEND.md skip behavior:** If `auto_select: true` or `auto_select_title: true` is set in EXTEND.md, skip title selection — generate the best candidate directly without asking.
|
||||
|
||||
Once title is in frontmatter, body should NOT have H1 (avoid duplication).
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
# Title Formulas Reference
|
||||
|
||||
8 hook formulas + straightforward style for balanced title generation.
|
||||
|
||||
## Hook Formulas
|
||||
|
||||
| # | Formula | Characteristics | Example |
|
||||
|---|---------|----------------|---------|
|
||||
| 1 | Subversive | Deny common belief, create cognitive conflict | "All de-AI-flavor prompts are wrong" |
|
||||
| 2 | Solution | Give the answer directly, promise concrete value | "One recipe to make AI write in your voice" |
|
||||
| 3 | Suspense | Reveal half, spark a curiosity gap | "It took me six months to find how to remove AI flavor" |
|
||||
| 4 | Concrete Number | Use specific numbers for credibility and impact | "150 lines of docs taught AI my writing style" |
|
||||
| 5 | Contrast | Small cause → big result, or expectation vs reality | "One doc replaced three months of AI tuning" |
|
||||
| 6 | Result First | Lead with a surprising outcome, hook reader to find out why | "After using this method, nobody could tell it was AI" |
|
||||
| 7 | Rhetorical Question | Ask a question that creates an unfinished feeling | "Why can people spot your AI writing at a glance?" |
|
||||
| 8 | Empathy | Touch pain points, trigger shared frustration or relief | "Three months fighting AI flavor — I finally broke free" |
|
||||
|
||||
### When to pick each formula
|
||||
|
||||
| Formula | Best for |
|
||||
|---------|----------|
|
||||
| Subversive | Articles that challenge mainstream advice or debunk myths |
|
||||
| Solution | How-to guides, tutorials, actionable advice pieces |
|
||||
| Suspense | Personal stories, case studies, journey narratives |
|
||||
| Concrete Number | Data-driven articles, benchmarks, step-by-step guides |
|
||||
| Contrast | Before/after stories, unexpected discoveries, comparisons |
|
||||
| Result First | Success stories, transformation pieces, "I tried X" articles |
|
||||
| Rhetorical Question | Problem-awareness pieces, diagnostic/explainer content |
|
||||
| Empathy | Struggle narratives, community pain points, relatable experiences |
|
||||
|
||||
## Straightforward Style
|
||||
|
||||
Not every title needs a hook. Straightforward titles work well as alternatives:
|
||||
|
||||
- **Descriptive**: clearly state the topic and scope
|
||||
- **Declarative**: state the main conclusion or thesis directly
|
||||
|
||||
These provide balance — readers who prefer clarity over curiosity will appreciate them.
|
||||
|
||||
## Title Principles
|
||||
|
||||
- **Hook in first 5 characters**: create information gap or cognitive conflict
|
||||
- **Specific > abstract**: "150 lines" beats "a document"
|
||||
- **Negation > affirmation**: "you're doing it wrong" beats "the right way"
|
||||
- **Conversational**: like chatting with a friend, not an academic paper
|
||||
- **Max ~30 characters**: longer titles get truncated in feeds
|
||||
- **Accurate, not clickbait**: the article must deliver what the title promises — titles can be bold but the content must back them up
|
||||
|
||||
## Prohibited Patterns
|
||||
|
||||
- Vague academic-style: "On XX", "Thoughts on XX", "Exploration and Practice of XX"
|
||||
- Pure shock bait: "Shocking!", "10,000-word essay", "Must bookmark"
|
||||
- Directionless questions: "Where is the future of AI writing?"
|
||||
@@ -1,165 +0,0 @@
|
||||
import type { CliArgs } from "../types";
|
||||
|
||||
export function getDefaultModel(): string {
|
||||
return process.env.DASHSCOPE_IMAGE_MODEL || "z-image-turbo";
|
||||
}
|
||||
|
||||
function getApiKey(): string | null {
|
||||
return process.env.DASHSCOPE_API_KEY || null;
|
||||
}
|
||||
|
||||
function getBaseUrl(): string {
|
||||
const base = process.env.DASHSCOPE_BASE_URL || "https://dashscope.aliyuncs.com";
|
||||
return base.replace(/\/+$/g, "");
|
||||
}
|
||||
|
||||
export function parseAspectRatio(ar: string): { width: number; height: number } | null {
|
||||
const match = ar.match(/^(\d+(?:\.\d+)?):(\d+(?:\.\d+)?)$/);
|
||||
if (!match) return null;
|
||||
const w = parseFloat(match[1]!);
|
||||
const h = parseFloat(match[2]!);
|
||||
if (w <= 0 || h <= 0) return null;
|
||||
return { width: w, height: h };
|
||||
}
|
||||
|
||||
const STANDARD_SIZES: [number, number][] = [
|
||||
[1024, 1024],
|
||||
[1280, 720],
|
||||
[720, 1280],
|
||||
[1024, 768],
|
||||
[768, 1024],
|
||||
[1536, 1024],
|
||||
[1024, 1536],
|
||||
[1536, 864],
|
||||
[864, 1536],
|
||||
];
|
||||
|
||||
const STANDARD_SIZES_2K: [number, number][] = [
|
||||
[1536, 1536],
|
||||
[2048, 1152],
|
||||
[1152, 2048],
|
||||
[1536, 1024],
|
||||
[1024, 1536],
|
||||
[1536, 864],
|
||||
[864, 1536],
|
||||
[2048, 2048],
|
||||
];
|
||||
|
||||
export function getSizeFromAspectRatio(ar: string | null, quality: CliArgs["quality"]): string {
|
||||
const is2k = quality === "2k";
|
||||
const defaultSize = is2k ? "1536*1536" : "1024*1024";
|
||||
|
||||
if (!ar) return defaultSize;
|
||||
|
||||
const parsed = parseAspectRatio(ar);
|
||||
if (!parsed) return defaultSize;
|
||||
|
||||
const targetRatio = parsed.width / parsed.height;
|
||||
const sizes = is2k ? STANDARD_SIZES_2K : STANDARD_SIZES;
|
||||
|
||||
let best = defaultSize;
|
||||
let bestDiff = Infinity;
|
||||
|
||||
for (const [w, h] of sizes) {
|
||||
const diff = Math.abs(w / h - targetRatio);
|
||||
if (diff < bestDiff) {
|
||||
bestDiff = diff;
|
||||
best = `${w}*${h}`;
|
||||
}
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
export function normalizeSize(size: string): string {
|
||||
return size.replace("x", "*");
|
||||
}
|
||||
|
||||
export async function generateImage(
|
||||
prompt: string,
|
||||
model: string,
|
||||
args: CliArgs
|
||||
): Promise<Uint8Array> {
|
||||
const apiKey = getApiKey();
|
||||
if (!apiKey) throw new Error("DASHSCOPE_API_KEY is required");
|
||||
|
||||
if (args.referenceImages.length > 0) {
|
||||
throw new Error(
|
||||
"Reference images are not supported with DashScope provider in baoyu-image-gen. Use --provider google with a Gemini multimodal model."
|
||||
);
|
||||
}
|
||||
|
||||
const size = args.size ? normalizeSize(args.size) : getSizeFromAspectRatio(args.aspectRatio, args.quality);
|
||||
const url = `${getBaseUrl()}/api/v1/services/aigc/multimodal-generation/generation`;
|
||||
|
||||
const body = {
|
||||
model,
|
||||
input: {
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [{ text: prompt }],
|
||||
},
|
||||
],
|
||||
},
|
||||
parameters: {
|
||||
prompt_extend: false,
|
||||
size,
|
||||
},
|
||||
};
|
||||
|
||||
console.log(`Generating image with DashScope (${model})...`, { size });
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
throw new Error(`DashScope API error (${res.status}): ${err}`);
|
||||
}
|
||||
|
||||
const result = await res.json() as {
|
||||
output?: {
|
||||
result_image?: string;
|
||||
choices?: Array<{
|
||||
message?: {
|
||||
content?: Array<{ image?: string }>;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
let imageData: string | null = null;
|
||||
|
||||
if (result.output?.result_image) {
|
||||
imageData = result.output.result_image;
|
||||
} else if (result.output?.choices?.[0]?.message?.content) {
|
||||
const content = result.output.choices[0].message.content;
|
||||
for (const item of content) {
|
||||
if (item.image) {
|
||||
imageData = item.image;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!imageData) {
|
||||
console.error("Response:", JSON.stringify(result, null, 2));
|
||||
throw new Error("No image in response");
|
||||
}
|
||||
|
||||
if (imageData.startsWith("http://") || imageData.startsWith("https://")) {
|
||||
const imgRes = await fetch(imageData);
|
||||
if (!imgRes.ok) throw new Error("Failed to download image");
|
||||
const buf = await imgRes.arrayBuffer();
|
||||
return new Uint8Array(buf);
|
||||
}
|
||||
|
||||
return Uint8Array.from(Buffer.from(imageData, "base64"));
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
import type { CliArgs } from "../types";
|
||||
|
||||
export function getDefaultModel(): string {
|
||||
return process.env.SEEDREAM_IMAGE_MODEL || "doubao-seedream-5-0-260128";
|
||||
}
|
||||
|
||||
function getApiKey(): string | null {
|
||||
return process.env.ARK_API_KEY || null;
|
||||
}
|
||||
|
||||
function getBaseUrl(): string {
|
||||
return process.env.SEEDREAM_BASE_URL || "https://ark.cn-beijing.volces.com/api/v3";
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert aspect ratio to Seedream size format
|
||||
* Seedream API accepts: "2k" (default), "3k", or WIDTHxHEIGHT format
|
||||
* Note: API uses lowercase "2k"/"3k", not "2K"/"3K"
|
||||
*/
|
||||
function getSeedreamSize(ar: string | null, quality: CliArgs["quality"], imageSize?: string | null): string {
|
||||
// If explicit size is provided
|
||||
if (imageSize) {
|
||||
const upper = imageSize.toUpperCase();
|
||||
if (upper === "2K" || upper === "3K") {
|
||||
return upper.toLowerCase(); // API expects "2k" or "3k"
|
||||
}
|
||||
// For widthxheight format, pass through as-is
|
||||
if (imageSize.includes("x")) {
|
||||
return imageSize;
|
||||
}
|
||||
}
|
||||
|
||||
// Default to 2k (smallest option supported by API)
|
||||
return "2k";
|
||||
}
|
||||
|
||||
type SeedreamImageResponse = {
|
||||
model: string;
|
||||
created: number;
|
||||
data: Array<{
|
||||
url: string;
|
||||
size: string;
|
||||
}>;
|
||||
usage: {
|
||||
generated_images: number;
|
||||
output_tokens: number;
|
||||
total_tokens: number;
|
||||
};
|
||||
};
|
||||
|
||||
export async function generateImage(
|
||||
prompt: string,
|
||||
model: string,
|
||||
args: CliArgs
|
||||
): Promise<Uint8Array> {
|
||||
const apiKey = getApiKey();
|
||||
if (!apiKey) {
|
||||
throw new Error(
|
||||
"ARK_API_KEY is required. " +
|
||||
"Get your API key from https://console.volcengine.com/ark"
|
||||
);
|
||||
}
|
||||
|
||||
const baseUrl = getBaseUrl();
|
||||
const size = getSeedreamSize(args.aspectRatio, args.quality, args.imageSize);
|
||||
|
||||
console.error(`Calling Seedream API (${model}) with size: ${size}`);
|
||||
|
||||
const requestBody = {
|
||||
model,
|
||||
prompt,
|
||||
size,
|
||||
output_format: "png",
|
||||
watermark: false,
|
||||
};
|
||||
|
||||
const response = await fetch(`${baseUrl}/images/generations`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.text();
|
||||
throw new Error(`Seedream API error (${response.status}): ${err}`);
|
||||
}
|
||||
|
||||
const result = (await response.json()) as SeedreamImageResponse;
|
||||
|
||||
if (!result.data || result.data.length === 0) {
|
||||
throw new Error("No image data in Seedream response");
|
||||
}
|
||||
|
||||
const imageUrl = result.data[0].url;
|
||||
if (!imageUrl) {
|
||||
throw new Error("No image URL in Seedream response");
|
||||
}
|
||||
|
||||
// Download image from URL
|
||||
console.error(`Downloading image from ${imageUrl}...`);
|
||||
const imgResponse = await fetch(imageUrl);
|
||||
if (!imgResponse.ok) {
|
||||
throw new Error(`Failed to download image from ${imageUrl}`);
|
||||
}
|
||||
|
||||
const buffer = await imgResponse.arrayBuffer();
|
||||
return new Uint8Array(buffer);
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
---
|
||||
name: baoyu-image-gen
|
||||
description: AI image generation with OpenAI, Google, OpenRouter, DashScope, Jimeng, Seedream and Replicate APIs. Supports text-to-image, reference images, aspect ratios, and batch generation from saved prompt files. Sequential by default; use batch parallel generation when the user already has multiple prompts or wants stable multi-image throughput. Use when user asks to generate, create, or draw images.
|
||||
version: 1.56.2
|
||||
name: baoyu-imagine
|
||||
description: AI image generation with OpenAI, Azure OpenAI, Google, OpenRouter, DashScope, MiniMax, Jimeng, Seedream and Replicate APIs. Supports text-to-image, reference images, aspect ratios, and batch generation from saved prompt files. Sequential by default; use batch parallel generation when the user already has multiple prompts or wants stable multi-image throughput. Use when user asks to generate, create, or draw images.
|
||||
version: 1.56.4
|
||||
metadata:
|
||||
openclaw:
|
||||
homepage: https://github.com/JimLiu/baoyu-skills#baoyu-image-gen
|
||||
homepage: https://github.com/JimLiu/baoyu-skills#baoyu-imagine
|
||||
requires:
|
||||
anyBins:
|
||||
- bun
|
||||
@@ -13,7 +13,7 @@ metadata:
|
||||
|
||||
# Image Generation (AI SDK)
|
||||
|
||||
Official API-based image generation. Supports OpenAI, Google, OpenRouter, DashScope (阿里通义万象), Jimeng (即梦), Seedream (豆包) and Replicate providers.
|
||||
Official API-based image generation. Supports OpenAI, Azure OpenAI, Google, OpenRouter, DashScope (阿里通义万象), MiniMax, Jimeng (即梦), Seedream (豆包) and Replicate providers.
|
||||
|
||||
## Script Directory
|
||||
|
||||
@@ -30,17 +30,17 @@ Check EXTEND.md existence (priority: project → user):
|
||||
|
||||
```bash
|
||||
# macOS, Linux, WSL, Git Bash
|
||||
test -f .baoyu-skills/baoyu-image-gen/EXTEND.md && echo "project"
|
||||
test -f "${XDG_CONFIG_HOME:-$HOME/.config}/baoyu-skills/baoyu-image-gen/EXTEND.md" && echo "xdg"
|
||||
test -f "$HOME/.baoyu-skills/baoyu-image-gen/EXTEND.md" && echo "user"
|
||||
test -f .baoyu-skills/baoyu-imagine/EXTEND.md && echo "project"
|
||||
test -f "${XDG_CONFIG_HOME:-$HOME/.config}/baoyu-skills/baoyu-imagine/EXTEND.md" && echo "xdg"
|
||||
test -f "$HOME/.baoyu-skills/baoyu-imagine/EXTEND.md" && echo "user"
|
||||
```
|
||||
|
||||
```powershell
|
||||
# PowerShell (Windows)
|
||||
if (Test-Path .baoyu-skills/baoyu-image-gen/EXTEND.md) { "project" }
|
||||
if (Test-Path .baoyu-skills/baoyu-imagine/EXTEND.md) { "project" }
|
||||
$xdg = if ($env:XDG_CONFIG_HOME) { $env:XDG_CONFIG_HOME } else { "$HOME/.config" }
|
||||
if (Test-Path "$xdg/baoyu-skills/baoyu-image-gen/EXTEND.md") { "xdg" }
|
||||
if (Test-Path "$HOME/.baoyu-skills/baoyu-image-gen/EXTEND.md") { "user" }
|
||||
if (Test-Path "$xdg/baoyu-skills/baoyu-imagine/EXTEND.md") { "xdg" }
|
||||
if (Test-Path "$HOME/.baoyu-skills/baoyu-imagine/EXTEND.md") { "user" }
|
||||
```
|
||||
|
||||
| Result | Action |
|
||||
@@ -52,8 +52,8 @@ if (Test-Path "$HOME/.baoyu-skills/baoyu-image-gen/EXTEND.md") { "user" }
|
||||
|
||||
| Path | Location |
|
||||
|------|----------|
|
||||
| `.baoyu-skills/baoyu-image-gen/EXTEND.md` | Project directory |
|
||||
| `$HOME/.baoyu-skills/baoyu-image-gen/EXTEND.md` | User home |
|
||||
| `.baoyu-skills/baoyu-imagine/EXTEND.md` | Project directory |
|
||||
| `$HOME/.baoyu-skills/baoyu-imagine/EXTEND.md` | User home |
|
||||
|
||||
**EXTEND.md Supports**: Default provider | Default quality | Default aspect ratio | Default image size | Default models | Batch worker cap | Provider-specific batch limits
|
||||
|
||||
@@ -74,12 +74,15 @@ ${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --quality 2k
|
||||
# From prompt files
|
||||
${BUN_X} {baseDir}/scripts/main.ts --promptfiles system.md content.md --image out.png
|
||||
|
||||
# With reference images (Google, OpenAI, OpenRouter, or Replicate)
|
||||
# With reference images (Google, OpenAI, Azure OpenAI, OpenRouter, Replicate, MiniMax, or Seedream 4.0/4.5/5.0)
|
||||
${BUN_X} {baseDir}/scripts/main.ts --prompt "Make blue" --image out.png --ref source.png
|
||||
|
||||
# With reference images (explicit provider/model)
|
||||
${BUN_X} {baseDir}/scripts/main.ts --prompt "Make blue" --image out.png --provider google --model gemini-3-pro-image-preview --ref source.png
|
||||
|
||||
# Azure OpenAI (model means deployment name)
|
||||
${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --provider azure --model gpt-image-1.5
|
||||
|
||||
# OpenRouter (recommended default model)
|
||||
${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --provider openrouter
|
||||
|
||||
@@ -92,6 +95,21 @@ ${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --provider o
|
||||
# DashScope (阿里通义万象)
|
||||
${BUN_X} {baseDir}/scripts/main.ts --prompt "一只可爱的猫" --image out.png --provider dashscope
|
||||
|
||||
# DashScope Qwen-Image 2.0 Pro (recommended for custom sizes and text rendering)
|
||||
${BUN_X} {baseDir}/scripts/main.ts --prompt "为咖啡品牌设计一张 21:9 横幅海报,包含清晰中文标题" --image out.png --provider dashscope --model qwen-image-2.0-pro --size 2048x872
|
||||
|
||||
# DashScope legacy Qwen fixed-size model
|
||||
${BUN_X} {baseDir}/scripts/main.ts --prompt "一张电影感海报" --image out.png --provider dashscope --model qwen-image-max --size 1664x928
|
||||
|
||||
# MiniMax
|
||||
${BUN_X} {baseDir}/scripts/main.ts --prompt "A fashion editorial portrait by a bright studio window" --image out.jpg --provider minimax
|
||||
|
||||
# MiniMax with subject reference (best for character/portrait consistency)
|
||||
${BUN_X} {baseDir}/scripts/main.ts --prompt "A girl stands by the library window, cinematic lighting" --image out.jpg --provider minimax --model image-01 --ref portrait.png --ar 16:9
|
||||
|
||||
# MiniMax with custom size (documented for image-01)
|
||||
${BUN_X} {baseDir}/scripts/main.ts --prompt "A cinematic poster" --image out.jpg --provider minimax --model image-01 --size 1536x1024
|
||||
|
||||
# Replicate (google/nano-banana-pro)
|
||||
${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --provider replicate
|
||||
|
||||
@@ -141,13 +159,13 @@ Paths in `promptFiles`, `image`, and `ref` are resolved relative to the batch fi
|
||||
| `--image <path>` | Output image path (required in single-image mode) |
|
||||
| `--batchfile <path>` | JSON batch file for multi-image generation |
|
||||
| `--jobs <count>` | Worker count for batch mode (default: auto, max from config, built-in default 10) |
|
||||
| `--provider google\|openai\|openrouter\|dashscope\|jimeng\|seedream\|replicate` | Force provider (default: auto-detect) |
|
||||
| `--model <id>`, `-m` | Model ID (Google: `gemini-3-pro-image-preview`; OpenAI: `gpt-image-1.5`; OpenRouter: `google/gemini-3.1-flash-image-preview`) |
|
||||
| `--provider google\|openai\|azure\|openrouter\|dashscope\|minimax\|jimeng\|seedream\|replicate` | Force provider (default: auto-detect) |
|
||||
| `--model <id>`, `-m` | Model ID (Google: `gemini-3-pro-image-preview`; OpenAI: `gpt-image-1.5`; Azure: deployment name such as `gpt-image-1.5` or `image-prod`; OpenRouter: `google/gemini-3.1-flash-image-preview`; DashScope: `qwen-image-2.0-pro`; MiniMax: `image-01`) |
|
||||
| `--ar <ratio>` | Aspect ratio (e.g., `16:9`, `1:1`, `4:3`) |
|
||||
| `--size <WxH>` | Size (e.g., `1024x1024`) |
|
||||
| `--quality normal\|2k` | Quality preset (default: `2k`) |
|
||||
| `--imageSize 1K\|2K\|4K` | Image size for Google/OpenRouter (default: from quality) |
|
||||
| `--ref <files...>` | Reference images. Supported by Google multimodal, OpenAI GPT Image edits, OpenRouter multimodal models, and Replicate. Not supported by Jimeng or Seedream |
|
||||
| `--ref <files...>` | Reference images. Supported by Google multimodal, OpenAI GPT Image edits, Azure OpenAI edits (PNG/JPG only), OpenRouter multimodal models, Replicate, MiniMax subject-reference, and Seedream 5.0/4.5/4.0. Not supported by Jimeng, Seedream 3.0, or removed SeedEdit 3.0 |
|
||||
| `--n <count>` | Number of images |
|
||||
| `--json` | JSON output |
|
||||
|
||||
@@ -156,26 +174,34 @@ Paths in `promptFiles`, `image`, and `ref` are resolved relative to the batch fi
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `OPENAI_API_KEY` | OpenAI API key |
|
||||
| `AZURE_OPENAI_API_KEY` | Azure OpenAI API key |
|
||||
| `OPENROUTER_API_KEY` | OpenRouter API key |
|
||||
| `GOOGLE_API_KEY` | Google API key |
|
||||
| `DASHSCOPE_API_KEY` | DashScope API key (阿里云) |
|
||||
| `MINIMAX_API_KEY` | MiniMax API key |
|
||||
| `REPLICATE_API_TOKEN` | Replicate API token |
|
||||
| `JIMENG_ACCESS_KEY_ID` | Jimeng (即梦) Volcengine access key |
|
||||
| `JIMENG_SECRET_ACCESS_KEY` | Jimeng (即梦) Volcengine secret key |
|
||||
| `ARK_API_KEY` | Seedream (豆包) Volcengine ARK API key |
|
||||
| `OPENAI_IMAGE_MODEL` | OpenAI model override |
|
||||
| `AZURE_OPENAI_DEPLOYMENT` | Azure default deployment name |
|
||||
| `AZURE_OPENAI_IMAGE_MODEL` | Backward-compatible alias for Azure default deployment/model name |
|
||||
| `OPENROUTER_IMAGE_MODEL` | OpenRouter model override (default: `google/gemini-3.1-flash-image-preview`) |
|
||||
| `GOOGLE_IMAGE_MODEL` | Google model override |
|
||||
| `DASHSCOPE_IMAGE_MODEL` | DashScope model override (default: z-image-turbo) |
|
||||
| `DASHSCOPE_IMAGE_MODEL` | DashScope model override (default: `qwen-image-2.0-pro`) |
|
||||
| `MINIMAX_IMAGE_MODEL` | MiniMax model override (default: `image-01`) |
|
||||
| `REPLICATE_IMAGE_MODEL` | Replicate model override (default: google/nano-banana-pro) |
|
||||
| `JIMENG_IMAGE_MODEL` | Jimeng model override (default: jimeng_t2i_v40) |
|
||||
| `SEEDREAM_IMAGE_MODEL` | Seedream model override (default: doubao-seedream-5-0-260128) |
|
||||
| `OPENAI_BASE_URL` | Custom OpenAI endpoint |
|
||||
| `AZURE_OPENAI_BASE_URL` | Azure resource endpoint or deployment endpoint |
|
||||
| `AZURE_API_VERSION` | Azure image API version (default: `2025-04-01-preview`) |
|
||||
| `OPENROUTER_BASE_URL` | Custom OpenRouter endpoint (default: `https://openrouter.ai/api/v1`) |
|
||||
| `OPENROUTER_HTTP_REFERER` | Optional app/site URL for OpenRouter attribution |
|
||||
| `OPENROUTER_TITLE` | Optional app name for OpenRouter attribution |
|
||||
| `GOOGLE_BASE_URL` | Custom Google endpoint |
|
||||
| `DASHSCOPE_BASE_URL` | Custom DashScope endpoint |
|
||||
| `MINIMAX_BASE_URL` | Custom MiniMax endpoint (default: `https://api.minimax.io`) |
|
||||
| `REPLICATE_BASE_URL` | Custom Replicate endpoint |
|
||||
| `JIMENG_BASE_URL` | Custom Jimeng endpoint (default: `https://visual.volcengineapi.com`) |
|
||||
| `JIMENG_REGION` | Jimeng region (default: `cn-north-1`) |
|
||||
@@ -195,12 +221,88 @@ Model priority (highest → lowest), applies to all providers:
|
||||
3. Env var: `<PROVIDER>_IMAGE_MODEL` (e.g., `GOOGLE_IMAGE_MODEL`)
|
||||
4. Built-in default
|
||||
|
||||
For Azure, `--model` / `default_model.azure` should be the Azure deployment name. `AZURE_OPENAI_DEPLOYMENT` is the preferred env var, and `AZURE_OPENAI_IMAGE_MODEL` remains as a backward-compatible alias.
|
||||
|
||||
**EXTEND.md overrides env vars**. If both EXTEND.md `default_model.google: "gemini-3-pro-image-preview"` and env var `GOOGLE_IMAGE_MODEL=gemini-3.1-flash-image-preview` exist, EXTEND.md wins.
|
||||
|
||||
**Agent MUST display model info** before each generation:
|
||||
- Show: `Using [provider] / [model]`
|
||||
- Show switch hint: `Switch model: --model <id> | EXTEND.md default_model.[provider] | env <PROVIDER>_IMAGE_MODEL`
|
||||
|
||||
### DashScope Models
|
||||
|
||||
Use `--model qwen-image-2.0-pro` or set `default_model.dashscope` / `DASHSCOPE_IMAGE_MODEL` when the user wants official Qwen-Image behavior.
|
||||
|
||||
Official DashScope model families:
|
||||
|
||||
- `qwen-image-2.0-pro`, `qwen-image-2.0-pro-2026-03-03`, `qwen-image-2.0`, `qwen-image-2.0-2026-03-03`
|
||||
- Free-form `size` in `宽*高` format
|
||||
- Total pixels must stay between `512*512` and `2048*2048`
|
||||
- Default size is approximately `1024*1024`
|
||||
- Best choice for custom ratios such as `21:9` and text-heavy Chinese/English layouts
|
||||
- `qwen-image-max`, `qwen-image-max-2025-12-30`, `qwen-image-plus`, `qwen-image-plus-2026-01-09`, `qwen-image`
|
||||
- Fixed sizes only: `1664*928`, `1472*1104`, `1328*1328`, `1104*1472`, `928*1664`
|
||||
- Default size is `1664*928`
|
||||
- `qwen-image` currently has the same capability as `qwen-image-plus`
|
||||
- Legacy DashScope models such as `z-image-turbo`, `z-image-ultra`, `wanx-v1`
|
||||
- Keep using them only when the user explicitly asks for legacy behavior or compatibility
|
||||
|
||||
When translating CLI args into DashScope behavior:
|
||||
|
||||
- `--size` wins over `--ar`
|
||||
- For `qwen-image-2.0*`, prefer explicit `--size`; otherwise infer from `--ar` and use the official recommended resolutions below
|
||||
- For `qwen-image-max/plus/image`, only use the five official fixed sizes; if the requested ratio is not covered, switch to `qwen-image-2.0-pro`
|
||||
- `--quality` is a baoyu-imagine compatibility preset, not a native DashScope API field. Mapping `normal` / `2k` onto the `qwen-image-2.0*` table below is an implementation inference, not an official API guarantee
|
||||
|
||||
Recommended `qwen-image-2.0*` sizes for common aspect ratios:
|
||||
|
||||
| Ratio | `normal` | `2k` |
|
||||
|-------|----------|------|
|
||||
| `1:1` | `1024*1024` | `1536*1536` |
|
||||
| `2:3` | `768*1152` | `1024*1536` |
|
||||
| `3:2` | `1152*768` | `1536*1024` |
|
||||
| `3:4` | `960*1280` | `1080*1440` |
|
||||
| `4:3` | `1280*960` | `1440*1080` |
|
||||
| `9:16` | `720*1280` | `1080*1920` |
|
||||
| `16:9` | `1280*720` | `1920*1080` |
|
||||
| `21:9` | `1344*576` | `2048*872` |
|
||||
|
||||
DashScope official APIs also expose `negative_prompt`, `prompt_extend`, and `watermark`, but `baoyu-imagine` does not expose them as dedicated CLI flags today.
|
||||
|
||||
Official references:
|
||||
|
||||
- [Qwen-Image API](https://help.aliyun.com/zh/model-studio/qwen-image-api)
|
||||
- [Text-to-image guide](https://help.aliyun.com/zh/model-studio/text-to-image)
|
||||
- [Qwen-Image Edit API](https://help.aliyun.com/zh/model-studio/qwen-image-edit-api)
|
||||
|
||||
### MiniMax Models
|
||||
|
||||
Use `--model image-01` or set `default_model.minimax` / `MINIMAX_IMAGE_MODEL` when the user wants MiniMax image generation.
|
||||
|
||||
Official MiniMax image model options currently documented in the API reference:
|
||||
|
||||
- `image-01` (recommended default)
|
||||
- Supports text-to-image and subject-reference image generation
|
||||
- Supports official `aspect_ratio` values: `1:1`, `16:9`, `4:3`, `3:2`, `2:3`, `3:4`, `9:16`, `21:9`
|
||||
- Supports documented custom `width` / `height` output sizes when using `--size <WxH>`
|
||||
- `width` and `height` must both be between `512` and `2048`, and both must be divisible by `8`
|
||||
- `image-01-live`
|
||||
- Lower-latency variant
|
||||
- Use `--ar` for sizing; MiniMax documents custom `width` / `height` as only effective for `image-01`
|
||||
|
||||
MiniMax subject reference notes:
|
||||
|
||||
- `--ref` files are sent as MiniMax `subject_reference`
|
||||
- MiniMax docs currently describe `subject_reference[].type` as `character`
|
||||
- Official docs say `image_file` supports public URLs or Base64 Data URLs; `baoyu-imagine` sends local refs as Data URLs
|
||||
- Official docs recommend front-facing portrait references in JPG/JPEG/PNG under 10MB
|
||||
|
||||
Official references:
|
||||
|
||||
- [MiniMax Image Generation Guide](https://platform.minimax.io/docs/guides/image-generation)
|
||||
- [MiniMax Text-to-Image API](https://platform.minimax.io/docs/api-reference/image-generation-t2i)
|
||||
- [MiniMax Image-to-Image API](https://platform.minimax.io/docs/api-reference/image-generation-i2i)
|
||||
|
||||
### OpenRouter Models
|
||||
|
||||
Use full OpenRouter model IDs, e.g.:
|
||||
@@ -235,8 +337,8 @@ ${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --provider r
|
||||
|
||||
## Provider Selection
|
||||
|
||||
1. `--ref` provided + no `--provider` → auto-select Google first, then OpenAI, then OpenRouter, then Replicate (Jimeng and Seedream do not support reference images)
|
||||
2. `--provider` specified → use it (if `--ref`, must be `google`, `openai`, `openrouter`, or `replicate`)
|
||||
1. `--ref` provided + no `--provider` → auto-select Google first, then OpenAI, then Azure, then OpenRouter, then Replicate, then Seedream, then MiniMax (MiniMax subject reference is more specialized toward character/portrait consistency)
|
||||
2. `--provider` specified → use it (if `--ref`, must be `google`, `openai`, `azure`, `openrouter`, `replicate`, `seedream`, or `minimax`)
|
||||
3. Only one API key available → use that provider
|
||||
4. Multiple available → default to Google
|
||||
|
||||
@@ -257,6 +359,7 @@ Supported: `1:1`, `16:9`, `9:16`, `4:3`, `3:4`, `2.35:1`
|
||||
- OpenAI: maps to closest supported size
|
||||
- OpenRouter: sends `imageGenerationOptions.aspect_ratio`; if only `--size <WxH>` is given, aspect ratio is inferred automatically
|
||||
- Replicate: passes `aspect_ratio` to model; when `--ref` is provided without `--ar`, defaults to `match_input_image`
|
||||
- MiniMax: sends official `aspect_ratio` values directly; if `--size <WxH>` is given without `--ar`, `width` / `height` are sent for `image-01`
|
||||
|
||||
## Generation Mode
|
||||
|
||||
+92
-7
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: first-time-setup
|
||||
description: First-time setup and default model selection flow for baoyu-image-gen
|
||||
description: First-time setup and default model selection flow for baoyu-imagine
|
||||
---
|
||||
|
||||
# First-Time Setup
|
||||
@@ -47,10 +47,14 @@ options:
|
||||
description: "Gemini multimodal - high quality, reference images, flexible sizes"
|
||||
- label: "OpenAI"
|
||||
description: "GPT Image - consistent quality, reliable output"
|
||||
- label: "Azure OpenAI"
|
||||
description: "Azure-hosted GPT Image deployments with resource-specific routing"
|
||||
- label: "OpenRouter"
|
||||
description: "Router for Gemini/FLUX/OpenAI-compatible image models"
|
||||
- label: "DashScope"
|
||||
description: "Alibaba Cloud - z-image-turbo, good for Chinese content"
|
||||
description: "Alibaba Cloud - Qwen-Image, strong Chinese/English text rendering"
|
||||
- label: "MiniMax"
|
||||
description: "MiniMax image generation with subject-reference character workflows"
|
||||
- label: "Replicate"
|
||||
description: "Community models - nano-banana-pro, flexible model selection"
|
||||
```
|
||||
@@ -87,6 +91,34 @@ options:
|
||||
description: "Strong text-to-image quality through OpenRouter"
|
||||
```
|
||||
|
||||
### Question 2c: Default Azure Deployment
|
||||
|
||||
Only show if user selected Azure OpenAI.
|
||||
|
||||
```yaml
|
||||
header: "Azure Deploy"
|
||||
question: "Default Azure image deployment name?"
|
||||
options:
|
||||
- label: "gpt-image-1.5 (Recommended)"
|
||||
description: "Best default if your Azure deployment uses the same name"
|
||||
- label: "gpt-image-1"
|
||||
description: "Previous GPT Image deployment name"
|
||||
```
|
||||
|
||||
### Question 2d: Default MiniMax Model
|
||||
|
||||
Only show if user selected MiniMax.
|
||||
|
||||
```yaml
|
||||
header: "MiniMax Model"
|
||||
question: "Default MiniMax image generation model?"
|
||||
options:
|
||||
- label: "image-01 (Recommended)"
|
||||
description: "Best default, supports aspect ratios and custom width/height"
|
||||
- label: "image-01-live"
|
||||
description: "Faster variant, use aspect ratio instead of custom size"
|
||||
```
|
||||
|
||||
### Question 3: Default Quality
|
||||
|
||||
```yaml
|
||||
@@ -115,8 +147,8 @@ options:
|
||||
|
||||
| Choice | Path | Scope |
|
||||
|--------|------|-------|
|
||||
| Project | `.baoyu-skills/baoyu-image-gen/EXTEND.md` | Current project |
|
||||
| User | `$HOME/.baoyu-skills/baoyu-image-gen/EXTEND.md` | All projects |
|
||||
| Project | `.baoyu-skills/baoyu-imagine/EXTEND.md` | Current project |
|
||||
| User | `$HOME/.baoyu-skills/baoyu-imagine/EXTEND.md` | All projects |
|
||||
|
||||
### EXTEND.md Template
|
||||
|
||||
@@ -130,8 +162,10 @@ default_image_size: null
|
||||
default_model:
|
||||
google: [selected google model or null]
|
||||
openai: null
|
||||
azure: [selected azure deployment or null]
|
||||
openrouter: [selected openrouter model or null]
|
||||
dashscope: null
|
||||
minimax: [selected minimax model or null]
|
||||
replicate: null
|
||||
---
|
||||
```
|
||||
@@ -166,6 +200,23 @@ options:
|
||||
description: "Previous generation GPT Image model"
|
||||
```
|
||||
|
||||
### Azure Deployment Selection
|
||||
|
||||
```yaml
|
||||
header: "Azure Deploy"
|
||||
question: "Choose a default Azure image deployment name?"
|
||||
options:
|
||||
- label: "gpt-image-1.5 (Recommended)"
|
||||
description: "Use when your Azure deployment name matches the GPT-image-1.5 model"
|
||||
- label: "gpt-image-1"
|
||||
description: "Use when your Azure deployment name matches GPT-image-1"
|
||||
```
|
||||
|
||||
Notes for Azure setup:
|
||||
|
||||
- In `baoyu-imagine`, Azure `--model` / `default_model.azure` should be the Azure deployment name, not just the underlying model family.
|
||||
- If the deployment name is custom, save that exact deployment name in `default_model.azure`.
|
||||
|
||||
### OpenRouter Model Selection
|
||||
|
||||
```yaml
|
||||
@@ -186,12 +237,26 @@ options:
|
||||
header: "DashScope Model"
|
||||
question: "Choose a default DashScope image generation model?"
|
||||
options:
|
||||
- label: "z-image-turbo (Recommended)"
|
||||
description: "Fast generation, good quality"
|
||||
- label: "qwen-image-2.0-pro (Recommended)"
|
||||
description: "Best DashScope model for text rendering and custom sizes"
|
||||
- label: "qwen-image-2.0"
|
||||
description: "Faster 2.0 variant with flexible output size"
|
||||
- label: "qwen-image-max"
|
||||
description: "Legacy Qwen model with five fixed output sizes"
|
||||
- label: "qwen-image-plus"
|
||||
description: "Legacy Qwen model, same current capability as qwen-image"
|
||||
- label: "z-image-turbo"
|
||||
description: "Legacy DashScope model for compatibility"
|
||||
- label: "z-image-ultra"
|
||||
description: "Higher quality, slower generation"
|
||||
description: "Legacy DashScope model, higher quality but slower"
|
||||
```
|
||||
|
||||
Notes for DashScope setup:
|
||||
|
||||
- Prefer `qwen-image-2.0-pro` when the user needs custom `--size`, uncommon ratios like `21:9`, or strong Chinese/English text rendering.
|
||||
- `qwen-image-max` / `qwen-image-plus` / `qwen-image` only support five fixed sizes: `1664*928`, `1472*1104`, `1328*1328`, `1104*1472`, `928*1664`.
|
||||
- In `baoyu-imagine`, `quality` is a compatibility preset. It is not a native DashScope parameter.
|
||||
|
||||
### Replicate Model Selection
|
||||
|
||||
```yaml
|
||||
@@ -204,6 +269,24 @@ options:
|
||||
description: "Google's base image model on Replicate"
|
||||
```
|
||||
|
||||
### MiniMax Model Selection
|
||||
|
||||
```yaml
|
||||
header: "MiniMax Model"
|
||||
question: "Choose a default MiniMax image generation model?"
|
||||
options:
|
||||
- label: "image-01 (Recommended)"
|
||||
description: "Best general-purpose MiniMax image model with custom width/height support"
|
||||
- label: "image-01-live"
|
||||
description: "Lower-latency MiniMax image model using aspect ratios"
|
||||
```
|
||||
|
||||
Notes for MiniMax setup:
|
||||
|
||||
- `image-01` is the safest default. It supports official `aspect_ratio` values and documented custom `width` / `height` output sizes.
|
||||
- `image-01-live` is useful when the user prefers faster generation and can work with aspect-ratio-based sizing.
|
||||
- MiniMax subject reference currently uses `subject_reference[].type = character`; docs recommend front-facing portrait references in JPG/JPEG/PNG under 10MB.
|
||||
|
||||
### Update EXTEND.md
|
||||
|
||||
After user selects a model:
|
||||
@@ -216,8 +299,10 @@ After user selects a model:
|
||||
default_model:
|
||||
google: [value or null]
|
||||
openai: [value or null]
|
||||
azure: [value or null]
|
||||
openrouter: [value or null]
|
||||
dashscope: [value or null]
|
||||
minimax: [value or null]
|
||||
replicate: [value or null]
|
||||
```
|
||||
|
||||
+22
-4
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: preferences-schema
|
||||
description: EXTEND.md YAML schema for baoyu-image-gen user preferences
|
||||
description: EXTEND.md YAML schema for baoyu-imagine user preferences
|
||||
---
|
||||
|
||||
# Preferences Schema
|
||||
@@ -11,7 +11,7 @@ description: EXTEND.md YAML schema for baoyu-image-gen user preferences
|
||||
---
|
||||
version: 1
|
||||
|
||||
default_provider: null # google|openai|openrouter|dashscope|replicate|null (null = auto-detect)
|
||||
default_provider: null # google|openai|azure|openrouter|dashscope|minimax|replicate|null (null = auto-detect)
|
||||
|
||||
default_quality: null # normal|2k|null (null = use default: 2k)
|
||||
|
||||
@@ -22,8 +22,10 @@ default_image_size: null # 1K|2K|4K|null (Google/OpenRouter, overrides qualit
|
||||
default_model:
|
||||
google: null # e.g., "gemini-3-pro-image-preview", "gemini-3.1-flash-image-preview"
|
||||
openai: null # e.g., "gpt-image-1.5", "gpt-image-1"
|
||||
azure: null # Azure deployment name, e.g., "gpt-image-1.5" or "image-prod"
|
||||
openrouter: null # e.g., "google/gemini-3.1-flash-image-preview"
|
||||
dashscope: null # e.g., "z-image-turbo"
|
||||
dashscope: null # e.g., "qwen-image-2.0-pro"
|
||||
minimax: null # e.g., "image-01"
|
||||
replicate: null # e.g., "google/nano-banana-pro"
|
||||
|
||||
batch:
|
||||
@@ -38,12 +40,18 @@ batch:
|
||||
openai:
|
||||
concurrency: 3
|
||||
start_interval_ms: 1100
|
||||
azure:
|
||||
concurrency: 3
|
||||
start_interval_ms: 1100
|
||||
openrouter:
|
||||
concurrency: 3
|
||||
start_interval_ms: 1100
|
||||
dashscope:
|
||||
concurrency: 3
|
||||
start_interval_ms: 1100
|
||||
minimax:
|
||||
concurrency: 3
|
||||
start_interval_ms: 1100
|
||||
---
|
||||
```
|
||||
|
||||
@@ -58,8 +66,10 @@ batch:
|
||||
| `default_image_size` | string\|null | null | Google/OpenRouter image size (overrides quality) |
|
||||
| `default_model.google` | string\|null | null | Google default model |
|
||||
| `default_model.openai` | string\|null | null | OpenAI default model |
|
||||
| `default_model.azure` | string\|null | null | Azure default deployment name |
|
||||
| `default_model.openrouter` | string\|null | null | OpenRouter default model |
|
||||
| `default_model.dashscope` | string\|null | null | DashScope default model |
|
||||
| `default_model.minimax` | string\|null | null | MiniMax default model |
|
||||
| `default_model.replicate` | string\|null | null | Replicate default model |
|
||||
| `batch.max_workers` | int\|null | 10 | Batch worker cap |
|
||||
| `batch.provider_limits.<provider>.concurrency` | int\|null | provider default | Max simultaneous requests per provider |
|
||||
@@ -87,8 +97,10 @@ default_image_size: 2K
|
||||
default_model:
|
||||
google: "gemini-3-pro-image-preview"
|
||||
openai: "gpt-image-1.5"
|
||||
azure: "gpt-image-1.5"
|
||||
openrouter: "google/gemini-3.1-flash-image-preview"
|
||||
dashscope: "z-image-turbo"
|
||||
dashscope: "qwen-image-2.0-pro"
|
||||
minimax: "image-01"
|
||||
replicate: "google/nano-banana-pro"
|
||||
batch:
|
||||
max_workers: 10
|
||||
@@ -96,8 +108,14 @@ batch:
|
||||
replicate:
|
||||
concurrency: 5
|
||||
start_interval_ms: 700
|
||||
azure:
|
||||
concurrency: 3
|
||||
start_interval_ms: 1100
|
||||
openrouter:
|
||||
concurrency: 3
|
||||
start_interval_ms: 1100
|
||||
minimax:
|
||||
concurrency: 3
|
||||
start_interval_ms: 1100
|
||||
---
|
||||
```
|
||||
+123
-12
@@ -2,8 +2,9 @@ import assert from "node:assert/strict";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import test, { type TestContext } from "node:test";
|
||||
|
||||
import type { CliArgs, ExtendConfig } from "./types.ts";
|
||||
import {
|
||||
createTaskArgs,
|
||||
detectProvider,
|
||||
@@ -16,9 +17,9 @@ import {
|
||||
normalizeOutputImagePath,
|
||||
parseArgs,
|
||||
parseSimpleYaml,
|
||||
} from "../../../skills/baoyu-image-gen/scripts/main.ts";
|
||||
} from "./main.ts";
|
||||
|
||||
function makeArgs(overrides = {}) {
|
||||
function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
return {
|
||||
prompt: null,
|
||||
promptFiles: [],
|
||||
@@ -39,8 +40,11 @@ function makeArgs(overrides = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
function useEnv(t, values) {
|
||||
const previous = new Map();
|
||||
function useEnv(
|
||||
t: TestContext,
|
||||
values: Record<string, string | null>,
|
||||
): void {
|
||||
const previous = new Map<string, string | undefined>();
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
previous.set(key, process.env[key]);
|
||||
if (value == null) {
|
||||
@@ -61,11 +65,11 @@ function useEnv(t, values) {
|
||||
});
|
||||
}
|
||||
|
||||
async function makeTempDir(prefix) {
|
||||
async function makeTempDir(prefix: string): Promise<string> {
|
||||
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
test("parseArgs parses the main image-gen CLI flags", () => {
|
||||
test("parseArgs parses the main baoyu-imagine CLI flags", () => {
|
||||
const args = parseArgs([
|
||||
"--promptfiles",
|
||||
"prompts/system.md",
|
||||
@@ -119,6 +123,8 @@ default_image_size: 2K
|
||||
default_model:
|
||||
google: gemini-3-pro-image-preview
|
||||
openai: gpt-image-1.5
|
||||
azure: image-prod
|
||||
minimax: image-01
|
||||
batch:
|
||||
max_workers: 8
|
||||
provider_limits:
|
||||
@@ -127,6 +133,12 @@ batch:
|
||||
start_interval_ms: 900
|
||||
openai:
|
||||
concurrency: 4
|
||||
minimax:
|
||||
concurrency: 2
|
||||
start_interval_ms: 1400
|
||||
azure:
|
||||
concurrency: 1
|
||||
start_interval_ms: 1500
|
||||
`;
|
||||
|
||||
const config = parseSimpleYaml(yaml);
|
||||
@@ -138,6 +150,8 @@ batch:
|
||||
assert.equal(config.default_image_size, "2K");
|
||||
assert.equal(config.default_model?.google, "gemini-3-pro-image-preview");
|
||||
assert.equal(config.default_model?.openai, "gpt-image-1.5");
|
||||
assert.equal(config.default_model?.azure, "image-prod");
|
||||
assert.equal(config.default_model?.minimax, "image-01");
|
||||
assert.equal(config.batch?.max_workers, 8);
|
||||
assert.deepEqual(config.batch?.provider_limits?.google, {
|
||||
concurrency: 2,
|
||||
@@ -146,6 +160,14 @@ batch:
|
||||
assert.deepEqual(config.batch?.provider_limits?.openai, {
|
||||
concurrency: 4,
|
||||
});
|
||||
assert.deepEqual(config.batch?.provider_limits?.minimax, {
|
||||
concurrency: 2,
|
||||
start_interval_ms: 1400,
|
||||
});
|
||||
assert.deepEqual(config.batch?.provider_limits?.azure, {
|
||||
concurrency: 1,
|
||||
start_interval_ms: 1500,
|
||||
});
|
||||
});
|
||||
|
||||
test("mergeConfig only fills values missing from CLI args", () => {
|
||||
@@ -161,7 +183,7 @@ test("mergeConfig only fills values missing from CLI args", () => {
|
||||
default_quality: "2k",
|
||||
default_aspect_ratio: "3:2",
|
||||
default_image_size: "2K",
|
||||
},
|
||||
} satisfies Partial<ExtendConfig>,
|
||||
);
|
||||
|
||||
assert.equal(merged.provider, "openai");
|
||||
@@ -187,6 +209,7 @@ test("detectProvider rejects non-ref-capable providers and prefers Google first
|
||||
OPENAI_API_KEY: "openai-key",
|
||||
OPENROUTER_API_KEY: null,
|
||||
DASHSCOPE_API_KEY: null,
|
||||
MINIMAX_API_KEY: null,
|
||||
REPLICATE_API_TOKEN: null,
|
||||
JIMENG_ACCESS_KEY_ID: null,
|
||||
JIMENG_SECRET_ACCESS_KEY: null,
|
||||
@@ -199,8 +222,11 @@ test("detectProvider selects an available ref-capable provider for reference-ima
|
||||
useEnv(t, {
|
||||
GOOGLE_API_KEY: null,
|
||||
OPENAI_API_KEY: "openai-key",
|
||||
AZURE_OPENAI_API_KEY: null,
|
||||
AZURE_OPENAI_BASE_URL: null,
|
||||
OPENROUTER_API_KEY: null,
|
||||
DASHSCOPE_API_KEY: null,
|
||||
MINIMAX_API_KEY: null,
|
||||
REPLICATE_API_TOKEN: null,
|
||||
JIMENG_ACCESS_KEY_ID: null,
|
||||
JIMENG_SECRET_ACCESS_KEY: null,
|
||||
@@ -212,6 +238,82 @@ test("detectProvider selects an available ref-capable provider for reference-ima
|
||||
);
|
||||
});
|
||||
|
||||
test("detectProvider selects Azure when only Azure credentials are configured", (t) => {
|
||||
useEnv(t, {
|
||||
GOOGLE_API_KEY: null,
|
||||
OPENAI_API_KEY: null,
|
||||
AZURE_OPENAI_API_KEY: "azure-key",
|
||||
AZURE_OPENAI_BASE_URL: "https://example.openai.azure.com",
|
||||
OPENROUTER_API_KEY: null,
|
||||
DASHSCOPE_API_KEY: null,
|
||||
MINIMAX_API_KEY: null,
|
||||
REPLICATE_API_TOKEN: null,
|
||||
JIMENG_ACCESS_KEY_ID: null,
|
||||
JIMENG_SECRET_ACCESS_KEY: null,
|
||||
ARK_API_KEY: null,
|
||||
});
|
||||
|
||||
assert.equal(detectProvider(makeArgs()), "azure");
|
||||
assert.equal(
|
||||
detectProvider(makeArgs({ referenceImages: ["ref.png"] })),
|
||||
"azure",
|
||||
);
|
||||
});
|
||||
|
||||
test("detectProvider infers Seedream from model id and allows Seedream reference-image workflows", (t) => {
|
||||
useEnv(t, {
|
||||
GOOGLE_API_KEY: null,
|
||||
OPENAI_API_KEY: null,
|
||||
OPENROUTER_API_KEY: null,
|
||||
DASHSCOPE_API_KEY: null,
|
||||
MINIMAX_API_KEY: null,
|
||||
REPLICATE_API_TOKEN: null,
|
||||
JIMENG_ACCESS_KEY_ID: null,
|
||||
JIMENG_SECRET_ACCESS_KEY: null,
|
||||
ARK_API_KEY: "ark-key",
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
detectProvider(
|
||||
makeArgs({
|
||||
model: "doubao-seedream-4-5-251128",
|
||||
referenceImages: ["ref.png"],
|
||||
}),
|
||||
),
|
||||
"seedream",
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
detectProvider(
|
||||
makeArgs({
|
||||
provider: "seedream",
|
||||
referenceImages: ["ref.png"],
|
||||
}),
|
||||
),
|
||||
"seedream",
|
||||
);
|
||||
});
|
||||
|
||||
test("detectProvider selects MiniMax when only MiniMax credentials are configured or the model id matches", (t) => {
|
||||
useEnv(t, {
|
||||
GOOGLE_API_KEY: null,
|
||||
OPENAI_API_KEY: null,
|
||||
AZURE_OPENAI_API_KEY: null,
|
||||
AZURE_OPENAI_BASE_URL: null,
|
||||
OPENROUTER_API_KEY: null,
|
||||
DASHSCOPE_API_KEY: null,
|
||||
MINIMAX_API_KEY: "minimax-key",
|
||||
REPLICATE_API_TOKEN: null,
|
||||
JIMENG_ACCESS_KEY_ID: null,
|
||||
JIMENG_SECRET_ACCESS_KEY: null,
|
||||
ARK_API_KEY: null,
|
||||
});
|
||||
|
||||
assert.equal(detectProvider(makeArgs()), "minimax");
|
||||
assert.equal(detectProvider(makeArgs({ referenceImages: ["ref.png"] })), "minimax");
|
||||
assert.equal(detectProvider(makeArgs({ model: "image-01-live" })), "minimax");
|
||||
});
|
||||
|
||||
test("batch worker and provider-rate-limit configuration prefer env over EXTEND config", (t) => {
|
||||
useEnv(t, {
|
||||
BAOYU_IMAGE_GEN_MAX_WORKERS: "12",
|
||||
@@ -219,7 +321,7 @@ test("batch worker and provider-rate-limit configuration prefer env over EXTEND
|
||||
BAOYU_IMAGE_GEN_GOOGLE_START_INTERVAL_MS: "450",
|
||||
});
|
||||
|
||||
const extendConfig = {
|
||||
const extendConfig: Partial<ExtendConfig> = {
|
||||
batch: {
|
||||
max_workers: 7,
|
||||
provider_limits: {
|
||||
@@ -227,6 +329,10 @@ test("batch worker and provider-rate-limit configuration prefer env over EXTEND
|
||||
concurrency: 2,
|
||||
start_interval_ms: 900,
|
||||
},
|
||||
minimax: {
|
||||
concurrency: 1,
|
||||
start_interval_ms: 1500,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -236,10 +342,14 @@ test("batch worker and provider-rate-limit configuration prefer env over EXTEND
|
||||
concurrency: 5,
|
||||
startIntervalMs: 450,
|
||||
});
|
||||
assert.deepEqual(getConfiguredProviderRateLimits(extendConfig).minimax, {
|
||||
concurrency: 1,
|
||||
startIntervalMs: 1500,
|
||||
});
|
||||
});
|
||||
|
||||
test("loadBatchTasks and createTaskArgs resolve batch-relative paths", async (t) => {
|
||||
const root = await makeTempDir("baoyu-image-gen-batch-");
|
||||
const root = await makeTempDir("baoyu-imagine-batch-");
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
|
||||
const batchFile = path.join(root, "jobs", "batch.json");
|
||||
@@ -263,7 +373,7 @@ test("loadBatchTasks and createTaskArgs resolve batch-relative paths", async (t)
|
||||
const loaded = await loadBatchTasks(batchFile);
|
||||
assert.equal(loaded.jobs, 2);
|
||||
assert.equal(loaded.batchDir, path.dirname(batchFile));
|
||||
assert.equal(loaded.tasks[0].id, "hero");
|
||||
assert.equal(loaded.tasks[0]?.id, "hero");
|
||||
|
||||
const taskArgs = createTaskArgs(
|
||||
makeArgs({
|
||||
@@ -271,7 +381,7 @@ test("loadBatchTasks and createTaskArgs resolve batch-relative paths", async (t)
|
||||
quality: "2k",
|
||||
json: true,
|
||||
}),
|
||||
loaded.tasks[0],
|
||||
loaded.tasks[0]!,
|
||||
loaded.batchDir,
|
||||
);
|
||||
|
||||
@@ -290,6 +400,7 @@ test("loadBatchTasks and createTaskArgs resolve batch-relative paths", async (t)
|
||||
|
||||
test("path normalization, worker count, and retry classification follow expected rules", () => {
|
||||
assert.match(normalizeOutputImagePath("out/sample"), /out[\\/]+sample\.png$/);
|
||||
assert.match(normalizeOutputImagePath("out/sample", ".jpg"), /out[\\/]+sample\.jpg$/);
|
||||
assert.match(normalizeOutputImagePath("out/sample.webp"), /out[\\/]+sample\.webp$/);
|
||||
|
||||
assert.equal(getWorkerCount(8, null, 3), 3);
|
||||
@@ -14,6 +14,8 @@ import type {
|
||||
type ProviderModule = {
|
||||
getDefaultModel: () => string;
|
||||
generateImage: (prompt: string, model: string, args: CliArgs) => Promise<Uint8Array>;
|
||||
validateArgs?: (model: string, args: CliArgs) => void;
|
||||
getDefaultOutputExtension?: (model: string, args: CliArgs) => string;
|
||||
};
|
||||
|
||||
type PreparedTask = {
|
||||
@@ -56,8 +58,10 @@ const DEFAULT_PROVIDER_RATE_LIMITS: Record<Provider, ProviderRateLimit> = {
|
||||
openai: { concurrency: 3, startIntervalMs: 1100 },
|
||||
openrouter: { concurrency: 3, startIntervalMs: 1100 },
|
||||
dashscope: { concurrency: 3, startIntervalMs: 1100 },
|
||||
minimax: { concurrency: 3, startIntervalMs: 1100 },
|
||||
jimeng: { concurrency: 3, startIntervalMs: 1100 },
|
||||
seedream: { concurrency: 3, startIntervalMs: 1100 },
|
||||
azure: { concurrency: 3, startIntervalMs: 1100 },
|
||||
};
|
||||
|
||||
function printUsage(): void {
|
||||
@@ -72,13 +76,13 @@ Options:
|
||||
--image <path> Output image path (required in single-image mode)
|
||||
--batchfile <path> JSON batch file for multi-image generation
|
||||
--jobs <count> Worker count for batch mode (default: auto, max from config, built-in default 10)
|
||||
--provider google|openai|openrouter|dashscope|replicate|jimeng|seedream Force provider (auto-detect by default)
|
||||
--provider google|openai|openrouter|dashscope|minimax|replicate|jimeng|seedream|azure Force provider (auto-detect by default)
|
||||
-m, --model <id> Model ID
|
||||
--ar <ratio> Aspect ratio (e.g., 16:9, 1:1, 4:3)
|
||||
--size <WxH> Size (e.g., 1024x1024)
|
||||
--quality normal|2k Quality preset (default: 2k)
|
||||
--imageSize 1K|2K|4K Image size for Google/OpenRouter (default: from quality)
|
||||
--ref <files...> Reference images (Google multimodal, OpenAI GPT Image edits, OpenRouter multimodal, or Replicate)
|
||||
--ref <files...> Reference images (Google, OpenAI, Azure, OpenRouter, Replicate, MiniMax, or Seedream 4.0/4.5/5.0)
|
||||
--n <count> Number of images for the current task (default: 1)
|
||||
--json JSON output
|
||||
-h, --help Show help
|
||||
@@ -109,6 +113,7 @@ Environment variables:
|
||||
GOOGLE_API_KEY Google API key
|
||||
GEMINI_API_KEY Gemini API key (alias for GOOGLE_API_KEY)
|
||||
DASHSCOPE_API_KEY DashScope API key
|
||||
MINIMAX_API_KEY MiniMax API key
|
||||
REPLICATE_API_TOKEN Replicate API token
|
||||
JIMENG_ACCESS_KEY_ID Jimeng Access Key ID
|
||||
JIMENG_SECRET_ACCESS_KEY Jimeng Secret Access Key
|
||||
@@ -116,7 +121,8 @@ Environment variables:
|
||||
OPENAI_IMAGE_MODEL Default OpenAI model (gpt-image-1.5)
|
||||
OPENROUTER_IMAGE_MODEL Default OpenRouter model (google/gemini-3.1-flash-image-preview)
|
||||
GOOGLE_IMAGE_MODEL Default Google model (gemini-3-pro-image-preview)
|
||||
DASHSCOPE_IMAGE_MODEL Default DashScope model (z-image-turbo)
|
||||
DASHSCOPE_IMAGE_MODEL Default DashScope model (qwen-image-2.0-pro)
|
||||
MINIMAX_IMAGE_MODEL Default MiniMax model (image-01)
|
||||
REPLICATE_IMAGE_MODEL Default Replicate model (google/nano-banana-pro)
|
||||
JIMENG_IMAGE_MODEL Default Jimeng model (jimeng_t2i_v40)
|
||||
SEEDREAM_IMAGE_MODEL Default Seedream model (doubao-seedream-5-0-260128)
|
||||
@@ -127,8 +133,14 @@ Environment variables:
|
||||
OPENROUTER_TITLE Optional app name for OpenRouter attribution
|
||||
GOOGLE_BASE_URL Custom Google endpoint
|
||||
DASHSCOPE_BASE_URL Custom DashScope endpoint
|
||||
MINIMAX_BASE_URL Custom MiniMax endpoint
|
||||
REPLICATE_BASE_URL Custom Replicate endpoint
|
||||
JIMENG_BASE_URL Custom Jimeng endpoint
|
||||
AZURE_OPENAI_API_KEY Azure OpenAI API key
|
||||
AZURE_OPENAI_BASE_URL Azure OpenAI resource or deployment endpoint
|
||||
AZURE_OPENAI_DEPLOYMENT Default Azure deployment name
|
||||
AZURE_API_VERSION Azure API version (default: 2025-04-01-preview)
|
||||
AZURE_OPENAI_IMAGE_MODEL Backward-compatible Azure deployment/model alias (defaults to gpt-image-1.5)
|
||||
SEEDREAM_BASE_URL Custom Seedream endpoint
|
||||
BAOYU_IMAGE_GEN_MAX_WORKERS Override batch worker cap
|
||||
BAOYU_IMAGE_GEN_<PROVIDER>_CONCURRENCY Override provider concurrency
|
||||
@@ -227,9 +239,11 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
v !== "openai" &&
|
||||
v !== "openrouter" &&
|
||||
v !== "dashscope" &&
|
||||
v !== "minimax" &&
|
||||
v !== "replicate" &&
|
||||
v !== "jimeng" &&
|
||||
v !== "seedream"
|
||||
v !== "seedream" &&
|
||||
v !== "azure"
|
||||
) {
|
||||
throw new Error(`Invalid provider: ${v}`);
|
||||
}
|
||||
@@ -381,9 +395,11 @@ export function parseSimpleYaml(yaml: string): Partial<ExtendConfig> {
|
||||
openai: null,
|
||||
openrouter: null,
|
||||
dashscope: null,
|
||||
minimax: null,
|
||||
replicate: null,
|
||||
jimeng: null,
|
||||
seedream: null,
|
||||
azure: null,
|
||||
};
|
||||
currentKey = "default_model";
|
||||
currentProvider = null;
|
||||
@@ -407,9 +423,11 @@ export function parseSimpleYaml(yaml: string): Partial<ExtendConfig> {
|
||||
key === "openai" ||
|
||||
key === "openrouter" ||
|
||||
key === "dashscope" ||
|
||||
key === "minimax" ||
|
||||
key === "replicate" ||
|
||||
key === "jimeng" ||
|
||||
key === "seedream"
|
||||
key === "seedream" ||
|
||||
key === "azure"
|
||||
)
|
||||
) {
|
||||
config.batch ??= {};
|
||||
@@ -423,9 +441,11 @@ export function parseSimpleYaml(yaml: string): Partial<ExtendConfig> {
|
||||
key === "openai" ||
|
||||
key === "openrouter" ||
|
||||
key === "dashscope" ||
|
||||
key === "minimax" ||
|
||||
key === "replicate" ||
|
||||
key === "jimeng" ||
|
||||
key === "seedream"
|
||||
key === "seedream" ||
|
||||
key === "azure"
|
||||
)
|
||||
) {
|
||||
const cleaned = value.replace(/['"]/g, "");
|
||||
@@ -456,8 +476,8 @@ async function loadExtendConfig(): Promise<Partial<ExtendConfig>> {
|
||||
const cwd = process.cwd();
|
||||
|
||||
const paths = [
|
||||
path.join(cwd, ".baoyu-skills", "baoyu-image-gen", "EXTEND.md"),
|
||||
path.join(home, ".baoyu-skills", "baoyu-image-gen", "EXTEND.md"),
|
||||
path.join(cwd, ".baoyu-skills", "baoyu-imagine", "EXTEND.md"),
|
||||
path.join(home, ".baoyu-skills", "baoyu-imagine", "EXTEND.md"),
|
||||
];
|
||||
|
||||
for (const p of paths) {
|
||||
@@ -516,11 +536,13 @@ export function getConfiguredProviderRateLimits(
|
||||
openai: { ...DEFAULT_PROVIDER_RATE_LIMITS.openai },
|
||||
openrouter: { ...DEFAULT_PROVIDER_RATE_LIMITS.openrouter },
|
||||
dashscope: { ...DEFAULT_PROVIDER_RATE_LIMITS.dashscope },
|
||||
minimax: { ...DEFAULT_PROVIDER_RATE_LIMITS.minimax },
|
||||
jimeng: { ...DEFAULT_PROVIDER_RATE_LIMITS.jimeng },
|
||||
seedream: { ...DEFAULT_PROVIDER_RATE_LIMITS.seedream },
|
||||
azure: { ...DEFAULT_PROVIDER_RATE_LIMITS.azure },
|
||||
};
|
||||
|
||||
for (const provider of ["replicate", "google", "openai", "openrouter", "dashscope", "jimeng", "seedream"] as Provider[]) {
|
||||
for (const provider of ["replicate", "google", "openai", "openrouter", "dashscope", "minimax", "jimeng", "seedream", "azure"] as Provider[]) {
|
||||
const envPrefix = `BAOYU_IMAGE_GEN_${provider.toUpperCase()}`;
|
||||
const extendLimit = extendConfig.batch?.provider_limits?.[provider];
|
||||
configured[provider] = {
|
||||
@@ -560,11 +582,19 @@ async function readPromptFromStdin(): Promise<string | null> {
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeOutputImagePath(p: string): string {
|
||||
export function normalizeOutputImagePath(p: string, defaultExtension = ".png"): string {
|
||||
const full = path.resolve(p);
|
||||
const ext = path.extname(full);
|
||||
if (ext) return full;
|
||||
return `${full}.png`;
|
||||
return `${full}${defaultExtension}`;
|
||||
}
|
||||
|
||||
function inferProviderFromModel(model: string | null): Provider | null {
|
||||
if (!model) return null;
|
||||
const normalized = model.trim();
|
||||
if (normalized.includes("seedream") || normalized.includes("seededit")) return "seedream";
|
||||
if (normalized === "image-01" || normalized === "image-01-live") return "minimax";
|
||||
return null;
|
||||
}
|
||||
|
||||
export function detectProvider(args: CliArgs): Provider {
|
||||
@@ -573,39 +603,64 @@ export function detectProvider(args: CliArgs): Provider {
|
||||
args.provider &&
|
||||
args.provider !== "google" &&
|
||||
args.provider !== "openai" &&
|
||||
args.provider !== "azure" &&
|
||||
args.provider !== "openrouter" &&
|
||||
args.provider !== "replicate"
|
||||
args.provider !== "replicate" &&
|
||||
args.provider !== "seedream" &&
|
||||
args.provider !== "minimax"
|
||||
) {
|
||||
throw new Error(
|
||||
"Reference images require a ref-capable provider. Use --provider google (Gemini multimodal), --provider openai (GPT Image edits), --provider openrouter (OpenRouter multimodal), or --provider replicate."
|
||||
"Reference images require a ref-capable provider. Use --provider google (Gemini multimodal), --provider openai (GPT Image edits), --provider azure (Azure OpenAI), --provider openrouter (OpenRouter multimodal), --provider replicate, --provider seedream for supported Seedream models, or --provider minimax for MiniMax subject-reference workflows."
|
||||
);
|
||||
}
|
||||
|
||||
if (args.provider) return args.provider;
|
||||
|
||||
const hasGoogle = !!(process.env.GOOGLE_API_KEY || process.env.GEMINI_API_KEY);
|
||||
const hasAzure = !!(process.env.AZURE_OPENAI_API_KEY && process.env.AZURE_OPENAI_BASE_URL);
|
||||
const hasOpenai = !!process.env.OPENAI_API_KEY;
|
||||
const hasOpenrouter = !!process.env.OPENROUTER_API_KEY;
|
||||
const hasDashscope = !!process.env.DASHSCOPE_API_KEY;
|
||||
const hasMinimax = !!process.env.MINIMAX_API_KEY;
|
||||
const hasReplicate = !!process.env.REPLICATE_API_TOKEN;
|
||||
const hasJimeng = !!(process.env.JIMENG_ACCESS_KEY_ID && process.env.JIMENG_SECRET_ACCESS_KEY);
|
||||
const hasSeedream = !!process.env.ARK_API_KEY;
|
||||
const modelProvider = inferProviderFromModel(args.model);
|
||||
|
||||
if (modelProvider === "seedream") {
|
||||
if (!hasSeedream) {
|
||||
throw new Error("Model looks like a Volcengine ARK image model, but ARK_API_KEY is not set.");
|
||||
}
|
||||
return "seedream";
|
||||
}
|
||||
|
||||
if (modelProvider === "minimax") {
|
||||
if (!hasMinimax) {
|
||||
throw new Error("Model looks like a MiniMax image model, but MINIMAX_API_KEY is not set.");
|
||||
}
|
||||
return "minimax";
|
||||
}
|
||||
|
||||
if (args.referenceImages.length > 0) {
|
||||
if (hasGoogle) return "google";
|
||||
if (hasOpenai) return "openai";
|
||||
if (hasAzure) return "azure";
|
||||
if (hasOpenrouter) return "openrouter";
|
||||
if (hasReplicate) return "replicate";
|
||||
if (hasSeedream) return "seedream";
|
||||
if (hasMinimax) return "minimax";
|
||||
throw new Error(
|
||||
"Reference images require Google, OpenAI, OpenRouter, or Replicate. Set GOOGLE_API_KEY/GEMINI_API_KEY, OPENAI_API_KEY, OPENROUTER_API_KEY, or REPLICATE_API_TOKEN, or remove --ref."
|
||||
"Reference images require Google, OpenAI, Azure, OpenRouter, Replicate, supported Seedream models, or MiniMax. Set GOOGLE_API_KEY/GEMINI_API_KEY, OPENAI_API_KEY, AZURE_OPENAI_API_KEY+AZURE_OPENAI_BASE_URL, OPENROUTER_API_KEY, REPLICATE_API_TOKEN, ARK_API_KEY, or MINIMAX_API_KEY, or remove --ref."
|
||||
);
|
||||
}
|
||||
|
||||
const available = [
|
||||
hasGoogle && "google",
|
||||
hasOpenai && "openai",
|
||||
hasAzure && "azure",
|
||||
hasOpenrouter && "openrouter",
|
||||
hasDashscope && "dashscope",
|
||||
hasMinimax && "minimax",
|
||||
hasReplicate && "replicate",
|
||||
hasJimeng && "jimeng",
|
||||
hasSeedream && "seedream",
|
||||
@@ -615,7 +670,7 @@ export function detectProvider(args: CliArgs): Provider {
|
||||
if (available.length > 1) return available[0]!;
|
||||
|
||||
throw new Error(
|
||||
"No API key found. Set GOOGLE_API_KEY, GEMINI_API_KEY, OPENAI_API_KEY, OPENROUTER_API_KEY, DASHSCOPE_API_KEY, REPLICATE_API_TOKEN, JIMENG keys, or ARK_API_KEY.\n" +
|
||||
"No API key found. Set GOOGLE_API_KEY, GEMINI_API_KEY, OPENAI_API_KEY, AZURE_OPENAI_API_KEY+AZURE_OPENAI_BASE_URL, OPENROUTER_API_KEY, DASHSCOPE_API_KEY, MINIMAX_API_KEY, REPLICATE_API_TOKEN, JIMENG keys, or ARK_API_KEY.\n" +
|
||||
"Create ~/.baoyu-skills/.env or <cwd>/.baoyu-skills/.env with your keys."
|
||||
);
|
||||
}
|
||||
@@ -654,10 +709,12 @@ export function isRetryableGenerationError(error: unknown): boolean {
|
||||
async function loadProviderModule(provider: Provider): Promise<ProviderModule> {
|
||||
if (provider === "google") return (await import("./providers/google")) as ProviderModule;
|
||||
if (provider === "dashscope") return (await import("./providers/dashscope")) as ProviderModule;
|
||||
if (provider === "minimax") return (await import("./providers/minimax")) as ProviderModule;
|
||||
if (provider === "replicate") return (await import("./providers/replicate")) as ProviderModule;
|
||||
if (provider === "openrouter") return (await import("./providers/openrouter")) as ProviderModule;
|
||||
if (provider === "jimeng") return (await import("./providers/jimeng")) as ProviderModule;
|
||||
if (provider === "seedream") return (await import("./providers/seedream")) as ProviderModule;
|
||||
if (provider === "azure") return (await import("./providers/azure")) as ProviderModule;
|
||||
return (await import("./providers/openai")) as ProviderModule;
|
||||
}
|
||||
|
||||
@@ -683,9 +740,11 @@ function getModelForProvider(
|
||||
return extendConfig.default_model.openrouter;
|
||||
}
|
||||
if (provider === "dashscope" && extendConfig.default_model.dashscope) return extendConfig.default_model.dashscope;
|
||||
if (provider === "minimax" && extendConfig.default_model.minimax) return extendConfig.default_model.minimax;
|
||||
if (provider === "replicate" && extendConfig.default_model.replicate) return extendConfig.default_model.replicate;
|
||||
if (provider === "jimeng" && extendConfig.default_model.jimeng) return extendConfig.default_model.jimeng;
|
||||
if (provider === "seedream" && extendConfig.default_model.seedream) return extendConfig.default_model.seedream;
|
||||
if (provider === "azure" && extendConfig.default_model.azure) return extendConfig.default_model.azure;
|
||||
}
|
||||
return providerModule.getDefaultModel();
|
||||
}
|
||||
@@ -701,6 +760,8 @@ async function prepareSingleTask(args: CliArgs, extendConfig: Partial<ExtendConf
|
||||
const provider = detectProvider(args);
|
||||
const providerModule = await loadProviderModule(provider);
|
||||
const model = getModelForProvider(provider, args.model, extendConfig, providerModule);
|
||||
providerModule.validateArgs?.(model, args);
|
||||
const defaultOutputExtension = providerModule.getDefaultOutputExtension?.(model, args) ?? ".png";
|
||||
|
||||
return {
|
||||
id: "single",
|
||||
@@ -708,7 +769,7 @@ async function prepareSingleTask(args: CliArgs, extendConfig: Partial<ExtendConf
|
||||
args,
|
||||
provider,
|
||||
model,
|
||||
outputPath: normalizeOutputImagePath(args.imagePath),
|
||||
outputPath: normalizeOutputImagePath(args.imagePath, defaultOutputExtension),
|
||||
providerModule,
|
||||
};
|
||||
}
|
||||
@@ -784,13 +845,15 @@ async function prepareBatchTasks(
|
||||
const provider = detectProvider(taskArgs);
|
||||
const providerModule = await loadProviderModule(provider);
|
||||
const model = getModelForProvider(provider, taskArgs.model, extendConfig, providerModule);
|
||||
providerModule.validateArgs?.(model, taskArgs);
|
||||
const defaultOutputExtension = providerModule.getDefaultOutputExtension?.(model, taskArgs) ?? ".png";
|
||||
prepared.push({
|
||||
id: task.id || `task-${String(i + 1).padStart(2, "0")}`,
|
||||
prompt,
|
||||
args: taskArgs,
|
||||
provider,
|
||||
model,
|
||||
outputPath: normalizeOutputImagePath(taskArgs.imagePath),
|
||||
outputPath: normalizeOutputImagePath(taskArgs.imagePath, defaultOutputExtension),
|
||||
providerModule,
|
||||
});
|
||||
}
|
||||
@@ -901,7 +964,7 @@ async function runBatchTasks(
|
||||
const acquireProvider = createProviderGate(providerRateLimits);
|
||||
const workerCount = getWorkerCount(tasks.length, jobs, maxWorkers);
|
||||
console.error(`Batch mode: ${tasks.length} tasks, ${workerCount} workers, parallel mode enabled.`);
|
||||
for (const provider of ["replicate", "google", "openai", "openrouter", "dashscope", "jimeng", "seedream"] as Provider[]) {
|
||||
for (const provider of ["replicate", "google", "openai", "openrouter", "dashscope", "jimeng", "seedream", "azure"] as Provider[]) {
|
||||
const limit = providerRateLimits[provider];
|
||||
console.error(`- ${provider}: concurrency=${limit.concurrency}, startIntervalMs=${limit.startIntervalMs}`);
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test, { type TestContext } from "node:test";
|
||||
|
||||
import type { CliArgs } from "../types.ts";
|
||||
import {
|
||||
generateImage,
|
||||
getDefaultModel,
|
||||
parseAzureBaseURL,
|
||||
validateArgs,
|
||||
} from "./azure.ts";
|
||||
|
||||
function useEnv(
|
||||
t: TestContext,
|
||||
values: Record<string, string | null>,
|
||||
): void {
|
||||
const previous = new Map<string, string | undefined>();
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
previous.set(key, process.env[key]);
|
||||
if (value == null) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
t.after(() => {
|
||||
for (const [key, value] of previous.entries()) {
|
||||
if (value == null) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
return {
|
||||
prompt: null,
|
||||
promptFiles: [],
|
||||
imagePath: null,
|
||||
provider: null,
|
||||
model: null,
|
||||
aspectRatio: null,
|
||||
size: null,
|
||||
quality: null,
|
||||
imageSize: null,
|
||||
referenceImages: [],
|
||||
n: 1,
|
||||
batchFile: null,
|
||||
jobs: null,
|
||||
json: false,
|
||||
help: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function makeTempDir(prefix: string): Promise<string> {
|
||||
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
test("Azure endpoint parsing and default deployment selection follow env precedence", (t) => {
|
||||
assert.deepEqual(parseAzureBaseURL("https://example.openai.azure.com"), {
|
||||
resourceBaseURL: "https://example.openai.azure.com/openai",
|
||||
deployment: null,
|
||||
});
|
||||
assert.deepEqual(
|
||||
parseAzureBaseURL("https://example.openai.azure.com/openai/deployments/from-url"),
|
||||
{
|
||||
resourceBaseURL: "https://example.openai.azure.com/openai",
|
||||
deployment: "from-url",
|
||||
},
|
||||
);
|
||||
|
||||
useEnv(t, {
|
||||
AZURE_OPENAI_BASE_URL: "https://example.openai.azure.com/openai/deployments/from-url",
|
||||
AZURE_OPENAI_DEPLOYMENT: "explicit-deploy",
|
||||
AZURE_OPENAI_IMAGE_MODEL: "env-fallback",
|
||||
});
|
||||
assert.equal(getDefaultModel(), "explicit-deploy");
|
||||
});
|
||||
|
||||
test("Azure validateArgs rejects unsupported edit input formats before the API call", () => {
|
||||
assert.doesNotThrow(() =>
|
||||
validateArgs("demo-deployment", makeArgs({ referenceImages: ["hero.png", "photo.jpeg"] })),
|
||||
);
|
||||
assert.throws(
|
||||
() => validateArgs("demo-deployment", makeArgs({ referenceImages: ["hero.webp"] })),
|
||||
/PNG or JPG\/JPEG/,
|
||||
);
|
||||
});
|
||||
|
||||
test("Azure image generation routes model to deployment and sends mapped quality", async (t) => {
|
||||
useEnv(t, {
|
||||
AZURE_OPENAI_API_KEY: "azure-key",
|
||||
AZURE_OPENAI_BASE_URL: "https://example.openai.azure.com/openai/deployments/default-deploy",
|
||||
AZURE_API_VERSION: null,
|
||||
AZURE_OPENAI_DEPLOYMENT: null,
|
||||
AZURE_OPENAI_IMAGE_MODEL: null,
|
||||
});
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
t.after(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
const calls: Array<{ url: string; body: string }> = [];
|
||||
globalThis.fetch = async (input, init) => {
|
||||
calls.push({
|
||||
url: String(input),
|
||||
body: String(init?.body ?? ""),
|
||||
});
|
||||
return Response.json({
|
||||
data: [{ b64_json: Buffer.from("azure-image").toString("base64") }],
|
||||
});
|
||||
};
|
||||
|
||||
const bytes = await generateImage(
|
||||
"A calm lake at sunset",
|
||||
"custom-deploy",
|
||||
makeArgs({ quality: "normal" }),
|
||||
);
|
||||
|
||||
assert.equal(Buffer.from(bytes).toString("utf8"), "azure-image");
|
||||
assert.equal(
|
||||
calls[0]?.url,
|
||||
"https://example.openai.azure.com/openai/deployments/custom-deploy/images/generations?api-version=2025-04-01-preview",
|
||||
);
|
||||
|
||||
const body = JSON.parse(calls[0]!.body) as Record<string, string>;
|
||||
assert.equal(body.quality, "medium");
|
||||
assert.equal(body.size, "1024x1024");
|
||||
});
|
||||
|
||||
test("Azure image edits include quality in multipart requests", async (t) => {
|
||||
const root = await makeTempDir("baoyu-imagine-azure-");
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
|
||||
const pngPath = path.join(root, "ref.png");
|
||||
const jpgPath = path.join(root, "ref.jpg");
|
||||
await fs.writeFile(pngPath, "png-bytes");
|
||||
await fs.writeFile(jpgPath, "jpg-bytes");
|
||||
|
||||
useEnv(t, {
|
||||
AZURE_OPENAI_API_KEY: "azure-key",
|
||||
AZURE_OPENAI_BASE_URL: "https://example.openai.azure.com",
|
||||
AZURE_API_VERSION: "2025-04-01-preview",
|
||||
AZURE_OPENAI_DEPLOYMENT: null,
|
||||
AZURE_OPENAI_IMAGE_MODEL: null,
|
||||
});
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
t.after(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
const calls: Array<{ url: string; form: FormData }> = [];
|
||||
globalThis.fetch = async (input, init) => {
|
||||
calls.push({
|
||||
url: String(input),
|
||||
form: init?.body as FormData,
|
||||
});
|
||||
return Response.json({
|
||||
data: [{ b64_json: Buffer.from("edited-image").toString("base64") }],
|
||||
});
|
||||
};
|
||||
|
||||
const bytes = await generateImage(
|
||||
"Add warm lighting",
|
||||
"edit-deploy",
|
||||
makeArgs({
|
||||
quality: "2k",
|
||||
referenceImages: [pngPath, jpgPath],
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(Buffer.from(bytes).toString("utf8"), "edited-image");
|
||||
assert.equal(
|
||||
calls[0]?.url,
|
||||
"https://example.openai.azure.com/openai/deployments/edit-deploy/images/edits?api-version=2025-04-01-preview",
|
||||
);
|
||||
assert.equal(calls[0]?.form.get("quality"), "high");
|
||||
assert.equal(calls[0]?.form.get("size"), "1024x1024");
|
||||
assert.equal(calls[0]?.form.getAll("image[]").length, 2);
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
import path from "node:path";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import type { CliArgs } from "../types";
|
||||
import { getOpenAISize, extractImageFromResponse } from "./openai.ts";
|
||||
|
||||
type OpenAIImageResponse = { data: Array<{ url?: string; b64_json?: string }> };
|
||||
type AzureEndpoint = {
|
||||
resourceBaseURL: string;
|
||||
deployment: string | null;
|
||||
};
|
||||
|
||||
const DEFAULT_AZURE_API_VERSION = "2025-04-01-preview";
|
||||
const AZURE_EDIT_IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg"]);
|
||||
|
||||
export function parseAzureBaseURL(url: string): AzureEndpoint {
|
||||
const parsed = new URL(url);
|
||||
const trimmedPath = parsed.pathname.replace(/\/+$/, "");
|
||||
const deploymentMatch = trimmedPath.match(/^(.*?)(?:\/openai)?\/deployments\/([^/]+)$/);
|
||||
|
||||
if (deploymentMatch) {
|
||||
parsed.pathname = `${deploymentMatch[1] || ""}/openai`;
|
||||
return {
|
||||
resourceBaseURL: parsed.toString().replace(/\/+$/, ""),
|
||||
deployment: decodeURIComponent(deploymentMatch[2]!),
|
||||
};
|
||||
}
|
||||
|
||||
parsed.pathname = trimmedPath.endsWith("/openai") ? trimmedPath : `${trimmedPath}/openai`;
|
||||
return {
|
||||
resourceBaseURL: parsed.toString().replace(/\/+$/, ""),
|
||||
deployment: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function getDefaultModel(): string {
|
||||
const explicitDeployment = process.env.AZURE_OPENAI_DEPLOYMENT?.trim();
|
||||
if (explicitDeployment) return explicitDeployment;
|
||||
|
||||
const baseURL = process.env.AZURE_OPENAI_BASE_URL;
|
||||
if (baseURL) {
|
||||
try {
|
||||
const { deployment } = parseAzureBaseURL(baseURL);
|
||||
if (deployment) return deployment;
|
||||
} catch {
|
||||
// Ignore invalid URLs here so the required-env check can raise the user-facing error later.
|
||||
}
|
||||
}
|
||||
|
||||
return process.env.AZURE_OPENAI_IMAGE_MODEL || "gpt-image-1.5";
|
||||
}
|
||||
|
||||
function getEndpoint(): AzureEndpoint {
|
||||
const url = process.env.AZURE_OPENAI_BASE_URL;
|
||||
if (!url) {
|
||||
throw new Error(
|
||||
"AZURE_OPENAI_BASE_URL is required. Set it to your Azure resource or deployment endpoint, e.g.: https://your-resource.openai.azure.com or https://your-resource.openai.azure.com/openai/deployments/your-deployment"
|
||||
);
|
||||
}
|
||||
return parseAzureBaseURL(url);
|
||||
}
|
||||
|
||||
function getApiKey(): string {
|
||||
const key = process.env.AZURE_OPENAI_API_KEY;
|
||||
if (!key) {
|
||||
throw new Error(
|
||||
"AZURE_OPENAI_API_KEY is required. Get it from Azure Portal → your OpenAI resource → Keys and Endpoint."
|
||||
);
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
function getApiVersion(): string {
|
||||
return process.env.AZURE_API_VERSION || DEFAULT_AZURE_API_VERSION;
|
||||
}
|
||||
|
||||
function getDeployment(model: string): string {
|
||||
const deployment = model.trim();
|
||||
if (!deployment) {
|
||||
throw new Error(
|
||||
"Azure deployment name is required. Use --model <deployment>, AZURE_OPENAI_DEPLOYMENT, AZURE_OPENAI_IMAGE_MODEL, or embed the deployment in AZURE_OPENAI_BASE_URL."
|
||||
);
|
||||
}
|
||||
return deployment;
|
||||
}
|
||||
|
||||
function buildURL(deployment: string, pathSuffix: string): string {
|
||||
const { resourceBaseURL } = getEndpoint();
|
||||
return `${resourceBaseURL}/deployments/${encodeURIComponent(deployment)}${pathSuffix}?api-version=${getApiVersion()}`;
|
||||
}
|
||||
|
||||
function authHeaders(): Record<string, string> {
|
||||
return { "api-key": getApiKey() };
|
||||
}
|
||||
|
||||
function getAzureQuality(quality: CliArgs["quality"]): "medium" | "high" {
|
||||
return quality === "2k" ? "high" : "medium";
|
||||
}
|
||||
|
||||
export function validateArgs(_model: string, args: CliArgs): void {
|
||||
for (const refPath of args.referenceImages) {
|
||||
const ext = path.extname(refPath).toLowerCase();
|
||||
if (!AZURE_EDIT_IMAGE_EXTENSIONS.has(ext)) {
|
||||
throw new Error(
|
||||
`Azure OpenAI reference images must be PNG or JPG/JPEG. Unsupported file: ${refPath}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateImage(
|
||||
prompt: string,
|
||||
model: string,
|
||||
args: CliArgs
|
||||
): Promise<Uint8Array> {
|
||||
const deployment = getDeployment(model);
|
||||
const size = args.size || getOpenAISize(model, args.aspectRatio, args.quality);
|
||||
|
||||
if (args.referenceImages.length > 0) {
|
||||
return generateWithAzureEdits(prompt, deployment, size, args.referenceImages, args.quality);
|
||||
}
|
||||
|
||||
return generateWithAzureGenerations(prompt, deployment, size, args.quality);
|
||||
}
|
||||
|
||||
async function generateWithAzureGenerations(
|
||||
prompt: string,
|
||||
deployment: string,
|
||||
size: string,
|
||||
quality: CliArgs["quality"]
|
||||
): Promise<Uint8Array> {
|
||||
const body: Record<string, any> = {
|
||||
prompt,
|
||||
size,
|
||||
n: 1,
|
||||
quality: getAzureQuality(quality),
|
||||
};
|
||||
|
||||
const res = await fetch(buildURL(deployment, "/images/generations"), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...authHeaders(),
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
throw new Error(`Azure OpenAI API error: ${err}`);
|
||||
}
|
||||
|
||||
const result = (await res.json()) as OpenAIImageResponse;
|
||||
return extractImageFromResponse(result);
|
||||
}
|
||||
|
||||
async function generateWithAzureEdits(
|
||||
prompt: string,
|
||||
deployment: string,
|
||||
size: string,
|
||||
referenceImages: string[],
|
||||
quality: CliArgs["quality"]
|
||||
): Promise<Uint8Array> {
|
||||
const form = new FormData();
|
||||
form.append("prompt", prompt);
|
||||
form.append("size", size);
|
||||
form.append("n", "1");
|
||||
form.append("quality", getAzureQuality(quality));
|
||||
|
||||
for (const refPath of referenceImages) {
|
||||
const bytes = await readFile(refPath);
|
||||
const filename = path.basename(refPath);
|
||||
const mimeType = path.extname(filename).toLowerCase() === ".png" ? "image/png" : "image/jpeg";
|
||||
const blob = new Blob([bytes], { type: mimeType });
|
||||
form.append("image[]", blob, filename);
|
||||
}
|
||||
|
||||
const res = await fetch(buildURL(deployment, "/images/edits"), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
...authHeaders(),
|
||||
},
|
||||
body: form,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
throw new Error(`Azure OpenAI edits API error: ${err}`);
|
||||
}
|
||||
|
||||
const result = (await res.json()) as OpenAIImageResponse;
|
||||
return extractImageFromResponse(result);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test, { type TestContext } from "node:test";
|
||||
|
||||
import {
|
||||
getDefaultModel,
|
||||
getModelFamily,
|
||||
getQwen2SizeFromAspectRatio,
|
||||
getSizeFromAspectRatio,
|
||||
normalizeSize,
|
||||
parseAspectRatio,
|
||||
parseSize,
|
||||
resolveSizeForModel,
|
||||
} from "./dashscope.ts";
|
||||
|
||||
function useEnv(
|
||||
t: TestContext,
|
||||
values: Record<string, string | null>,
|
||||
): void {
|
||||
const previous = new Map<string, string | undefined>();
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
previous.set(key, process.env[key]);
|
||||
if (value == null) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
t.after(() => {
|
||||
for (const [key, value] of previous.entries()) {
|
||||
if (value == null) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("DashScope default model prefers env override and otherwise uses qwen-image-2.0-pro", (t) => {
|
||||
useEnv(t, { DASHSCOPE_IMAGE_MODEL: null });
|
||||
assert.equal(getDefaultModel(), "qwen-image-2.0-pro");
|
||||
|
||||
process.env.DASHSCOPE_IMAGE_MODEL = "qwen-image-max";
|
||||
assert.equal(getDefaultModel(), "qwen-image-max");
|
||||
});
|
||||
|
||||
test("DashScope aspect-ratio parsing accepts numeric ratios only", () => {
|
||||
assert.deepEqual(parseAspectRatio("3:2"), { width: 3, height: 2 });
|
||||
assert.equal(parseAspectRatio("square"), null);
|
||||
assert.equal(parseAspectRatio("-1:2"), null);
|
||||
});
|
||||
|
||||
test("DashScope model family routing distinguishes qwen-2.0, fixed-size qwen, and legacy models", () => {
|
||||
assert.equal(getModelFamily("qwen-image-2.0-pro"), "qwen2");
|
||||
assert.equal(getModelFamily("qwen-image"), "qwenFixed");
|
||||
assert.equal(getModelFamily("z-image-turbo"), "legacy");
|
||||
assert.equal(getModelFamily("wanx-v1"), "legacy");
|
||||
});
|
||||
|
||||
test("Legacy DashScope size selection keeps the previous quality-based heuristic", () => {
|
||||
assert.equal(getSizeFromAspectRatio(null, "normal"), "1024*1024");
|
||||
assert.equal(getSizeFromAspectRatio("16:9", "normal"), "1280*720");
|
||||
assert.equal(getSizeFromAspectRatio("16:9", "2k"), "2048*1152");
|
||||
assert.equal(getSizeFromAspectRatio("invalid", "2k"), "1536*1536");
|
||||
});
|
||||
|
||||
test("Qwen 2.0 recommended sizes follow the official common-ratio table", () => {
|
||||
assert.equal(getQwen2SizeFromAspectRatio(null, "normal"), "1024*1024");
|
||||
assert.equal(getQwen2SizeFromAspectRatio(null, "2k"), "1536*1536");
|
||||
assert.equal(getQwen2SizeFromAspectRatio("16:9", "normal"), "1280*720");
|
||||
assert.equal(getQwen2SizeFromAspectRatio("21:9", "2k"), "2048*872");
|
||||
});
|
||||
|
||||
test("Qwen 2.0 derives free-form sizes within pixel budget for uncommon ratios", () => {
|
||||
const size = getQwen2SizeFromAspectRatio("5:2", "normal");
|
||||
const parsed = parseSize(size);
|
||||
assert.ok(parsed);
|
||||
assert.ok(parsed.width * parsed.height >= 512 * 512);
|
||||
assert.ok(parsed.width * parsed.height <= 2048 * 2048);
|
||||
assert.ok(Math.abs(parsed.width / parsed.height - 2.5) < 0.08);
|
||||
});
|
||||
|
||||
test("resolveSizeForModel validates explicit qwen-image-2.0 sizes by total pixels", () => {
|
||||
assert.equal(
|
||||
resolveSizeForModel("qwen-image-2.0-pro", {
|
||||
size: "2048x872",
|
||||
aspectRatio: null,
|
||||
quality: "2k",
|
||||
}),
|
||||
"2048*872",
|
||||
);
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
resolveSizeForModel("qwen-image-2.0-pro", {
|
||||
size: "4096x4096",
|
||||
aspectRatio: null,
|
||||
quality: "2k",
|
||||
}),
|
||||
/total pixels between/,
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveSizeForModel enforces fixed sizes for qwen-image-max/plus/image", () => {
|
||||
assert.equal(
|
||||
resolveSizeForModel("qwen-image-max", {
|
||||
size: null,
|
||||
aspectRatio: "1:1",
|
||||
quality: "2k",
|
||||
}),
|
||||
"1328*1328",
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
resolveSizeForModel("qwen-image", {
|
||||
size: "1664x928",
|
||||
aspectRatio: "9:16",
|
||||
quality: "normal",
|
||||
}),
|
||||
"1664*928",
|
||||
);
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
resolveSizeForModel("qwen-image-max", {
|
||||
size: null,
|
||||
aspectRatio: "21:9",
|
||||
quality: "2k",
|
||||
}),
|
||||
/supports only fixed ratios/,
|
||||
);
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
resolveSizeForModel("qwen-image-plus", {
|
||||
size: "1024x1024",
|
||||
aspectRatio: null,
|
||||
quality: "2k",
|
||||
}),
|
||||
/support only these sizes/,
|
||||
);
|
||||
});
|
||||
|
||||
test("DashScope size normalization converts WxH into provider format", () => {
|
||||
assert.equal(normalizeSize("1024x1024"), "1024*1024");
|
||||
assert.equal(normalizeSize("2048*1152"), "2048*1152");
|
||||
});
|
||||
@@ -0,0 +1,463 @@
|
||||
import type { CliArgs, Quality } from "../types";
|
||||
|
||||
type DashScopeModelFamily = "qwen2" | "qwenFixed" | "legacy";
|
||||
|
||||
type DashScopeModelSpec = {
|
||||
family: DashScopeModelFamily;
|
||||
defaultSize: string;
|
||||
};
|
||||
|
||||
const DEFAULT_MODEL = "qwen-image-2.0-pro";
|
||||
const MIN_QWEN_2_TOTAL_PIXELS = 512 * 512;
|
||||
const MAX_QWEN_2_TOTAL_PIXELS = 2048 * 2048;
|
||||
const SIZE_STEP = 16;
|
||||
const QWEN_NEGATIVE_PROMPT =
|
||||
"低分辨率,低画质,肢体畸形,手指畸形,画面过饱和,蜡像感,人脸无细节,过度光滑,画面具有AI感,构图混乱,文字模糊,扭曲";
|
||||
|
||||
const QWEN_2_TARGET_PIXELS: Record<Quality, number> = {
|
||||
normal: 1024 * 1024,
|
||||
"2k": 1536 * 1536,
|
||||
};
|
||||
|
||||
const QWEN_2_RECOMMENDED: Record<string, Record<Quality, string>> = {
|
||||
"1:1": { normal: "1024*1024", "2k": "1536*1536" },
|
||||
"2:3": { normal: "768*1152", "2k": "1024*1536" },
|
||||
"3:2": { normal: "1152*768", "2k": "1536*1024" },
|
||||
"3:4": { normal: "960*1280", "2k": "1080*1440" },
|
||||
"4:3": { normal: "1280*960", "2k": "1440*1080" },
|
||||
"9:16": { normal: "720*1280", "2k": "1080*1920" },
|
||||
"16:9": { normal: "1280*720", "2k": "1920*1080" },
|
||||
"21:9": { normal: "1344*576", "2k": "2048*872" },
|
||||
};
|
||||
|
||||
const QWEN_FIXED_SIZES_BY_RATIO: Record<string, string> = {
|
||||
"16:9": "1664*928",
|
||||
"4:3": "1472*1104",
|
||||
"1:1": "1328*1328",
|
||||
"3:4": "1104*1472",
|
||||
"9:16": "928*1664",
|
||||
};
|
||||
|
||||
const QWEN_FIXED_SIZES = Object.values(QWEN_FIXED_SIZES_BY_RATIO);
|
||||
|
||||
const LEGACY_STANDARD_SIZES: [number, number][] = [
|
||||
[1024, 1024],
|
||||
[1280, 720],
|
||||
[720, 1280],
|
||||
[1024, 768],
|
||||
[768, 1024],
|
||||
[1536, 1024],
|
||||
[1024, 1536],
|
||||
[1536, 864],
|
||||
[864, 1536],
|
||||
];
|
||||
|
||||
const LEGACY_STANDARD_SIZES_2K: [number, number][] = [
|
||||
[1536, 1536],
|
||||
[2048, 1152],
|
||||
[1152, 2048],
|
||||
[1536, 1024],
|
||||
[1024, 1536],
|
||||
[1536, 864],
|
||||
[864, 1536],
|
||||
[2048, 2048],
|
||||
];
|
||||
|
||||
const QWEN_2_SPEC: DashScopeModelSpec = {
|
||||
family: "qwen2",
|
||||
defaultSize: "1024*1024",
|
||||
};
|
||||
|
||||
const QWEN_FIXED_SPEC: DashScopeModelSpec = {
|
||||
family: "qwenFixed",
|
||||
defaultSize: QWEN_FIXED_SIZES_BY_RATIO["16:9"],
|
||||
};
|
||||
|
||||
const LEGACY_SPEC: DashScopeModelSpec = {
|
||||
family: "legacy",
|
||||
defaultSize: "1536*1536",
|
||||
};
|
||||
|
||||
const MODEL_SPEC_ALIASES: Record<string, DashScopeModelSpec> = {
|
||||
"qwen-image-2.0-pro": QWEN_2_SPEC,
|
||||
"qwen-image-2.0-pro-2026-03-03": QWEN_2_SPEC,
|
||||
"qwen-image-2.0": QWEN_2_SPEC,
|
||||
"qwen-image-2.0-2026-03-03": QWEN_2_SPEC,
|
||||
"qwen-image-max": QWEN_FIXED_SPEC,
|
||||
"qwen-image-max-2025-12-30": QWEN_FIXED_SPEC,
|
||||
"qwen-image-plus": QWEN_FIXED_SPEC,
|
||||
"qwen-image-plus-2026-01-09": QWEN_FIXED_SPEC,
|
||||
"qwen-image": QWEN_FIXED_SPEC,
|
||||
};
|
||||
|
||||
export function getDefaultModel(): string {
|
||||
return process.env.DASHSCOPE_IMAGE_MODEL || DEFAULT_MODEL;
|
||||
}
|
||||
|
||||
function getApiKey(): string | null {
|
||||
return process.env.DASHSCOPE_API_KEY || null;
|
||||
}
|
||||
|
||||
function getBaseUrl(): string {
|
||||
const base = process.env.DASHSCOPE_BASE_URL || "https://dashscope.aliyuncs.com";
|
||||
return base.replace(/\/+$/g, "");
|
||||
}
|
||||
|
||||
function getModelSpec(model: string): DashScopeModelSpec {
|
||||
return MODEL_SPEC_ALIASES[model.trim().toLowerCase()] || LEGACY_SPEC;
|
||||
}
|
||||
|
||||
export function getModelFamily(model: string): DashScopeModelFamily {
|
||||
return getModelSpec(model).family;
|
||||
}
|
||||
|
||||
function normalizeQuality(quality: CliArgs["quality"]): Quality {
|
||||
return quality === "normal" ? "normal" : "2k";
|
||||
}
|
||||
|
||||
export function parseAspectRatio(ar: string): { width: number; height: number } | null {
|
||||
const match = ar.match(/^(\d+(?:\.\d+)?):(\d+(?:\.\d+)?)$/);
|
||||
if (!match) return null;
|
||||
const w = parseFloat(match[1]!);
|
||||
const h = parseFloat(match[2]!);
|
||||
if (w <= 0 || h <= 0) return null;
|
||||
return { width: w, height: h };
|
||||
}
|
||||
|
||||
export function normalizeSize(size: string): string {
|
||||
return size.replace("x", "*");
|
||||
}
|
||||
|
||||
export function parseSize(size: string): { width: number; height: number } | null {
|
||||
const match = normalizeSize(size).match(/^(\d+)\*(\d+)$/);
|
||||
if (!match) return null;
|
||||
const width = Number(match[1]);
|
||||
const height = Number(match[2]);
|
||||
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
|
||||
return null;
|
||||
}
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
function formatSize(width: number, height: number): string {
|
||||
return `${width}*${height}`;
|
||||
}
|
||||
|
||||
function getRatioValue(ar: string): number | null {
|
||||
const parsed = parseAspectRatio(ar);
|
||||
if (!parsed) return null;
|
||||
return parsed.width / parsed.height;
|
||||
}
|
||||
|
||||
function findKnownRatioKey(ar: string, candidates: string[], tolerance = 0.02): string | null {
|
||||
const targetRatio = getRatioValue(ar);
|
||||
if (targetRatio == null) return null;
|
||||
|
||||
let bestKey: string | null = null;
|
||||
let bestDiff = Infinity;
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const candidateRatio = getRatioValue(candidate);
|
||||
if (candidateRatio == null) continue;
|
||||
const diff = Math.abs(candidateRatio - targetRatio);
|
||||
if (diff < bestDiff) {
|
||||
bestDiff = diff;
|
||||
bestKey = candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return bestDiff <= tolerance ? bestKey : null;
|
||||
}
|
||||
|
||||
function roundToStep(value: number): number {
|
||||
return Math.max(SIZE_STEP, Math.round(value / SIZE_STEP) * SIZE_STEP);
|
||||
}
|
||||
|
||||
function fitToPixelBudget(
|
||||
width: number,
|
||||
height: number,
|
||||
minPixels: number,
|
||||
maxPixels: number,
|
||||
): { width: number; height: number } {
|
||||
let nextWidth = width;
|
||||
let nextHeight = height;
|
||||
let pixels = nextWidth * nextHeight;
|
||||
|
||||
if (pixels > maxPixels) {
|
||||
const scale = Math.sqrt(maxPixels / pixels);
|
||||
nextWidth *= scale;
|
||||
nextHeight *= scale;
|
||||
} else if (pixels < minPixels) {
|
||||
const scale = Math.sqrt(minPixels / pixels);
|
||||
nextWidth *= scale;
|
||||
nextHeight *= scale;
|
||||
}
|
||||
|
||||
let roundedWidth = roundToStep(nextWidth);
|
||||
let roundedHeight = roundToStep(nextHeight);
|
||||
pixels = roundedWidth * roundedHeight;
|
||||
|
||||
while (pixels > maxPixels && (roundedWidth > SIZE_STEP || roundedHeight > SIZE_STEP)) {
|
||||
if (roundedWidth >= roundedHeight && roundedWidth > SIZE_STEP) {
|
||||
roundedWidth -= SIZE_STEP;
|
||||
} else if (roundedHeight > SIZE_STEP) {
|
||||
roundedHeight -= SIZE_STEP;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
pixels = roundedWidth * roundedHeight;
|
||||
}
|
||||
|
||||
while (pixels < minPixels) {
|
||||
if (roundedWidth <= roundedHeight) {
|
||||
roundedWidth += SIZE_STEP;
|
||||
} else {
|
||||
roundedHeight += SIZE_STEP;
|
||||
}
|
||||
pixels = roundedWidth * roundedHeight;
|
||||
}
|
||||
|
||||
return { width: roundedWidth, height: roundedHeight };
|
||||
}
|
||||
|
||||
export function getSizeFromAspectRatio(ar: string | null, quality: CliArgs["quality"]): string {
|
||||
const normalizedQuality = normalizeQuality(quality);
|
||||
const sizes = normalizedQuality === "2k" ? LEGACY_STANDARD_SIZES_2K : LEGACY_STANDARD_SIZES;
|
||||
const defaultSize = normalizedQuality === "2k" ? "1536*1536" : "1024*1024";
|
||||
|
||||
if (!ar) return defaultSize;
|
||||
|
||||
const parsed = parseAspectRatio(ar);
|
||||
if (!parsed) return defaultSize;
|
||||
|
||||
const targetRatio = parsed.width / parsed.height;
|
||||
let best = defaultSize;
|
||||
let bestDiff = Infinity;
|
||||
|
||||
for (const [width, height] of sizes) {
|
||||
const diff = Math.abs(width / height - targetRatio);
|
||||
if (diff < bestDiff) {
|
||||
bestDiff = diff;
|
||||
best = formatSize(width, height);
|
||||
}
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
export function getQwen2SizeFromAspectRatio(ar: string | null, quality: CliArgs["quality"]): string {
|
||||
const normalizedQuality = normalizeQuality(quality);
|
||||
|
||||
if (!ar) {
|
||||
return QWEN_2_RECOMMENDED["1:1"][normalizedQuality];
|
||||
}
|
||||
|
||||
const recommendedRatio = findKnownRatioKey(ar, Object.keys(QWEN_2_RECOMMENDED));
|
||||
if (recommendedRatio) {
|
||||
return QWEN_2_RECOMMENDED[recommendedRatio][normalizedQuality];
|
||||
}
|
||||
|
||||
const parsed = parseAspectRatio(ar);
|
||||
if (!parsed) {
|
||||
return QWEN_2_RECOMMENDED["1:1"][normalizedQuality];
|
||||
}
|
||||
|
||||
const targetRatio = parsed.width / parsed.height;
|
||||
const targetPixels = QWEN_2_TARGET_PIXELS[normalizedQuality];
|
||||
const rawWidth = Math.sqrt(targetPixels * targetRatio);
|
||||
const rawHeight = Math.sqrt(targetPixels / targetRatio);
|
||||
const fitted = fitToPixelBudget(
|
||||
rawWidth,
|
||||
rawHeight,
|
||||
MIN_QWEN_2_TOTAL_PIXELS,
|
||||
MAX_QWEN_2_TOTAL_PIXELS,
|
||||
);
|
||||
|
||||
return formatSize(fitted.width, fitted.height);
|
||||
}
|
||||
|
||||
function getQwenFixedSizeFromAspectRatio(ar: string | null, quality: CliArgs["quality"]): string {
|
||||
if (quality === "normal") {
|
||||
console.warn(
|
||||
"DashScope qwen-image-max/plus/image models use fixed output sizes; --quality normal does not change the generated resolution."
|
||||
);
|
||||
}
|
||||
|
||||
if (!ar) return QWEN_FIXED_SPEC.defaultSize;
|
||||
|
||||
const ratioKey = findKnownRatioKey(ar, Object.keys(QWEN_FIXED_SIZES_BY_RATIO));
|
||||
if (!ratioKey) {
|
||||
throw new Error(
|
||||
`DashScope model supports only fixed ratios ${Object.keys(QWEN_FIXED_SIZES_BY_RATIO).join(", ")}. ` +
|
||||
`For custom ratios like "${ar}", use --model qwen-image-2.0-pro.`
|
||||
);
|
||||
}
|
||||
|
||||
return QWEN_FIXED_SIZES_BY_RATIO[ratioKey]!;
|
||||
}
|
||||
|
||||
function validateSizeFormat(size: string): { width: number; height: number } {
|
||||
const parsed = parseSize(size);
|
||||
if (!parsed) {
|
||||
throw new Error(`Invalid DashScope size "${size}". Expected <width>x<height> or <width>*<height>.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function validateQwen2Size(size: string): string {
|
||||
const normalized = normalizeSize(size);
|
||||
const parsed = validateSizeFormat(normalized);
|
||||
const totalPixels = parsed.width * parsed.height;
|
||||
if (totalPixels < MIN_QWEN_2_TOTAL_PIXELS || totalPixels > MAX_QWEN_2_TOTAL_PIXELS) {
|
||||
throw new Error(
|
||||
`DashScope qwen-image-2.0* models require total pixels between ${MIN_QWEN_2_TOTAL_PIXELS} ` +
|
||||
`and ${MAX_QWEN_2_TOTAL_PIXELS}. Received ${normalized} (${totalPixels} pixels).`
|
||||
);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function validateQwenFixedSize(size: string): string {
|
||||
const normalized = normalizeSize(size);
|
||||
validateSizeFormat(normalized);
|
||||
if (!QWEN_FIXED_SIZES.includes(normalized)) {
|
||||
throw new Error(
|
||||
`DashScope qwen-image-max/plus/image models support only these sizes: ${QWEN_FIXED_SIZES.join(", ")}. ` +
|
||||
`Received ${normalized}.`
|
||||
);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function resolveSizeForModel(
|
||||
model: string,
|
||||
args: Pick<CliArgs, "size" | "aspectRatio" | "quality">,
|
||||
): string {
|
||||
const spec = getModelSpec(model);
|
||||
|
||||
if (args.size) {
|
||||
if (spec.family === "qwen2") return validateQwen2Size(args.size);
|
||||
if (spec.family === "qwenFixed") return validateQwenFixedSize(args.size);
|
||||
validateSizeFormat(args.size);
|
||||
return normalizeSize(args.size);
|
||||
}
|
||||
|
||||
if (spec.family === "qwen2") {
|
||||
return getQwen2SizeFromAspectRatio(args.aspectRatio, args.quality);
|
||||
}
|
||||
|
||||
if (spec.family === "qwenFixed") {
|
||||
return getQwenFixedSizeFromAspectRatio(args.aspectRatio, args.quality);
|
||||
}
|
||||
|
||||
return getSizeFromAspectRatio(args.aspectRatio, args.quality);
|
||||
}
|
||||
|
||||
function buildParameters(
|
||||
family: DashScopeModelFamily,
|
||||
size: string,
|
||||
): Record<string, unknown> {
|
||||
const parameters: Record<string, unknown> = {
|
||||
prompt_extend: false,
|
||||
size,
|
||||
};
|
||||
|
||||
if (family === "qwen2" || family === "qwenFixed") {
|
||||
parameters.watermark = false;
|
||||
parameters.negative_prompt = QWEN_NEGATIVE_PROMPT;
|
||||
}
|
||||
|
||||
return parameters;
|
||||
}
|
||||
|
||||
type DashScopeResponse = {
|
||||
output?: {
|
||||
result_image?: string;
|
||||
choices?: Array<{
|
||||
message?: {
|
||||
content?: Array<{ image?: string }>;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
async function extractImageFromResponse(result: DashScopeResponse): Promise<Uint8Array> {
|
||||
let imageData: string | null = null;
|
||||
|
||||
if (result.output?.result_image) {
|
||||
imageData = result.output.result_image;
|
||||
} else if (result.output?.choices?.[0]?.message?.content) {
|
||||
const content = result.output.choices[0].message.content;
|
||||
for (const item of content) {
|
||||
if (item.image) {
|
||||
imageData = item.image;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!imageData) {
|
||||
console.error("Response:", JSON.stringify(result, null, 2));
|
||||
throw new Error("No image in response");
|
||||
}
|
||||
|
||||
if (imageData.startsWith("http://") || imageData.startsWith("https://")) {
|
||||
const imgRes = await fetch(imageData);
|
||||
if (!imgRes.ok) throw new Error("Failed to download image");
|
||||
const buf = await imgRes.arrayBuffer();
|
||||
return new Uint8Array(buf);
|
||||
}
|
||||
|
||||
return Uint8Array.from(Buffer.from(imageData, "base64"));
|
||||
}
|
||||
|
||||
export async function generateImage(
|
||||
prompt: string,
|
||||
model: string,
|
||||
args: CliArgs
|
||||
): Promise<Uint8Array> {
|
||||
const apiKey = getApiKey();
|
||||
if (!apiKey) throw new Error("DASHSCOPE_API_KEY is required");
|
||||
|
||||
if (args.referenceImages.length > 0) {
|
||||
throw new Error(
|
||||
"Reference images are not supported with DashScope provider in baoyu-imagine. Use --provider google with a Gemini multimodal model."
|
||||
);
|
||||
}
|
||||
|
||||
const spec = getModelSpec(model);
|
||||
const size = resolveSizeForModel(model, args);
|
||||
const url = `${getBaseUrl()}/api/v1/services/aigc/multimodal-generation/generation`;
|
||||
|
||||
const body = {
|
||||
model,
|
||||
input: {
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [{ text: prompt }],
|
||||
},
|
||||
],
|
||||
},
|
||||
parameters: buildParameters(spec.family, size),
|
||||
};
|
||||
|
||||
console.log(`Generating image with DashScope (${model})...`, { family: spec.family, size });
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
throw new Error(`DashScope API error (${res.status}): ${err}`);
|
||||
}
|
||||
|
||||
const result = await res.json() as DashScopeResponse;
|
||||
return extractImageFromResponse(result);
|
||||
}
|
||||
+31
-12
@@ -1,6 +1,7 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import test, { type TestContext } from "node:test";
|
||||
|
||||
import type { CliArgs } from "../types.ts";
|
||||
import {
|
||||
addAspectRatioToPrompt,
|
||||
buildGoogleUrl,
|
||||
@@ -11,10 +12,13 @@ import {
|
||||
isGoogleImagen,
|
||||
isGoogleMultimodal,
|
||||
normalizeGoogleModelId,
|
||||
} from "../../../skills/baoyu-image-gen/scripts/providers/google.ts";
|
||||
} from "./google.ts";
|
||||
|
||||
function useEnv(t, values) {
|
||||
const previous = new Map();
|
||||
function useEnv(
|
||||
t: TestContext,
|
||||
values: Record<string, string | null>,
|
||||
): void {
|
||||
const previous = new Map<string, string | undefined>();
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
previous.set(key, process.env[key]);
|
||||
if (value == null) {
|
||||
@@ -35,6 +39,27 @@ function useEnv(t, values) {
|
||||
});
|
||||
}
|
||||
|
||||
function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
return {
|
||||
prompt: null,
|
||||
promptFiles: [],
|
||||
imagePath: null,
|
||||
provider: null,
|
||||
model: null,
|
||||
aspectRatio: null,
|
||||
size: null,
|
||||
quality: null,
|
||||
imageSize: null,
|
||||
referenceImages: [],
|
||||
n: 1,
|
||||
batchFile: null,
|
||||
jobs: null,
|
||||
json: false,
|
||||
help: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("Google provider helpers normalize model IDs and select image size defaults", () => {
|
||||
assert.equal(
|
||||
normalizeGoogleModelId("models/gemini-3.1-flash-image-preview"),
|
||||
@@ -42,14 +67,8 @@ test("Google provider helpers normalize model IDs and select image size defaults
|
||||
);
|
||||
assert.equal(isGoogleMultimodal("models/gemini-3-pro-image-preview"), true);
|
||||
assert.equal(isGoogleImagen("imagen-3.0-generate-002"), true);
|
||||
assert.equal(
|
||||
getGoogleImageSize({ imageSize: null, quality: "2k" }),
|
||||
"2K",
|
||||
);
|
||||
assert.equal(
|
||||
getGoogleImageSize({ imageSize: "4K", quality: "normal" }),
|
||||
"4K",
|
||||
);
|
||||
assert.equal(getGoogleImageSize(makeArgs({ imageSize: null, quality: "2k" })), "2K");
|
||||
assert.equal(getGoogleImageSize(makeArgs({ imageSize: "4K", quality: "normal" })), "4K");
|
||||
});
|
||||
|
||||
test("Google URL builder appends v1beta when the base URL does not already include it", (t) => {
|
||||
@@ -0,0 +1,114 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test, { type TestContext } from "node:test";
|
||||
|
||||
import type { CliArgs } from "../types.ts";
|
||||
import { generateImage } from "./jimeng.ts";
|
||||
|
||||
function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
return {
|
||||
prompt: null,
|
||||
promptFiles: [],
|
||||
imagePath: null,
|
||||
provider: null,
|
||||
model: null,
|
||||
aspectRatio: null,
|
||||
size: null,
|
||||
quality: null,
|
||||
imageSize: null,
|
||||
referenceImages: [],
|
||||
n: 1,
|
||||
batchFile: null,
|
||||
jobs: null,
|
||||
json: false,
|
||||
help: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function useEnv(
|
||||
t: TestContext,
|
||||
values: Record<string, string | null>,
|
||||
): void {
|
||||
const previous = new Map<string, string | undefined>();
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
previous.set(key, process.env[key]);
|
||||
if (value == null) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
t.after(() => {
|
||||
for (const [key, value] of previous.entries()) {
|
||||
if (value == null) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("Jimeng submit request uses prompt field expected by current API", async (t) => {
|
||||
useEnv(t, {
|
||||
JIMENG_ACCESS_KEY_ID: "test-access-key",
|
||||
JIMENG_SECRET_ACCESS_KEY: "test-secret-key",
|
||||
JIMENG_BASE_URL: null,
|
||||
JIMENG_REGION: null,
|
||||
});
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
t.after(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
const calls: Array<{
|
||||
input: string;
|
||||
init?: RequestInit;
|
||||
}> = [];
|
||||
|
||||
globalThis.fetch = async (input, init) => {
|
||||
calls.push({
|
||||
input: String(input),
|
||||
init,
|
||||
});
|
||||
|
||||
if (calls.length === 1) {
|
||||
return Response.json({
|
||||
code: 10000,
|
||||
data: {
|
||||
task_id: "task-123",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return Response.json({
|
||||
code: 10000,
|
||||
data: {
|
||||
status: "done",
|
||||
binary_data_base64: [Buffer.from("jimeng-image").toString("base64")],
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const image = await generateImage(
|
||||
"A quiet bamboo forest",
|
||||
"jimeng_t2i_v40",
|
||||
makeArgs({ quality: "normal" }),
|
||||
);
|
||||
|
||||
assert.equal(Buffer.from(image).toString("utf8"), "jimeng-image");
|
||||
assert.equal(calls.length, 2);
|
||||
assert.equal(
|
||||
calls[0]?.input,
|
||||
"https://visual.volcengineapi.com/?Action=CVSync2AsyncSubmitTask&Version=2022-08-31",
|
||||
);
|
||||
|
||||
const submitBody = JSON.parse(String(calls[0]?.init?.body)) as Record<string, unknown>;
|
||||
assert.equal(submitBody.req_key, "jimeng_t2i_v40");
|
||||
assert.equal(submitBody.prompt, "A quiet bamboo forest");
|
||||
assert.ok(!("prompt_text" in submitBody));
|
||||
assert.equal(submitBody.width, 1024);
|
||||
assert.equal(submitBody.height, 1024);
|
||||
});
|
||||
+1
-1
@@ -246,7 +246,7 @@ async function submitTask(
|
||||
const [width, height] = size.split("x").map(Number);
|
||||
const bodyObj = {
|
||||
req_key: model,
|
||||
prompt_text: prompt,
|
||||
prompt,
|
||||
// Use separate width and height parameters instead of size string
|
||||
width: width,
|
||||
height: height,
|
||||
@@ -0,0 +1,171 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test, { type TestContext } from "node:test";
|
||||
|
||||
import type { CliArgs } from "../types.ts";
|
||||
import {
|
||||
buildMinimaxUrl,
|
||||
buildRequestBody,
|
||||
buildSubjectReference,
|
||||
extractImageFromResponse,
|
||||
parsePixelSize,
|
||||
validateArgs,
|
||||
} from "./minimax.ts";
|
||||
|
||||
function useEnv(
|
||||
t: TestContext,
|
||||
values: Record<string, string | null>,
|
||||
): void {
|
||||
const previous = new Map<string, string | undefined>();
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
previous.set(key, process.env[key]);
|
||||
if (value == null) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
t.after(() => {
|
||||
for (const [key, value] of previous.entries()) {
|
||||
if (value == null) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
return {
|
||||
prompt: null,
|
||||
promptFiles: [],
|
||||
imagePath: null,
|
||||
provider: null,
|
||||
model: null,
|
||||
aspectRatio: null,
|
||||
size: null,
|
||||
quality: null,
|
||||
imageSize: null,
|
||||
referenceImages: [],
|
||||
n: 1,
|
||||
batchFile: null,
|
||||
jobs: null,
|
||||
json: false,
|
||||
help: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("MiniMax URL builder normalizes /v1 suffixes", (t) => {
|
||||
useEnv(t, { MINIMAX_BASE_URL: "https://api.minimax.io" });
|
||||
assert.equal(buildMinimaxUrl(), "https://api.minimax.io/v1/image_generation");
|
||||
|
||||
process.env.MINIMAX_BASE_URL = "https://proxy.example.com/custom/v1/";
|
||||
assert.equal(buildMinimaxUrl(), "https://proxy.example.com/custom/v1/image_generation");
|
||||
});
|
||||
|
||||
test("MiniMax size parsing and validation follow documented constraints", () => {
|
||||
assert.deepEqual(parsePixelSize("1536x1024"), { width: 1536, height: 1024 });
|
||||
assert.deepEqual(parsePixelSize("1536*1024"), { width: 1536, height: 1024 });
|
||||
assert.equal(parsePixelSize("wide"), null);
|
||||
|
||||
validateArgs("image-01", makeArgs({ size: "1536x1024", n: 9 }));
|
||||
|
||||
assert.throws(
|
||||
() => validateArgs("image-01-live", makeArgs({ size: "1536x1024" })),
|
||||
/only supported with model image-01/,
|
||||
);
|
||||
assert.throws(
|
||||
() => validateArgs("image-01", makeArgs({ size: "1537x1024" })),
|
||||
/divisible by 8/,
|
||||
);
|
||||
assert.throws(
|
||||
() => validateArgs("image-01", makeArgs({ aspectRatio: "2.35:1" })),
|
||||
/aspect_ratio must be one of/,
|
||||
);
|
||||
assert.throws(
|
||||
() => validateArgs("image-01", makeArgs({ n: 10 })),
|
||||
/at most 9 images/,
|
||||
);
|
||||
});
|
||||
|
||||
test("MiniMax request body maps aspect ratio, size, n, and subject references", async (t) => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "minimax-test-"));
|
||||
t.after(() => fs.rm(dir, { recursive: true, force: true }));
|
||||
|
||||
const refPath = path.join(dir, "portrait.png");
|
||||
await fs.writeFile(refPath, Buffer.from("portrait"));
|
||||
|
||||
const ratioBody = await buildRequestBody(
|
||||
"A portrait by the window",
|
||||
"image-01",
|
||||
makeArgs({ aspectRatio: "16:9", n: 2, referenceImages: [refPath] }),
|
||||
);
|
||||
assert.equal(ratioBody.aspect_ratio, "16:9");
|
||||
assert.equal(ratioBody.n, 2);
|
||||
assert.equal(ratioBody.response_format, "base64");
|
||||
assert.match(ratioBody.subject_reference?.[0]?.image_file || "", /^data:image\/png;base64,/);
|
||||
|
||||
const sizeBody = await buildRequestBody(
|
||||
"A portrait by the window",
|
||||
"image-01",
|
||||
makeArgs({ size: "1536x1024" }),
|
||||
);
|
||||
assert.equal(sizeBody.width, 1536);
|
||||
assert.equal(sizeBody.height, 1024);
|
||||
assert.equal(sizeBody.aspect_ratio, undefined);
|
||||
});
|
||||
|
||||
test("MiniMax subject references require supported file types", async (t) => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "minimax-ref-"));
|
||||
t.after(() => fs.rm(dir, { recursive: true, force: true }));
|
||||
|
||||
const good = path.join(dir, "portrait.jpg");
|
||||
const bad = path.join(dir, "portrait.webp");
|
||||
await fs.writeFile(good, Buffer.from("portrait"));
|
||||
await fs.writeFile(bad, Buffer.from("portrait"));
|
||||
|
||||
const subjectReference = await buildSubjectReference([good]);
|
||||
assert.equal(subjectReference?.[0]?.type, "character");
|
||||
|
||||
await assert.rejects(
|
||||
() => buildSubjectReference([bad]),
|
||||
/only supports JPG, JPEG, or PNG/,
|
||||
);
|
||||
});
|
||||
|
||||
test("MiniMax response extraction supports base64 and URL payloads", async (t) => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
t.after(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
const fromBase64 = await extractImageFromResponse({
|
||||
data: {
|
||||
image_base64: [Buffer.from("hello").toString("base64")],
|
||||
},
|
||||
});
|
||||
assert.equal(Buffer.from(fromBase64).toString("utf8"), "hello");
|
||||
|
||||
globalThis.fetch = async () =>
|
||||
new Response(Uint8Array.from([1, 2, 3]), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "image/jpeg" },
|
||||
});
|
||||
|
||||
const fromUrl = await extractImageFromResponse({
|
||||
data: {
|
||||
image_urls: ["https://example.com/output.jpg"],
|
||||
},
|
||||
});
|
||||
assert.deepEqual([...fromUrl], [1, 2, 3]);
|
||||
|
||||
await assert.rejects(
|
||||
() => extractImageFromResponse({ base_resp: { status_code: 1001, status_msg: "blocked" } }),
|
||||
/blocked/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,220 @@
|
||||
import path from "node:path";
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
import type { CliArgs } from "../types";
|
||||
|
||||
const DEFAULT_MODEL = "image-01";
|
||||
const MAX_REFERENCE_IMAGE_BYTES = 10 * 1024 * 1024;
|
||||
const SUPPORTED_ASPECT_RATIOS = new Set(["1:1", "16:9", "4:3", "3:2", "2:3", "3:4", "9:16", "21:9"]);
|
||||
|
||||
type MinimaxSubjectReference = {
|
||||
type: "character";
|
||||
image_file: string;
|
||||
};
|
||||
|
||||
type MinimaxRequestBody = {
|
||||
model: string;
|
||||
prompt: string;
|
||||
response_format: "base64";
|
||||
aspect_ratio?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
n?: number;
|
||||
subject_reference?: MinimaxSubjectReference[];
|
||||
};
|
||||
|
||||
type MinimaxResponse = {
|
||||
id?: string;
|
||||
data?: {
|
||||
image_urls?: string[];
|
||||
image_base64?: string[];
|
||||
};
|
||||
base_resp?: {
|
||||
status_code?: number;
|
||||
status_msg?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export function getDefaultModel(): string {
|
||||
return process.env.MINIMAX_IMAGE_MODEL || DEFAULT_MODEL;
|
||||
}
|
||||
|
||||
function getApiKey(): string | null {
|
||||
return process.env.MINIMAX_API_KEY || null;
|
||||
}
|
||||
|
||||
export function buildMinimaxUrl(): string {
|
||||
const base = (process.env.MINIMAX_BASE_URL || "https://api.minimax.io").replace(/\/+$/g, "");
|
||||
return base.endsWith("/v1") ? `${base}/image_generation` : `${base}/v1/image_generation`;
|
||||
}
|
||||
|
||||
function getMimeType(filename: string): "image/jpeg" | "image/png" {
|
||||
const ext = path.extname(filename).toLowerCase();
|
||||
if (ext === ".jpg" || ext === ".jpeg") return "image/jpeg";
|
||||
if (ext === ".png") return "image/png";
|
||||
throw new Error(
|
||||
`MiniMax subject_reference only supports JPG, JPEG, or PNG files: ${filename}`
|
||||
);
|
||||
}
|
||||
|
||||
export function parsePixelSize(size: string): { width: number; height: number } | null {
|
||||
const match = size.trim().match(/^(\d+)\s*[xX*]\s*(\d+)$/);
|
||||
if (!match) return null;
|
||||
|
||||
const width = parseInt(match[1]!, 10);
|
||||
const height = parseInt(match[2]!, 10);
|
||||
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
function validatePixelSize(width: number, height: number): void {
|
||||
if (width < 512 || width > 2048 || height < 512 || height > 2048) {
|
||||
throw new Error("MiniMax custom size must keep width and height between 512 and 2048.");
|
||||
}
|
||||
if (width % 8 !== 0 || height % 8 !== 0) {
|
||||
throw new Error("MiniMax custom size requires width and height divisible by 8.");
|
||||
}
|
||||
}
|
||||
|
||||
export function validateArgs(model: string, args: CliArgs): void {
|
||||
if (args.n > 9) {
|
||||
throw new Error("MiniMax supports at most 9 images per request.");
|
||||
}
|
||||
|
||||
if (args.aspectRatio && !SUPPORTED_ASPECT_RATIOS.has(args.aspectRatio)) {
|
||||
throw new Error(
|
||||
`MiniMax aspect_ratio must be one of: ${Array.from(SUPPORTED_ASPECT_RATIOS).join(", ")}.`
|
||||
);
|
||||
}
|
||||
|
||||
if (args.size && !args.aspectRatio) {
|
||||
if (model !== "image-01") {
|
||||
throw new Error("MiniMax custom --size is only supported with model image-01. Use --model image-01 or pass --ar instead.");
|
||||
}
|
||||
const parsed = parsePixelSize(args.size);
|
||||
if (!parsed) {
|
||||
throw new Error("MiniMax --size must be in WxH format, for example 1536x1024.");
|
||||
}
|
||||
validatePixelSize(parsed.width, parsed.height);
|
||||
}
|
||||
}
|
||||
|
||||
export async function buildSubjectReference(
|
||||
referenceImages: string[],
|
||||
): Promise<MinimaxSubjectReference[] | undefined> {
|
||||
if (referenceImages.length === 0) return undefined;
|
||||
|
||||
const subjectReference: MinimaxSubjectReference[] = [];
|
||||
for (const refPath of referenceImages) {
|
||||
const bytes = await readFile(refPath);
|
||||
if (bytes.length > MAX_REFERENCE_IMAGE_BYTES) {
|
||||
throw new Error(`MiniMax subject_reference images must be smaller than 10MB: ${refPath}`);
|
||||
}
|
||||
|
||||
subjectReference.push({
|
||||
type: "character",
|
||||
image_file: `data:${getMimeType(refPath)};base64,${bytes.toString("base64")}`,
|
||||
});
|
||||
}
|
||||
|
||||
return subjectReference;
|
||||
}
|
||||
|
||||
export async function buildRequestBody(
|
||||
prompt: string,
|
||||
model: string,
|
||||
args: CliArgs,
|
||||
): Promise<MinimaxRequestBody> {
|
||||
validateArgs(model, args);
|
||||
|
||||
const body: MinimaxRequestBody = {
|
||||
model,
|
||||
prompt,
|
||||
response_format: "base64",
|
||||
};
|
||||
|
||||
if (args.aspectRatio) {
|
||||
body.aspect_ratio = args.aspectRatio;
|
||||
} else if (args.size) {
|
||||
const parsed = parsePixelSize(args.size);
|
||||
if (!parsed) {
|
||||
throw new Error("MiniMax --size must be in WxH format, for example 1536x1024.");
|
||||
}
|
||||
body.width = parsed.width;
|
||||
body.height = parsed.height;
|
||||
}
|
||||
|
||||
if (args.n > 1) {
|
||||
body.n = args.n;
|
||||
}
|
||||
|
||||
const subjectReference = await buildSubjectReference(args.referenceImages);
|
||||
if (subjectReference) {
|
||||
body.subject_reference = subjectReference;
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
async function downloadImage(url: string): Promise<Uint8Array> {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download image from MiniMax: ${response.status}`);
|
||||
}
|
||||
return new Uint8Array(await response.arrayBuffer());
|
||||
}
|
||||
|
||||
export async function extractImageFromResponse(result: MinimaxResponse): Promise<Uint8Array> {
|
||||
const baseResp = result.base_resp;
|
||||
if (baseResp && baseResp.status_code !== undefined && baseResp.status_code !== 0) {
|
||||
throw new Error(baseResp.status_msg || `MiniMax API returned status_code=${baseResp.status_code}`);
|
||||
}
|
||||
|
||||
const base64Image = result.data?.image_base64?.[0];
|
||||
if (base64Image) {
|
||||
return Uint8Array.from(Buffer.from(base64Image, "base64"));
|
||||
}
|
||||
|
||||
const url = result.data?.image_urls?.[0];
|
||||
if (url) {
|
||||
return downloadImage(url);
|
||||
}
|
||||
|
||||
throw new Error("No image data in MiniMax response");
|
||||
}
|
||||
|
||||
export function getDefaultOutputExtension(): ".jpg" {
|
||||
return ".jpg";
|
||||
}
|
||||
|
||||
export async function generateImage(
|
||||
prompt: string,
|
||||
model: string,
|
||||
args: CliArgs
|
||||
): Promise<Uint8Array> {
|
||||
const apiKey = getApiKey();
|
||||
if (!apiKey) {
|
||||
throw new Error("MINIMAX_API_KEY is required. Get one from https://platform.minimax.io/");
|
||||
}
|
||||
|
||||
const body = await buildRequestBody(prompt, model, args);
|
||||
const response = await fetch(buildMinimaxUrl(), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.text();
|
||||
throw new Error(`MiniMax API error (${response.status}): ${err}`);
|
||||
}
|
||||
|
||||
const result = (await response.json()) as MinimaxResponse;
|
||||
return extractImageFromResponse(result);
|
||||
}
|
||||
+1
-1
@@ -6,7 +6,7 @@ import {
|
||||
getMimeType,
|
||||
getOpenAISize,
|
||||
parseAspectRatio,
|
||||
} from "../../../skills/baoyu-image-gen/scripts/providers/openai.ts";
|
||||
} from "./openai.ts";
|
||||
|
||||
test("OpenAI aspect-ratio parsing and size selection match model families", () => {
|
||||
assert.deepEqual(parseAspectRatio("16:9"), { width: 16, height: 9 });
|
||||
@@ -0,0 +1,168 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import type { CliArgs } from "../types.ts";
|
||||
import {
|
||||
buildContent,
|
||||
buildRequestBody,
|
||||
extractImageFromResponse,
|
||||
getAspectRatio,
|
||||
getImageSize,
|
||||
validateArgs,
|
||||
} from "./openrouter.ts";
|
||||
|
||||
const GEMINI_MODEL = "google/gemini-3.1-flash-image-preview";
|
||||
const GEMINI_25_MODEL = "google/gemini-2.5-flash-image";
|
||||
const GPT_5_IMAGE_MODEL = "openai/gpt-5-image";
|
||||
const OPENROUTER_AUTO_MODEL = "openrouter/auto";
|
||||
const FLUX_MODEL = "black-forest-labs/flux.2-pro";
|
||||
|
||||
function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
return {
|
||||
prompt: null,
|
||||
promptFiles: [],
|
||||
imagePath: null,
|
||||
provider: null,
|
||||
model: null,
|
||||
aspectRatio: null,
|
||||
size: null,
|
||||
quality: null,
|
||||
imageSize: null,
|
||||
referenceImages: [],
|
||||
n: 1,
|
||||
batchFile: null,
|
||||
jobs: null,
|
||||
json: false,
|
||||
help: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("OpenRouter request body uses image_config and string content for text-only prompts", () => {
|
||||
const args = makeArgs({ aspectRatio: "16:9", quality: "2k" });
|
||||
const body = buildRequestBody("hello", GEMINI_MODEL, args, []);
|
||||
|
||||
assert.deepEqual(body.image_config, {
|
||||
image_size: "2K",
|
||||
aspect_ratio: "16:9",
|
||||
});
|
||||
assert.deepEqual(body.provider, {
|
||||
require_parameters: true,
|
||||
});
|
||||
assert.deepEqual(body.modalities, ["image", "text"]);
|
||||
assert.equal(body.stream, false);
|
||||
assert.equal(body.messages[0].content, "hello");
|
||||
});
|
||||
|
||||
test("OpenRouter request body keeps text+image modalities for current text+image models", () => {
|
||||
for (const model of [GEMINI_MODEL, GEMINI_25_MODEL, GPT_5_IMAGE_MODEL, OPENROUTER_AUTO_MODEL]) {
|
||||
const body = buildRequestBody("hello", model, makeArgs({ quality: "2k" }), []);
|
||||
|
||||
assert.deepEqual(body.image_config, {
|
||||
image_size: "2K",
|
||||
});
|
||||
assert.deepEqual(body.provider, {
|
||||
require_parameters: true,
|
||||
});
|
||||
assert.deepEqual(body.modalities, ["image", "text"]);
|
||||
assert.equal(body.messages[0].content, "hello");
|
||||
}
|
||||
});
|
||||
|
||||
test("OpenRouter request body uses image-only modalities for image-only models under CLI defaults", () => {
|
||||
const body = buildRequestBody("hello", FLUX_MODEL, makeArgs({ quality: "2k" }), []);
|
||||
|
||||
assert.deepEqual(body.image_config, {
|
||||
image_size: "2K",
|
||||
});
|
||||
assert.deepEqual(body.provider, {
|
||||
require_parameters: true,
|
||||
});
|
||||
assert.deepEqual(body.modalities, ["image"]);
|
||||
assert.equal(body.stream, false);
|
||||
assert.equal(body.messages[0].content, "hello");
|
||||
});
|
||||
|
||||
test("OpenRouter helper omits image_config when no size or quality is passed", () => {
|
||||
const body = buildRequestBody("hello", FLUX_MODEL, makeArgs(), []);
|
||||
|
||||
assert.equal(body.image_config, undefined);
|
||||
assert.equal(body.provider, undefined);
|
||||
assert.deepEqual(body.modalities, ["image"]);
|
||||
assert.equal(body.stream, false);
|
||||
assert.equal(body.messages[0].content, "hello");
|
||||
});
|
||||
|
||||
test("OpenRouter request body keeps multimodal array content when references are provided", () => {
|
||||
const content = buildContent("hello", ["data:image/png;base64,abc"]);
|
||||
assert.ok(Array.isArray(content));
|
||||
assert.deepEqual(content[0], { type: "text", text: "hello" });
|
||||
assert.deepEqual(content[1], {
|
||||
type: "image_url",
|
||||
image_url: { url: "data:image/png;base64,abc" },
|
||||
});
|
||||
});
|
||||
|
||||
test("OpenRouter size and aspect helpers infer supported values", () => {
|
||||
assert.equal(getImageSize(makeArgs()), null);
|
||||
assert.equal(getImageSize(makeArgs({ quality: "normal" })), "1K");
|
||||
assert.equal(getImageSize(makeArgs({ size: "2048x1024" })), "2K");
|
||||
assert.equal(getAspectRatio(GEMINI_MODEL, makeArgs({ size: "1600x900" })), "16:9");
|
||||
assert.equal(getAspectRatio(GEMINI_MODEL, makeArgs({ size: "1024x4096" })), "1:4");
|
||||
assert.equal(getAspectRatio(GEMINI_25_MODEL, makeArgs({ size: "1600x900" })), "16:9");
|
||||
assert.equal(getAspectRatio(FLUX_MODEL, makeArgs({ size: "1024x4096" })), null);
|
||||
});
|
||||
|
||||
test("OpenRouter validates explicit aspect ratios and inferred size ratios against model support", () => {
|
||||
assert.doesNotThrow(() =>
|
||||
validateArgs(GEMINI_MODEL, makeArgs({ aspectRatio: "1:4" })),
|
||||
);
|
||||
assert.doesNotThrow(() =>
|
||||
validateArgs(GEMINI_MODEL, makeArgs({ size: "1024x4096" })),
|
||||
);
|
||||
assert.throws(
|
||||
() => validateArgs(GEMINI_25_MODEL, makeArgs({ aspectRatio: "1:4" })),
|
||||
/does not support aspect ratio 1:4/,
|
||||
);
|
||||
assert.throws(
|
||||
() => validateArgs(FLUX_MODEL, makeArgs({ aspectRatio: "1:4" })),
|
||||
/does not support aspect ratio 1:4/,
|
||||
);
|
||||
assert.throws(
|
||||
() => validateArgs(GEMINI_MODEL, makeArgs({ size: "2048x1024" })),
|
||||
/does not support size 2048x1024 \(aspect ratio 2:1\)/,
|
||||
);
|
||||
});
|
||||
|
||||
test("OpenRouter response extraction supports inline image data and finish_reason errors", async () => {
|
||||
const bytes = await extractImageFromResponse({
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
images: [
|
||||
{
|
||||
image_url: {
|
||||
url: `data:image/png;base64,${Buffer.from("hello").toString("base64")}`,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
assert.equal(Buffer.from(bytes).toString("utf8"), "hello");
|
||||
|
||||
await assert.rejects(
|
||||
() =>
|
||||
extractImageFromResponse({
|
||||
choices: [
|
||||
{
|
||||
finish_reason: "error",
|
||||
native_finish_reason: "MALFORMED_FUNCTION_CALL",
|
||||
message: { content: null },
|
||||
},
|
||||
],
|
||||
}),
|
||||
/finish_reason=MALFORMED_FUNCTION_CALL/,
|
||||
);
|
||||
});
|
||||
+145
-31
@@ -3,6 +3,19 @@ import { readFile } from "node:fs/promises";
|
||||
import type { CliArgs } from "../types";
|
||||
|
||||
const DEFAULT_MODEL = "google/gemini-3.1-flash-image-preview";
|
||||
const COMMON_ASPECT_RATIOS = [
|
||||
"1:1",
|
||||
"2:3",
|
||||
"3:2",
|
||||
"3:4",
|
||||
"4:3",
|
||||
"4:5",
|
||||
"5:4",
|
||||
"9:16",
|
||||
"16:9",
|
||||
"21:9",
|
||||
];
|
||||
const GEMINI_EXTENDED_ASPECT_RATIOS = ["1:4", "4:1", "1:8", "8:1"];
|
||||
|
||||
type OpenRouterImageEntry = {
|
||||
image_url?: string | { url?: string | null } | null;
|
||||
@@ -18,9 +31,11 @@ type OpenRouterMessagePart = {
|
||||
|
||||
type OpenRouterResponse = {
|
||||
choices?: Array<{
|
||||
finish_reason?: string | null;
|
||||
native_finish_reason?: string | null;
|
||||
message?: {
|
||||
images?: OpenRouterImageEntry[];
|
||||
content?: string | OpenRouterMessagePart[];
|
||||
content?: string | OpenRouterMessagePart[] | null;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
@@ -29,6 +44,36 @@ export function getDefaultModel(): string {
|
||||
return process.env.OPENROUTER_IMAGE_MODEL || DEFAULT_MODEL;
|
||||
}
|
||||
|
||||
function normalizeModelId(model: string): string {
|
||||
return model.trim().toLowerCase().split(":")[0]!;
|
||||
}
|
||||
|
||||
function isTextAndImageModel(model: string): boolean {
|
||||
const normalized = normalizeModelId(model);
|
||||
if (normalized === "openrouter/auto") {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (normalized.startsWith("google/gemini-") && normalized.includes("image")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (normalized.startsWith("openai/gpt-") && normalized.includes("image")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function getSupportedAspectRatios(model: string): Set<string> {
|
||||
const normalized = normalizeModelId(model);
|
||||
if (normalized !== "google/gemini-3.1-flash-image-preview") {
|
||||
return new Set(COMMON_ASPECT_RATIOS);
|
||||
}
|
||||
|
||||
return new Set([...COMMON_ASPECT_RATIOS, ...GEMINI_EXTENDED_ASPECT_RATIOS]);
|
||||
}
|
||||
|
||||
function getApiKey(): string | null {
|
||||
return process.env.OPENROUTER_API_KEY || null;
|
||||
}
|
||||
@@ -103,17 +148,50 @@ function inferImageSize(size: string | null): "1K" | "2K" | "4K" | null {
|
||||
return "4K";
|
||||
}
|
||||
|
||||
function getImageSize(args: CliArgs): "1K" | "2K" | "4K" {
|
||||
export function getImageSize(args: CliArgs): "1K" | "2K" | "4K" | null {
|
||||
if (args.imageSize) return args.imageSize as "1K" | "2K" | "4K";
|
||||
|
||||
const inferredFromSize = inferImageSize(args.size);
|
||||
if (inferredFromSize) return inferredFromSize;
|
||||
|
||||
return args.quality === "normal" ? "1K" : "2K";
|
||||
if (args.quality === "normal") return "1K";
|
||||
if (args.quality === "2k") return "2K";
|
||||
return null;
|
||||
}
|
||||
|
||||
function getAspectRatio(args: CliArgs): string | null {
|
||||
return args.aspectRatio || inferAspectRatio(args.size);
|
||||
export function getAspectRatio(model: string, args: CliArgs): string | null {
|
||||
if (args.aspectRatio) return args.aspectRatio;
|
||||
|
||||
const inferred = inferAspectRatio(args.size);
|
||||
if (!inferred || !getSupportedAspectRatios(model).has(inferred)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return inferred;
|
||||
}
|
||||
|
||||
function getModalities(model: string): string[] {
|
||||
return isTextAndImageModel(model) ? ["image", "text"] : ["image"];
|
||||
}
|
||||
|
||||
export function validateArgs(model: string, args: CliArgs): void {
|
||||
const requestedAspectRatio = args.aspectRatio || inferAspectRatio(args.size);
|
||||
if (!requestedAspectRatio) {
|
||||
return;
|
||||
}
|
||||
|
||||
const supported = getSupportedAspectRatios(model);
|
||||
if (supported.has(requestedAspectRatio)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const requestedValue = args.aspectRatio
|
||||
? `aspect ratio ${requestedAspectRatio}`
|
||||
: `size ${args.size} (aspect ratio ${requestedAspectRatio})`;
|
||||
|
||||
throw new Error(
|
||||
`OpenRouter model ${model} does not support ${requestedValue}. Supported values: ${Array.from(supported).join(", ")}`
|
||||
);
|
||||
}
|
||||
|
||||
function getMimeType(filename: string): string {
|
||||
@@ -129,7 +207,14 @@ async function readImageAsDataUrl(filePath: string): Promise<string> {
|
||||
return `data:${getMimeType(filePath)};base64,${bytes.toString("base64")}`;
|
||||
}
|
||||
|
||||
function buildContent(prompt: string, referenceImages: string[]): Array<Record<string, unknown>> {
|
||||
export function buildContent(
|
||||
prompt: string,
|
||||
referenceImages: string[],
|
||||
): string | Array<Record<string, unknown>> {
|
||||
if (referenceImages.length === 0) {
|
||||
return prompt;
|
||||
}
|
||||
|
||||
const content: Array<Record<string, unknown>> = [{ type: "text", text: prompt }];
|
||||
|
||||
for (const imageUrl of referenceImages) {
|
||||
@@ -171,8 +256,9 @@ async function downloadImage(value: string): Promise<Uint8Array> {
|
||||
return Uint8Array.from(Buffer.from(value, "base64"));
|
||||
}
|
||||
|
||||
async function extractImageFromResponse(result: OpenRouterResponse): Promise<Uint8Array> {
|
||||
const message = result.choices?.[0]?.message;
|
||||
export async function extractImageFromResponse(result: OpenRouterResponse): Promise<Uint8Array> {
|
||||
const choice = result.choices?.[0];
|
||||
const message = choice?.message;
|
||||
|
||||
for (const image of message?.images ?? []) {
|
||||
const imageUrl = extractImageUrl(image);
|
||||
@@ -194,7 +280,52 @@ async function extractImageFromResponse(result: OpenRouterResponse): Promise<Uin
|
||||
if (inline) return inline;
|
||||
}
|
||||
|
||||
throw new Error("No image in OpenRouter response");
|
||||
const finishReason =
|
||||
choice?.native_finish_reason || choice?.finish_reason || "unknown";
|
||||
throw new Error(
|
||||
`No image in OpenRouter response (finish_reason=${finishReason})`,
|
||||
);
|
||||
}
|
||||
|
||||
export function buildRequestBody(
|
||||
prompt: string,
|
||||
model: string,
|
||||
args: CliArgs,
|
||||
referenceImages: string[],
|
||||
): Record<string, unknown> {
|
||||
validateArgs(model, args);
|
||||
|
||||
const imageConfig: Record<string, string> = {};
|
||||
|
||||
const imageSize = getImageSize(args);
|
||||
if (imageSize) {
|
||||
imageConfig.image_size = imageSize;
|
||||
}
|
||||
|
||||
const aspectRatio = getAspectRatio(model, args);
|
||||
if (aspectRatio) {
|
||||
imageConfig.aspect_ratio = aspectRatio;
|
||||
}
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: buildContent(prompt, referenceImages),
|
||||
},
|
||||
],
|
||||
modalities: getModalities(model),
|
||||
stream: false,
|
||||
};
|
||||
|
||||
if (Object.keys(imageConfig).length > 0) {
|
||||
body.image_config = imageConfig;
|
||||
body.provider = {
|
||||
require_parameters: true,
|
||||
};
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
export async function generateImage(
|
||||
@@ -212,32 +343,15 @@ export async function generateImage(
|
||||
referenceImages.push(await readImageAsDataUrl(refPath));
|
||||
}
|
||||
|
||||
const imageGenerationOptions: Record<string, string> = {
|
||||
size: getImageSize(args),
|
||||
};
|
||||
|
||||
const aspectRatio = getAspectRatio(args);
|
||||
if (aspectRatio) {
|
||||
imageGenerationOptions.aspect_ratio = aspectRatio;
|
||||
}
|
||||
|
||||
const body = {
|
||||
model,
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: buildContent(prompt, referenceImages),
|
||||
},
|
||||
],
|
||||
modalities: ["image", "text"],
|
||||
max_tokens: 256,
|
||||
imageGenerationOptions,
|
||||
providerPreferences: {
|
||||
require_parameters: true,
|
||||
},
|
||||
...buildRequestBody(prompt, model, args, referenceImages),
|
||||
};
|
||||
|
||||
console.log(`Generating image with OpenRouter (${model})...`, imageGenerationOptions);
|
||||
console.log(
|
||||
`Generating image with OpenRouter (${model})...`,
|
||||
(body.image_config as Record<string, string>),
|
||||
);
|
||||
|
||||
const response = await fetch(`${getBaseUrl()}/chat/completions`, {
|
||||
method: "POST",
|
||||
+19
-6
@@ -1,17 +1,30 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import type { CliArgs } from "../types.ts";
|
||||
import {
|
||||
buildInput,
|
||||
extractOutputUrl,
|
||||
parseModelId,
|
||||
} from "../../../skills/baoyu-image-gen/scripts/providers/replicate.ts";
|
||||
} from "./replicate.ts";
|
||||
|
||||
function makeArgs(overrides = {}) {
|
||||
function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
return {
|
||||
prompt: null,
|
||||
promptFiles: [],
|
||||
imagePath: null,
|
||||
provider: null,
|
||||
model: null,
|
||||
aspectRatio: null,
|
||||
size: null,
|
||||
quality: null,
|
||||
imageSize: null,
|
||||
referenceImages: [],
|
||||
n: 1,
|
||||
batchFile: null,
|
||||
jobs: null,
|
||||
json: false,
|
||||
help: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -69,20 +82,20 @@ test("Replicate input builder maps aspect ratio, image count, quality, and refs"
|
||||
|
||||
test("Replicate output extraction supports string, array, and object URLs", () => {
|
||||
assert.equal(
|
||||
extractOutputUrl({ output: "https://example.com/a.png" }),
|
||||
extractOutputUrl({ output: "https://example.com/a.png" } as never),
|
||||
"https://example.com/a.png",
|
||||
);
|
||||
assert.equal(
|
||||
extractOutputUrl({ output: ["https://example.com/b.png"] }),
|
||||
extractOutputUrl({ output: ["https://example.com/b.png"] } as never),
|
||||
"https://example.com/b.png",
|
||||
);
|
||||
assert.equal(
|
||||
extractOutputUrl({ output: { url: "https://example.com/c.png" } }),
|
||||
extractOutputUrl({ output: { url: "https://example.com/c.png" } } as never),
|
||||
"https://example.com/c.png",
|
||||
);
|
||||
|
||||
assert.throws(
|
||||
() => extractOutputUrl({ output: { invalid: true } }),
|
||||
() => extractOutputUrl({ output: { invalid: true } } as never),
|
||||
/Unexpected Replicate output format/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,244 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test, { type TestContext } from "node:test";
|
||||
|
||||
import type { CliArgs } from "../types.ts";
|
||||
import {
|
||||
buildImageInput,
|
||||
buildRequestBody,
|
||||
generateImage,
|
||||
getDefaultOutputExtension,
|
||||
resolveSeedreamSize,
|
||||
validateArgs,
|
||||
} from "./seedream.ts";
|
||||
|
||||
function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
return {
|
||||
prompt: null,
|
||||
promptFiles: [],
|
||||
imagePath: null,
|
||||
provider: null,
|
||||
model: null,
|
||||
aspectRatio: null,
|
||||
size: null,
|
||||
quality: null,
|
||||
imageSize: null,
|
||||
referenceImages: [],
|
||||
n: 1,
|
||||
batchFile: null,
|
||||
jobs: null,
|
||||
json: false,
|
||||
help: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function useEnv(
|
||||
t: TestContext,
|
||||
values: Record<string, string | null>,
|
||||
): void {
|
||||
const previous = new Map<string, string | undefined>();
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
previous.set(key, process.env[key]);
|
||||
if (value == null) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
t.after(() => {
|
||||
for (const [key, value] of previous.entries()) {
|
||||
if (value == null) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function makeTempPng(t: TestContext, name: string): Promise<string> {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "seedream-test-"));
|
||||
t.after(() => fs.rm(dir, { recursive: true, force: true }));
|
||||
|
||||
const filePath = path.join(dir, name);
|
||||
const png1x1 =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+a7m0AAAAASUVORK5CYII=";
|
||||
await fs.writeFile(filePath, Buffer.from(png1x1, "base64"));
|
||||
return filePath;
|
||||
}
|
||||
|
||||
test("Seedream request body and default extensions follow official model capabilities", () => {
|
||||
const five = buildRequestBody(
|
||||
"A robot illustrator",
|
||||
"doubao-seedream-5-0-260128",
|
||||
makeArgs(),
|
||||
);
|
||||
assert.equal(five.size, "2K");
|
||||
assert.equal(five.response_format, "url");
|
||||
assert.equal(five.output_format, "png");
|
||||
assert.equal(getDefaultOutputExtension("doubao-seedream-5-0-260128"), ".png");
|
||||
|
||||
const fourFive = buildRequestBody(
|
||||
"A robot illustrator",
|
||||
"doubao-seedream-4-5-251128",
|
||||
makeArgs(),
|
||||
);
|
||||
assert.equal(fourFive.size, "2K");
|
||||
assert.equal(fourFive.response_format, "url");
|
||||
assert.ok(!("output_format" in fourFive));
|
||||
assert.equal(getDefaultOutputExtension("doubao-seedream-4-5-251128"), ".jpg");
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
buildRequestBody(
|
||||
"Change the bubbles into hearts",
|
||||
"doubao-seededit-3-0-i2i-250628",
|
||||
makeArgs({ referenceImages: ["ref.png"] }),
|
||||
"data:image/png;base64,AAAA",
|
||||
),
|
||||
/no longer supported/,
|
||||
);
|
||||
});
|
||||
|
||||
test("Seedream size selection validates model-specific presets", () => {
|
||||
assert.equal(
|
||||
resolveSeedreamSize("doubao-seedream-4-0-250828", makeArgs({ quality: "normal" })),
|
||||
"1K",
|
||||
);
|
||||
assert.equal(
|
||||
resolveSeedreamSize("doubao-seedream-3-0-t2i-250415", makeArgs({ quality: "2k" })),
|
||||
"2048x2048",
|
||||
);
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
resolveSeedreamSize("doubao-seedream-5-0-260128", makeArgs({ size: "4K" })),
|
||||
/only supports 2K, 3K/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
resolveSeedreamSize("doubao-seedream-3-0-t2i-250415", makeArgs({ imageSize: "2K" })),
|
||||
/only supports explicit WxH sizes/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
resolveSeedreamSize("doubao-seededit-3-0-i2i-250628", makeArgs({ size: "1024x1024" })),
|
||||
/no longer supported/,
|
||||
);
|
||||
});
|
||||
|
||||
test("Seedream reference-image support is model-specific", () => {
|
||||
assert.doesNotThrow(() =>
|
||||
validateArgs(
|
||||
"doubao-seedream-5-0-260128",
|
||||
makeArgs({ referenceImages: ["a.png", "b.png"] }),
|
||||
),
|
||||
);
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
validateArgs(
|
||||
"doubao-seedream-3-0-t2i-250415",
|
||||
makeArgs({ referenceImages: ["a.png"] }),
|
||||
),
|
||||
/does not support reference images/,
|
||||
);
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
validateArgs(
|
||||
"doubao-seededit-3-0-i2i-250628",
|
||||
makeArgs(),
|
||||
),
|
||||
/no longer supported/,
|
||||
);
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
validateArgs(
|
||||
"ep-20260315171508-t8br2",
|
||||
makeArgs({ referenceImages: ["a.png"] }),
|
||||
),
|
||||
/require a known model ID/,
|
||||
);
|
||||
});
|
||||
|
||||
test("Seedream image input encodes local references as data URLs", async (t) => {
|
||||
const refOne = await makeTempPng(t, "one.png");
|
||||
const refTwo = await makeTempPng(t, "two.png");
|
||||
|
||||
const single = await buildImageInput("doubao-seedream-4-5-251128", [refOne]);
|
||||
assert.match(String(single), /^data:image\/png;base64,/);
|
||||
|
||||
const multiple = await buildImageInput("doubao-seedream-5-0-260128", [refOne, refTwo]);
|
||||
assert.ok(Array.isArray(multiple));
|
||||
assert.equal(multiple.length, 2);
|
||||
});
|
||||
|
||||
test("Seedream generateImage posts the documented response_format and downloads the returned URL", async (t) => {
|
||||
useEnv(t, { ARK_API_KEY: "test-key", SEEDREAM_BASE_URL: null });
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
t.after(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
const calls: Array<{
|
||||
input: string;
|
||||
init?: RequestInit;
|
||||
}> = [];
|
||||
|
||||
globalThis.fetch = async (input, init) => {
|
||||
calls.push({
|
||||
input: String(input),
|
||||
init,
|
||||
});
|
||||
|
||||
if (calls.length === 1) {
|
||||
return Response.json({
|
||||
model: "doubao-seedream-4-5-251128",
|
||||
created: 1740000000,
|
||||
data: [
|
||||
{
|
||||
url: "https://example.com/generated-image",
|
||||
size: "2048x2048",
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
generated_images: 1,
|
||||
output_tokens: 1,
|
||||
total_tokens: 1,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(Uint8Array.from([7, 8, 9]), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "image/jpeg" },
|
||||
});
|
||||
};
|
||||
|
||||
const image = await generateImage(
|
||||
"A robot illustrator",
|
||||
"doubao-seedream-4-5-251128",
|
||||
makeArgs(),
|
||||
);
|
||||
|
||||
assert.deepEqual([...image], [7, 8, 9]);
|
||||
assert.equal(calls.length, 2);
|
||||
assert.equal(
|
||||
calls[0]?.input,
|
||||
"https://ark.cn-beijing.volces.com/api/v3/images/generations",
|
||||
);
|
||||
|
||||
const requestBody = JSON.parse(String(calls[0]?.init?.body)) as Record<string, unknown>;
|
||||
assert.equal(requestBody.model, "doubao-seedream-4-5-251128");
|
||||
assert.equal(requestBody.size, "2K");
|
||||
assert.equal(requestBody.response_format, "url");
|
||||
assert.ok(!("output_format" in requestBody));
|
||||
assert.equal(calls[1]?.input, "https://example.com/generated-image");
|
||||
});
|
||||
@@ -0,0 +1,341 @@
|
||||
import path from "node:path";
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
import type { CliArgs } from "../types";
|
||||
|
||||
export type SeedreamModelFamily =
|
||||
| "seedream5"
|
||||
| "seedream45"
|
||||
| "seedream40"
|
||||
| "seedream30"
|
||||
| "unknown";
|
||||
|
||||
type SeedreamRequestImage = string | string[];
|
||||
|
||||
type SeedreamRequestBody = {
|
||||
model: string;
|
||||
prompt: string;
|
||||
size: string;
|
||||
response_format: "url";
|
||||
watermark: boolean;
|
||||
image?: SeedreamRequestImage;
|
||||
output_format?: "png";
|
||||
};
|
||||
|
||||
type SeedreamImageResponse = {
|
||||
model?: string;
|
||||
created?: number;
|
||||
data?: Array<{
|
||||
url?: string;
|
||||
b64_json?: string;
|
||||
size?: string;
|
||||
error?: {
|
||||
code?: string;
|
||||
message?: string;
|
||||
};
|
||||
}>;
|
||||
usage?: {
|
||||
generated_images: number;
|
||||
output_tokens: number;
|
||||
total_tokens: number;
|
||||
};
|
||||
error?: {
|
||||
code?: string;
|
||||
message?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export function getDefaultModel(): string {
|
||||
return process.env.SEEDREAM_IMAGE_MODEL || "doubao-seedream-5-0-260128";
|
||||
}
|
||||
|
||||
function getApiKey(): string | null {
|
||||
return process.env.ARK_API_KEY || null;
|
||||
}
|
||||
|
||||
function getBaseUrl(): string {
|
||||
return process.env.SEEDREAM_BASE_URL || "https://ark.cn-beijing.volces.com/api/v3";
|
||||
}
|
||||
|
||||
function parsePixelSize(value: string): { width: number; height: number } | null {
|
||||
const match = value.trim().match(/^(\d+)\s*[xX]\s*(\d+)$/);
|
||||
if (!match) return null;
|
||||
|
||||
const width = parseInt(match[1]!, 10);
|
||||
const height = parseInt(match[2]!, 10);
|
||||
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
function normalizePixelSize(value: string): string | null {
|
||||
const parsed = parsePixelSize(value);
|
||||
if (!parsed) return null;
|
||||
return `${parsed.width}x${parsed.height}`;
|
||||
}
|
||||
|
||||
function normalizeSizePreset(value: string): string | null {
|
||||
const upper = value.trim().toUpperCase();
|
||||
if (upper === "ADAPTIVE") return "adaptive";
|
||||
if (upper === "1K" || upper === "2K" || upper === "3K" || upper === "4K") return upper;
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeSizeValue(value: string): string | null {
|
||||
return normalizeSizePreset(value) ?? normalizePixelSize(value);
|
||||
}
|
||||
|
||||
function getMimeType(filename: string): string {
|
||||
const ext = path.extname(filename).toLowerCase();
|
||||
if (ext === ".jpg" || ext === ".jpeg") return "image/jpeg";
|
||||
if (ext === ".webp") return "image/webp";
|
||||
if (ext === ".gif") return "image/gif";
|
||||
if (ext === ".bmp") return "image/bmp";
|
||||
if (ext === ".tiff" || ext === ".tif") return "image/tiff";
|
||||
return "image/png";
|
||||
}
|
||||
|
||||
async function readImageAsDataUrl(filePath: string): Promise<string> {
|
||||
const bytes = await readFile(filePath);
|
||||
return `data:${getMimeType(filePath)};base64,${bytes.toString("base64")}`;
|
||||
}
|
||||
|
||||
export function getModelFamily(model: string): SeedreamModelFamily {
|
||||
const normalized = model.trim();
|
||||
if (/^doubao-seedream-5-0(?:-lite)?-\d+$/.test(normalized)) return "seedream5";
|
||||
if (/^doubao-seedream-4-5-\d+$/.test(normalized)) return "seedream45";
|
||||
if (/^doubao-seedream-4-0-\d+$/.test(normalized)) return "seedream40";
|
||||
if (/^doubao-seedream-3-0-t2i-\d+$/.test(normalized)) return "seedream30";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function isRemovedSeededitModel(model: string): boolean {
|
||||
return /^doubao-seededit-3-0-i2i-\d+$/.test(model.trim());
|
||||
}
|
||||
|
||||
function assertSupportedModel(model: string): void {
|
||||
if (isRemovedSeededitModel(model)) {
|
||||
throw new Error(
|
||||
`${model} is no longer supported. SeedEdit 3.0 support has been removed from this tool; use Seedream 5.0/4.5/4.0/3.0 instead.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function supportsReferenceImages(model: string): boolean {
|
||||
const family = getModelFamily(model);
|
||||
return family === "seedream5" || family === "seedream45" || family === "seedream40";
|
||||
}
|
||||
|
||||
function supportsOutputFormat(model: string): boolean {
|
||||
return getModelFamily(model) === "seedream5";
|
||||
}
|
||||
|
||||
export function getDefaultOutputExtension(model: string): ".png" | ".jpg" {
|
||||
assertSupportedModel(model);
|
||||
return supportsOutputFormat(model) ? ".png" : ".jpg";
|
||||
}
|
||||
|
||||
export function getDefaultSeedreamSize(model: string, args: CliArgs): string {
|
||||
assertSupportedModel(model);
|
||||
const family = getModelFamily(model);
|
||||
|
||||
if (family === "seedream5") return "2K";
|
||||
if (family === "seedream45") return "2K";
|
||||
if (family === "seedream40") return args.quality === "normal" ? "1K" : "2K";
|
||||
if (family === "seedream30") return args.quality === "2k" ? "2048x2048" : "1024x1024";
|
||||
return "2K";
|
||||
}
|
||||
|
||||
export function resolveSeedreamSize(model: string, args: CliArgs): string {
|
||||
assertSupportedModel(model);
|
||||
const family = getModelFamily(model);
|
||||
const requested = args.size || args.imageSize || null;
|
||||
const normalized = requested ? normalizeSizeValue(requested) : null;
|
||||
|
||||
if (!normalized) {
|
||||
return getDefaultSeedreamSize(model, args);
|
||||
}
|
||||
|
||||
if (family === "seedream30") {
|
||||
const pixelSize = normalizePixelSize(normalized);
|
||||
if (!pixelSize) {
|
||||
throw new Error("Seedream 3.0 only supports explicit WxH sizes such as 1024x1024.");
|
||||
}
|
||||
return pixelSize;
|
||||
}
|
||||
|
||||
if (family === "seedream5") {
|
||||
if (normalized === "4K" || normalized === "1K" || normalized === "adaptive") {
|
||||
throw new Error("Seedream 5.0 only supports 2K, 3K, or explicit WxH sizes.");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
if (family === "seedream45") {
|
||||
if (normalized === "1K" || normalized === "3K" || normalized === "adaptive") {
|
||||
throw new Error("Seedream 4.5 only supports 2K, 4K, or explicit WxH sizes.");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
if (family === "seedream40") {
|
||||
if (normalized === "3K" || normalized === "adaptive") {
|
||||
throw new Error("Seedream 4.0 only supports 1K, 2K, 4K, or explicit WxH sizes.");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
if (normalized === "adaptive") {
|
||||
throw new Error("Adaptive size is not supported by Seedream image generation.");
|
||||
}
|
||||
|
||||
if (normalized === "1K" || normalized === "3K" || normalized === "4K") {
|
||||
throw new Error(
|
||||
"Unknown Seedream model ID. Use a documented model ID or pass an explicit WxH size instead of preset imageSize."
|
||||
);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function validateArgs(model: string, args: CliArgs): void {
|
||||
assertSupportedModel(model);
|
||||
const family = getModelFamily(model);
|
||||
const refCount = args.referenceImages.length;
|
||||
|
||||
if (refCount === 0) {
|
||||
resolveSeedreamSize(model, args);
|
||||
return;
|
||||
}
|
||||
|
||||
if (family === "unknown") {
|
||||
throw new Error(
|
||||
"Reference images with Seedream require a known model ID. Use Seedream 5.0/4.5/4.0 model IDs instead of an endpoint ID."
|
||||
);
|
||||
}
|
||||
|
||||
if (!supportsReferenceImages(model)) {
|
||||
throw new Error(`${model} does not support reference images.`);
|
||||
}
|
||||
|
||||
if ((family === "seedream5" || family === "seedream45" || family === "seedream40") && refCount > 14) {
|
||||
throw new Error(`${model} supports at most 14 reference images.`);
|
||||
}
|
||||
|
||||
resolveSeedreamSize(model, args);
|
||||
}
|
||||
|
||||
export async function buildImageInput(
|
||||
model: string,
|
||||
referenceImages: string[],
|
||||
): Promise<SeedreamRequestImage | undefined> {
|
||||
if (referenceImages.length === 0) return undefined;
|
||||
assertSupportedModel(model);
|
||||
|
||||
const encoded = await Promise.all(referenceImages.map((refPath) => readImageAsDataUrl(refPath)));
|
||||
|
||||
return encoded.length === 1 ? encoded[0]! : encoded;
|
||||
}
|
||||
|
||||
export function buildRequestBody(
|
||||
prompt: string,
|
||||
model: string,
|
||||
args: CliArgs,
|
||||
imageInput?: SeedreamRequestImage,
|
||||
): SeedreamRequestBody {
|
||||
validateArgs(model, args);
|
||||
|
||||
const requestBody: SeedreamRequestBody = {
|
||||
model,
|
||||
prompt,
|
||||
size: resolveSeedreamSize(model, args),
|
||||
response_format: "url",
|
||||
watermark: false,
|
||||
};
|
||||
|
||||
if (imageInput) {
|
||||
requestBody.image = imageInput;
|
||||
}
|
||||
|
||||
if (supportsOutputFormat(model)) {
|
||||
requestBody.output_format = "png";
|
||||
}
|
||||
|
||||
return requestBody;
|
||||
}
|
||||
|
||||
async function downloadImage(url: string): Promise<Uint8Array> {
|
||||
const imgResponse = await fetch(url);
|
||||
if (!imgResponse.ok) {
|
||||
throw new Error(`Failed to download image from ${url}`);
|
||||
}
|
||||
|
||||
const buffer = await imgResponse.arrayBuffer();
|
||||
return new Uint8Array(buffer);
|
||||
}
|
||||
|
||||
export async function extractImageFromResponse(result: SeedreamImageResponse): Promise<Uint8Array> {
|
||||
const first = result.data?.find((item) => item.url || item.b64_json || item.error);
|
||||
|
||||
if (!first) {
|
||||
throw new Error("No image data in Seedream response");
|
||||
}
|
||||
|
||||
if (first.error) {
|
||||
throw new Error(first.error.message || "Seedream returned an image generation error");
|
||||
}
|
||||
|
||||
if (first.b64_json) {
|
||||
return Uint8Array.from(Buffer.from(first.b64_json, "base64"));
|
||||
}
|
||||
|
||||
if (first.url) {
|
||||
console.error(`Downloading image from ${first.url}...`);
|
||||
return downloadImage(first.url);
|
||||
}
|
||||
|
||||
throw new Error("No image URL or base64 data in Seedream response");
|
||||
}
|
||||
|
||||
export async function generateImage(
|
||||
prompt: string,
|
||||
model: string,
|
||||
args: CliArgs,
|
||||
): Promise<Uint8Array> {
|
||||
const apiKey = getApiKey();
|
||||
if (!apiKey) {
|
||||
throw new Error(
|
||||
"ARK_API_KEY is required. " +
|
||||
"Get your API key from https://console.volcengine.com/ark"
|
||||
);
|
||||
}
|
||||
|
||||
validateArgs(model, args);
|
||||
const imageInput = await buildImageInput(model, args.referenceImages);
|
||||
const requestBody = buildRequestBody(prompt, model, args, imageInput);
|
||||
|
||||
console.error(`Calling Seedream API (${model}) with size: ${requestBody.size}`);
|
||||
|
||||
const response = await fetch(`${getBaseUrl()}/images/generations`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.text();
|
||||
throw new Error(`Seedream API error (${response.status}): ${err}`);
|
||||
}
|
||||
|
||||
const result = (await response.json()) as SeedreamImageResponse;
|
||||
if (result.error) {
|
||||
throw new Error(result.error.message || "Seedream API returned an error");
|
||||
}
|
||||
|
||||
return extractImageFromResponse(result);
|
||||
}
|
||||
@@ -1,4 +1,13 @@
|
||||
export type Provider = "google" | "openai" | "openrouter" | "dashscope" | "replicate" | "jimeng" | "seedream";
|
||||
export type Provider =
|
||||
| "google"
|
||||
| "openai"
|
||||
| "openrouter"
|
||||
| "dashscope"
|
||||
| "minimax"
|
||||
| "replicate"
|
||||
| "jimeng"
|
||||
| "seedream"
|
||||
| "azure";
|
||||
export type Quality = "normal" | "2k";
|
||||
|
||||
export type CliArgs = {
|
||||
@@ -52,9 +61,11 @@ export type ExtendConfig = {
|
||||
openai: string | null;
|
||||
openrouter: string | null;
|
||||
dashscope: string | null;
|
||||
minimax: string | null;
|
||||
replicate: string | null;
|
||||
jimeng: string | null;
|
||||
seedream: string | null;
|
||||
azure: string | null;
|
||||
};
|
||||
batch?: {
|
||||
max_workers?: number | null;
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: baoyu-markdown-to-html
|
||||
description: Converts Markdown to styled HTML with WeChat-compatible themes. Supports code highlighting, math, PlantUML, footnotes, alerts, infographics, and optional bottom citations for external links. Use when user asks for "markdown to html", "convert md to html", "md转html", "微信外链转底部引用", or needs styled HTML output from markdown.
|
||||
description: Converts Markdown to styled HTML with WeChat-compatible themes. Supports code highlighting, math, PlantUML, footnotes, alerts, infographics, and optional bottom citations for external links. Use when user asks for "markdown to html", "convert md to html", "md 转 html", "微信外链转底部引用", or needs styled HTML output from markdown.
|
||||
version: 1.56.1
|
||||
metadata:
|
||||
openclaw:
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFile } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import test from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
||||
const SCRIPT_PATH = path.join(SCRIPT_DIR, "main.ts");
|
||||
|
||||
async function makeTempDir(prefix: string): Promise<string> {
|
||||
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
test("CLI forwards wrapper title and vendor render options", async () => {
|
||||
const root = await makeTempDir("baoyu-markdown-to-html-cli-");
|
||||
const markdownPath = path.join(root, "article.md");
|
||||
await fs.writeFile(markdownPath, "## Section\n\nParagraph with **bold** text.\n", "utf-8");
|
||||
|
||||
const { stdout } = await execFileAsync(
|
||||
process.execPath,
|
||||
[
|
||||
"--import",
|
||||
"tsx",
|
||||
SCRIPT_PATH,
|
||||
markdownPath,
|
||||
"--theme", "grace",
|
||||
"--color", "red",
|
||||
"--font-family", "mono",
|
||||
"--font-size", "18",
|
||||
"--keep-title",
|
||||
"--title", "Overridden",
|
||||
],
|
||||
{ cwd: SCRIPT_DIR },
|
||||
);
|
||||
|
||||
const result = JSON.parse(stdout.trim()) as {
|
||||
htmlPath: string;
|
||||
title: string;
|
||||
};
|
||||
|
||||
assert.equal(result.title, "Overridden");
|
||||
|
||||
const html = await fs.readFile(result.htmlPath, "utf-8");
|
||||
assert.match(html, /<title>Overridden<\/title>/);
|
||||
assert.match(html, /<h2[^>]*style="[^"]*background: #A93226/);
|
||||
assert.match(html, /<strong[^>]*style="[^"]*color: #A93226/);
|
||||
assert.match(
|
||||
html,
|
||||
/<body[^>]*style="[^"]*font-family: Menlo, Monaco, 'Courier New', monospace;[^"]*font-size: 18px/,
|
||||
);
|
||||
});
|
||||
@@ -4,16 +4,22 @@ import path from "node:path";
|
||||
import process from "node:process";
|
||||
|
||||
import {
|
||||
COLOR_PRESETS,
|
||||
FONT_FAMILY_MAP,
|
||||
FONT_SIZE_OPTIONS,
|
||||
THEME_NAMES,
|
||||
extractSummaryFromBody,
|
||||
extractTitleFromMarkdown,
|
||||
formatTimestamp,
|
||||
parseArgs,
|
||||
parseFrontmatter,
|
||||
renderMarkdownDocument,
|
||||
replaceMarkdownImagesWithPlaceholders,
|
||||
resolveContentImages,
|
||||
serializeFrontmatter,
|
||||
stripWrappingQuotes,
|
||||
} from "baoyu-md";
|
||||
} from "./vendor/baoyu-md/src/index.ts";
|
||||
import type { CliOptions } from "./vendor/baoyu-md/src/types.ts";
|
||||
|
||||
interface ImageInfo {
|
||||
placeholder: string;
|
||||
@@ -30,9 +36,13 @@ interface ParsedResult {
|
||||
contentImages: ImageInfo[];
|
||||
}
|
||||
|
||||
type ConvertMarkdownOptions = Partial<Omit<CliOptions, "inputPath">> & {
|
||||
title?: string;
|
||||
};
|
||||
|
||||
export async function convertMarkdown(
|
||||
markdownPath: string,
|
||||
options?: { title?: string; theme?: string; keepTitle?: boolean; citeStatus?: boolean },
|
||||
options?: ConvertMarkdownOptions,
|
||||
): Promise<ParsedResult> {
|
||||
const baseDir = path.dirname(markdownPath);
|
||||
const content = fs.readFileSync(markdownPath, "utf-8");
|
||||
@@ -56,20 +66,32 @@ export async function convertMarkdown(
|
||||
summary = extractSummaryFromBody(body, 120);
|
||||
}
|
||||
|
||||
const effectiveFrontmatter = options?.title
|
||||
? { ...frontmatter, title }
|
||||
: frontmatter;
|
||||
|
||||
const { images, markdown: rewrittenBody } = replaceMarkdownImagesWithPlaceholders(
|
||||
body,
|
||||
"MDTOHTMLIMGPH_",
|
||||
);
|
||||
const rewrittenMarkdown = `${serializeFrontmatter(frontmatter)}${rewrittenBody}`;
|
||||
const rewrittenMarkdown = `${serializeFrontmatter(effectiveFrontmatter)}${rewrittenBody}`;
|
||||
|
||||
console.error(
|
||||
`[markdown-to-html] Rendering with theme: ${theme ?? "default"}, keepTitle: ${keepTitle}, citeStatus: ${citeStatus}`,
|
||||
);
|
||||
|
||||
const { html } = await renderMarkdownDocument(rewrittenMarkdown, {
|
||||
codeTheme: options?.codeTheme,
|
||||
countStatus: options?.countStatus,
|
||||
citeStatus,
|
||||
defaultTitle: title,
|
||||
fontFamily: options?.fontFamily,
|
||||
fontSize: options?.fontSize,
|
||||
isMacCodeBlock: options?.isMacCodeBlock,
|
||||
isShowLineNumber: options?.isShowLineNumber,
|
||||
keepTitle,
|
||||
legend: options?.legend,
|
||||
primaryColor: options?.primaryColor,
|
||||
theme,
|
||||
});
|
||||
|
||||
@@ -111,18 +133,30 @@ export async function convertMarkdown(
|
||||
};
|
||||
}
|
||||
|
||||
function printUsage(): never {
|
||||
function printUsage(exitCode = 0): never {
|
||||
const colorNames = Object.keys(COLOR_PRESETS).join(", ");
|
||||
const fontFamilyNames = Object.keys(FONT_FAMILY_MAP).join(", ");
|
||||
|
||||
console.log(`Convert Markdown to styled HTML
|
||||
|
||||
Usage:
|
||||
npx -y bun main.ts <markdown_file> [options]
|
||||
|
||||
Options:
|
||||
--title <title> Override title
|
||||
--theme <name> Theme name (default, grace, simple). Default: default
|
||||
--cite Convert ordinary external links to bottom citations. Default: off
|
||||
--keep-title Keep the first heading in content. Default: false (removed)
|
||||
--help Show this help
|
||||
--title <title> Override title
|
||||
--theme <name> Theme name (${THEME_NAMES.join(", ")}). Default: default
|
||||
--color <name|hex> Primary color: ${colorNames}
|
||||
--font-family <name> Font: ${fontFamilyNames}, or CSS value
|
||||
--font-size <N> Font size: ${FONT_SIZE_OPTIONS.join(", ")} (default: 16px)
|
||||
--code-theme <name> Code highlight theme (default: github)
|
||||
--mac-code-block Show Mac-style code block header
|
||||
--no-mac-code-block Hide Mac-style code block header
|
||||
--line-number Show line numbers in code blocks
|
||||
--cite Convert ordinary external links to bottom citations. Default: off
|
||||
--count Show reading time / word count
|
||||
--legend <value> Image caption: title-alt, alt-title, title, alt, none
|
||||
--keep-title Keep the first heading in content. Default: false (removed)
|
||||
--help Show this help
|
||||
|
||||
Output:
|
||||
HTML file saved to same directory as input markdown file.
|
||||
@@ -142,40 +176,60 @@ Output JSON format:
|
||||
Example:
|
||||
npx -y bun main.ts article.md
|
||||
npx -y bun main.ts article.md --theme grace
|
||||
npx -y bun main.ts article.md --theme modern --color red
|
||||
npx -y bun main.ts article.md --cite
|
||||
`);
|
||||
process.exit(0);
|
||||
process.exit(exitCode);
|
||||
}
|
||||
|
||||
function parseArgValue(argv: string[], i: number, flag: string): string | null {
|
||||
const arg = argv[i]!;
|
||||
if (arg.includes("=")) {
|
||||
return arg.slice(flag.length + 1);
|
||||
}
|
||||
const next = argv[i + 1];
|
||||
return next ?? null;
|
||||
}
|
||||
|
||||
function extractTitleArg(argv: string[]): { renderArgs: string[]; title?: string } {
|
||||
let title: string | undefined;
|
||||
const renderArgs: string[] = [];
|
||||
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const arg = argv[i]!;
|
||||
if (arg === "--title" || arg.startsWith("--title=")) {
|
||||
const value = parseArgValue(argv, i, "--title");
|
||||
if (!value) {
|
||||
console.error("Missing value for --title");
|
||||
printUsage(1);
|
||||
}
|
||||
title = value;
|
||||
if (!arg.includes("=")) {
|
||||
i += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
renderArgs.push(arg);
|
||||
}
|
||||
|
||||
return { renderArgs, title };
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = process.argv.slice(2);
|
||||
if (args.length === 0 || args.includes("--help") || args.includes("-h")) {
|
||||
printUsage();
|
||||
printUsage(0);
|
||||
}
|
||||
|
||||
let markdownPath: string | undefined;
|
||||
let title: string | undefined;
|
||||
let theme: string | undefined;
|
||||
let citeStatus = false;
|
||||
let keepTitle = false;
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i]!;
|
||||
if (arg === "--title" && args[i + 1]) {
|
||||
title = args[++i];
|
||||
} else if (arg === "--theme" && args[i + 1]) {
|
||||
theme = args[++i];
|
||||
} else if (arg === "--cite") {
|
||||
citeStatus = true;
|
||||
} else if (arg === "--keep-title") {
|
||||
keepTitle = true;
|
||||
} else if (!arg.startsWith("-")) {
|
||||
markdownPath = arg;
|
||||
}
|
||||
const { renderArgs, title } = extractTitleArg(args);
|
||||
const options = parseArgs(renderArgs);
|
||||
if (!options) {
|
||||
printUsage(1);
|
||||
}
|
||||
|
||||
if (!markdownPath) {
|
||||
console.error("Error: Markdown file path is required");
|
||||
const markdownPath = path.resolve(process.cwd(), options.inputPath);
|
||||
if (!markdownPath.toLowerCase().endsWith(".md")) {
|
||||
console.error("Input file must end with .md");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -184,7 +238,7 @@ async function main(): Promise<void> {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const result = await convertMarkdown(markdownPath, { title, theme, keepTitle, citeStatus });
|
||||
const result = await convertMarkdown(markdownPath, { ...options, title });
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
extractSummaryFromBody,
|
||||
extractTitleFromMarkdown,
|
||||
parseFrontmatter,
|
||||
pickFirstString,
|
||||
serializeFrontmatter,
|
||||
stripWrappingQuotes,
|
||||
toFrontmatterString,
|
||||
} from "./content.ts";
|
||||
|
||||
test("parseFrontmatter extracts YAML fields and strips wrapping quotes", () => {
|
||||
const input = `---
|
||||
title: "Hello World"
|
||||
author: ‘Baoyu’
|
||||
summary: plain text
|
||||
---
|
||||
# Heading
|
||||
|
||||
Body`;
|
||||
|
||||
const result = parseFrontmatter(input);
|
||||
|
||||
assert.deepEqual(result.frontmatter, {
|
||||
title: "Hello World",
|
||||
author: "Baoyu",
|
||||
summary: "plain text",
|
||||
});
|
||||
assert.match(result.body, /^# Heading/);
|
||||
});
|
||||
|
||||
test("parseFrontmatter returns original content when no frontmatter exists", () => {
|
||||
const input = "# No frontmatter";
|
||||
assert.deepEqual(parseFrontmatter(input), {
|
||||
frontmatter: {},
|
||||
body: input,
|
||||
});
|
||||
});
|
||||
|
||||
test("serializeFrontmatter renders YAML only when fields exist", () => {
|
||||
assert.equal(serializeFrontmatter({}), "");
|
||||
assert.equal(
|
||||
serializeFrontmatter({ title: "Hello", author: "Baoyu" }),
|
||||
"---\ntitle: Hello\nauthor: Baoyu\n---\n",
|
||||
);
|
||||
});
|
||||
|
||||
test("quote and frontmatter string helpers normalize mixed scalar values", () => {
|
||||
assert.equal(stripWrappingQuotes(`" quoted "`), "quoted");
|
||||
assert.equal(stripWrappingQuotes("“ 中文标题 ”"), "中文标题");
|
||||
assert.equal(stripWrappingQuotes("plain"), "plain");
|
||||
|
||||
assert.equal(toFrontmatterString("'hello'"), "hello");
|
||||
assert.equal(toFrontmatterString(42), "42");
|
||||
assert.equal(toFrontmatterString(false), "false");
|
||||
assert.equal(toFrontmatterString({}), undefined);
|
||||
|
||||
assert.equal(
|
||||
pickFirstString({ summary: 123, title: "" }, ["title", "summary"]),
|
||||
"123",
|
||||
);
|
||||
});
|
||||
|
||||
test("markdown title and summary extraction skip non-body content and clean formatting", () => {
|
||||
const markdown = `
|
||||

|
||||
## “My Title”
|
||||
|
||||
Body paragraph
|
||||
`;
|
||||
assert.equal(extractTitleFromMarkdown(markdown), "My Title");
|
||||
|
||||
const summary = extractSummaryFromBody(
|
||||
`
|
||||
# Heading
|
||||
> quote
|
||||
- list
|
||||
1. ordered
|
||||
\`\`\`
|
||||
code
|
||||
\`\`\`
|
||||
This is **the first paragraph** with [a link](https://example.com) and \`inline code\` that should be summarized cleanly.
|
||||
`,
|
||||
70,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
summary,
|
||||
"This is the first paragraph with a link and inline code that should...",
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import test, { type TestContext } from "node:test";
|
||||
|
||||
import { COLOR_PRESETS, FONT_FAMILY_MAP } from "./constants.ts";
|
||||
import {
|
||||
buildMarkdownDocumentMeta,
|
||||
formatTimestamp,
|
||||
renderMarkdownDocument,
|
||||
resolveColorToken,
|
||||
resolveFontFamilyToken,
|
||||
resolveMarkdownStyle,
|
||||
resolveRenderOptions,
|
||||
} from "./document.ts";
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, `\\$&`);
|
||||
}
|
||||
|
||||
function findInlineStyle(html: string, tagName: string, text: string): string {
|
||||
const pattern = new RegExp(
|
||||
`<${tagName}[^>]*style="([^"]*)"[^>]*>${escapeRegExp(text)}</${tagName}>`,
|
||||
);
|
||||
const match = html.match(pattern);
|
||||
assert.ok(match, `Expected inline style for <${tagName}>${text}</${tagName}>`);
|
||||
return match![1]!;
|
||||
}
|
||||
|
||||
function useCwd(t: TestContext, cwd: string): void {
|
||||
const previous = process.cwd();
|
||||
process.chdir(cwd);
|
||||
t.after(() => {
|
||||
process.chdir(previous);
|
||||
});
|
||||
}
|
||||
|
||||
async function makeTempDir(prefix: string): Promise<string> {
|
||||
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
test("document token resolvers map known presets and allow passthrough values", () => {
|
||||
assert.equal(resolveColorToken("green"), COLOR_PRESETS.green);
|
||||
assert.equal(resolveColorToken("#123456"), "#123456");
|
||||
assert.equal(resolveColorToken(), undefined);
|
||||
|
||||
assert.equal(resolveFontFamilyToken("mono"), FONT_FAMILY_MAP.mono);
|
||||
assert.equal(resolveFontFamilyToken("Custom Font"), "Custom Font");
|
||||
assert.equal(resolveFontFamilyToken(), undefined);
|
||||
});
|
||||
|
||||
test("formatTimestamp uses compact sortable datetime output", () => {
|
||||
const date = new Date("2026-03-13T21:04:05.000Z");
|
||||
const pad = (value: number) => String(value).padStart(2, "0");
|
||||
const expected = `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(
|
||||
date.getDate(),
|
||||
)}${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`;
|
||||
|
||||
assert.equal(formatTimestamp(date), expected);
|
||||
});
|
||||
|
||||
test("buildMarkdownDocumentMeta prefers frontmatter and falls back to markdown title and summary", () => {
|
||||
const metaFromYaml = buildMarkdownDocumentMeta(
|
||||
"# Markdown Title\n\nBody summary paragraph that should be ignored.",
|
||||
{
|
||||
title: `" YAML Title "`,
|
||||
author: "'Baoyu'",
|
||||
summary: `" YAML Summary "`,
|
||||
},
|
||||
"fallback",
|
||||
);
|
||||
|
||||
assert.deepEqual(metaFromYaml, {
|
||||
title: "YAML Title",
|
||||
author: "Baoyu",
|
||||
description: "YAML Summary",
|
||||
});
|
||||
|
||||
const metaFromMarkdown = buildMarkdownDocumentMeta(
|
||||
`## “Markdown Title”\n\nThis is the first body paragraph that should become the summary because it is long enough.`,
|
||||
{},
|
||||
"fallback",
|
||||
);
|
||||
|
||||
assert.equal(metaFromMarkdown.title, "Markdown Title");
|
||||
assert.match(metaFromMarkdown.description ?? "", /^This is the first body paragraph/);
|
||||
});
|
||||
|
||||
test("resolveMarkdownStyle merges theme defaults with explicit overrides", () => {
|
||||
const style = resolveMarkdownStyle({
|
||||
theme: "modern",
|
||||
primaryColor: "#112233",
|
||||
fontFamily: "Custom Sans",
|
||||
});
|
||||
|
||||
assert.equal(style.primaryColor, "#112233");
|
||||
assert.equal(style.fontFamily, "Custom Sans");
|
||||
assert.equal(style.fontSize, "15px");
|
||||
assert.equal(style.containerBg, "rgba(250, 249, 245, 1)");
|
||||
});
|
||||
|
||||
test("resolveRenderOptions loads workspace EXTEND settings and lets explicit options win", async (t) => {
|
||||
const root = await makeTempDir("baoyu-md-render-options-");
|
||||
useCwd(t, root);
|
||||
|
||||
const extendPath = path.join(
|
||||
root,
|
||||
".baoyu-skills",
|
||||
"baoyu-markdown-to-html",
|
||||
"EXTEND.md",
|
||||
);
|
||||
await fs.mkdir(path.dirname(extendPath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
extendPath,
|
||||
`---
|
||||
default_theme: modern
|
||||
default_color: green
|
||||
default_font_family: mono
|
||||
default_font_size: 17
|
||||
default_code_theme: nord
|
||||
mac_code_block: false
|
||||
show_line_number: true
|
||||
cite: true
|
||||
count: true
|
||||
legend: title-alt
|
||||
keep_title: true
|
||||
---
|
||||
`,
|
||||
);
|
||||
|
||||
const fromExtend = resolveRenderOptions();
|
||||
assert.equal(fromExtend.theme, "modern");
|
||||
assert.equal(fromExtend.primaryColor, COLOR_PRESETS.green);
|
||||
assert.equal(fromExtend.fontFamily, FONT_FAMILY_MAP.mono);
|
||||
assert.equal(fromExtend.fontSize, "17px");
|
||||
assert.equal(fromExtend.codeTheme, "nord");
|
||||
assert.equal(fromExtend.isMacCodeBlock, false);
|
||||
assert.equal(fromExtend.isShowLineNumber, true);
|
||||
assert.equal(fromExtend.citeStatus, true);
|
||||
assert.equal(fromExtend.countStatus, true);
|
||||
assert.equal(fromExtend.legend, "title-alt");
|
||||
assert.equal(fromExtend.keepTitle, true);
|
||||
|
||||
const explicit = resolveRenderOptions({
|
||||
theme: "simple",
|
||||
fontSize: "18px",
|
||||
keepTitle: false,
|
||||
});
|
||||
assert.equal(explicit.theme, "simple");
|
||||
assert.equal(explicit.fontSize, "18px");
|
||||
assert.equal(explicit.keepTitle, false);
|
||||
});
|
||||
|
||||
test("renderMarkdownDocument layers default rules into grace theme before CSS inlining", async () => {
|
||||
const { html } = await renderMarkdownDocument(
|
||||
`## Section\n\nParagraph with **bold** text.`,
|
||||
{ keepTitle: true, theme: "grace" },
|
||||
);
|
||||
|
||||
const h2Style = findInlineStyle(html, "h2", "Section");
|
||||
assert.match(h2Style, /background: #92617E/);
|
||||
assert.match(h2Style, /box-shadow: 0 4px 6px rgba\(0, 0, 0, 0\.1\)/);
|
||||
|
||||
const pMatch = html.match(/<p[^>]*style="([^"]*)"[^>]*>/);
|
||||
assert.ok(pMatch, "Expected inline style on <p> tag");
|
||||
assert.match(pMatch![1]!, /color:/);
|
||||
|
||||
const strongPattern = /<strong[^>]*style="([^"]*)"[^>]*>bold<\/strong>/;
|
||||
const strongMatch = html.match(strongPattern);
|
||||
assert.ok(strongMatch, "Expected inline style for <strong>bold</strong>");
|
||||
assert.match(strongMatch![1]!, /font-weight:/);
|
||||
});
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { DEFAULT_STYLE } from "./constants.ts";
|
||||
import {
|
||||
buildCss,
|
||||
buildHtmlDocument,
|
||||
modifyHtmlStructure,
|
||||
normalizeCssText,
|
||||
normalizeInlineCss,
|
||||
removeFirstHeading,
|
||||
} from "./html-builder.ts";
|
||||
|
||||
test("buildCss injects style variables and concatenates base and theme CSS", () => {
|
||||
const css = buildCss("body { color: red; }", ".theme { color: blue; }");
|
||||
|
||||
assert.match(css, /--md-primary-color: #0F4C81;/);
|
||||
assert.match(css, /body \{ color: red; \}/);
|
||||
assert.match(css, /\.theme \{ color: blue; \}/);
|
||||
});
|
||||
|
||||
test("buildHtmlDocument includes optional meta tags and code theme CSS", () => {
|
||||
const html = buildHtmlDocument(
|
||||
{
|
||||
title: "Doc",
|
||||
author: "Baoyu",
|
||||
description: "Summary",
|
||||
},
|
||||
"body { color: red; }",
|
||||
"<article>Hello</article>",
|
||||
".hljs { color: blue; }",
|
||||
);
|
||||
|
||||
assert.match(html, /<title>Doc<\/title>/);
|
||||
assert.match(html, /meta name="author" content="Baoyu"/);
|
||||
assert.match(html, /meta name="description" content="Summary"/);
|
||||
assert.match(html, /<style>body \{ color: red; \}<\/style>/);
|
||||
assert.match(html, /<style>\.hljs \{ color: blue; \}<\/style>/);
|
||||
assert.match(html, /<article>Hello<\/article>/);
|
||||
});
|
||||
|
||||
test("normalizeCssText and normalizeInlineCss replace variables and strip declarations", () => {
|
||||
const rawCss = `
|
||||
:root { --md-primary-color: #000; --md-font-size: 12px; --foreground: 0 0% 5%; }
|
||||
.box { color: var(--md-primary-color); font-size: var(--md-font-size); background: hsl(var(--foreground)); }
|
||||
`;
|
||||
|
||||
const normalizedCss = normalizeCssText(rawCss, DEFAULT_STYLE);
|
||||
assert.match(normalizedCss, /color: #0F4C81/);
|
||||
assert.match(normalizedCss, /font-size: 16px/);
|
||||
assert.match(normalizedCss, /background: #3f3f3f/);
|
||||
assert.doesNotMatch(normalizedCss, /--md-primary-color/);
|
||||
|
||||
const normalizedHtml = normalizeInlineCss(
|
||||
`<style>${rawCss}</style><div style="color: var(--md-primary-color)"></div>`,
|
||||
DEFAULT_STYLE,
|
||||
);
|
||||
assert.match(normalizedHtml, /color: #0F4C81/);
|
||||
assert.doesNotMatch(normalizedHtml, /var\(--md-primary-color\)/);
|
||||
});
|
||||
|
||||
test("normalizeInlineCss removes quoted custom property values without leaving fragments behind", () => {
|
||||
const normalizedHtml = normalizeInlineCss(
|
||||
`<html style="--md-font-family: Menlo, Monaco, 'Courier New', monospace; color: var(--md-primary-color)"></html>`,
|
||||
DEFAULT_STYLE,
|
||||
);
|
||||
|
||||
assert.match(normalizedHtml, /style=" color: #0F4C81"/);
|
||||
assert.doesNotMatch(normalizedHtml, /Courier New/);
|
||||
assert.doesNotMatch(normalizedHtml, /--md-font-family/);
|
||||
});
|
||||
|
||||
test("HTML structure helpers hoist nested lists and remove the first heading", () => {
|
||||
const nestedList = `<ul><li>Parent<ul><li>Child</li></ul></li></ul>`;
|
||||
assert.equal(
|
||||
modifyHtmlStructure(nestedList),
|
||||
`<ul><li>Parent</li><ul><li>Child</li></ul></ul>`,
|
||||
);
|
||||
|
||||
const html = `<h1>Title</h1><p>Intro</p><h2>Sub</h2>`;
|
||||
assert.equal(removeFirstHeading(html), `<p>Intro</p><h2>Sub</h2>`);
|
||||
});
|
||||
@@ -100,13 +100,13 @@ export function normalizeCssText(cssText: string, style: StyleConfig = DEFAULT_S
|
||||
.replace(/var\(--md-accent-color\)/g, style.accentColor)
|
||||
.replace(/var\(--md-container-bg\)/g, style.containerBg)
|
||||
.replace(/hsl\(var\(--foreground\)\)/g, "#3f3f3f")
|
||||
.replace(/--md-primary-color:\s*[^;"']+;?/g, "")
|
||||
.replace(/--md-font-family:\s*[^;"']+;?/g, "")
|
||||
.replace(/--md-font-size:\s*[^;"']+;?/g, "")
|
||||
.replace(/--blockquote-background:\s*[^;"']+;?/g, "")
|
||||
.replace(/--md-accent-color:\s*[^;"']+;?/g, "")
|
||||
.replace(/--md-container-bg:\s*[^;"']+;?/g, "")
|
||||
.replace(/--foreground:\s*[^;"']+;?/g, "");
|
||||
.replace(/--md-primary-color:\s*[^;]+;?/g, "")
|
||||
.replace(/--md-font-family:\s*[^;]+;?/g, "")
|
||||
.replace(/--md-font-size:\s*[^;]+;?/g, "")
|
||||
.replace(/--blockquote-background:\s*[^;]+;?/g, "")
|
||||
.replace(/--md-accent-color:\s*[^;]+;?/g, "")
|
||||
.replace(/--md-container-bg:\s*[^;]+;?/g, "")
|
||||
.replace(/--foreground:\s*[^;]+;?/g, "");
|
||||
}
|
||||
|
||||
export function normalizeInlineCss(html: string, style: StyleConfig = DEFAULT_STYLE): string {
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
getImageExtension,
|
||||
replaceMarkdownImagesWithPlaceholders,
|
||||
resolveContentImages,
|
||||
resolveImagePath,
|
||||
} from "./images.ts";
|
||||
|
||||
async function makeTempDir(prefix: string): Promise<string> {
|
||||
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
test("replaceMarkdownImagesWithPlaceholders rewrites markdown and tracks image metadata", () => {
|
||||
const result = replaceMarkdownImagesWithPlaceholders(
|
||||
`\n\nText\n\n`,
|
||||
"IMG_",
|
||||
);
|
||||
|
||||
assert.equal(result.markdown, `IMG_1\n\nText\n\nIMG_2`);
|
||||
assert.deepEqual(result.images, [
|
||||
{ alt: "cover", originalPath: "images/cover.png", placeholder: "IMG_1" },
|
||||
{ alt: "diagram", originalPath: "images/diagram.webp", placeholder: "IMG_2" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("image extension and local fallback resolution handle common path variants", async (t) => {
|
||||
assert.equal(getImageExtension("https://example.com/a.jpeg?x=1"), "jpeg");
|
||||
assert.equal(getImageExtension("/tmp/figure"), "png");
|
||||
|
||||
const root = await makeTempDir("baoyu-md-images-");
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
|
||||
const baseDir = path.join(root, "article");
|
||||
const tempDir = path.join(root, "tmp");
|
||||
await fs.mkdir(baseDir, { recursive: true });
|
||||
await fs.mkdir(tempDir, { recursive: true });
|
||||
await fs.writeFile(path.join(baseDir, "figure.webp"), "webp");
|
||||
|
||||
const resolved = await resolveImagePath("figure.png", baseDir, tempDir, "test");
|
||||
assert.equal(resolved, path.join(baseDir, "figure.webp"));
|
||||
});
|
||||
|
||||
test("resolveContentImages resolves image placeholders against the content directory", async (t) => {
|
||||
const root = await makeTempDir("baoyu-md-content-images-");
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
|
||||
const baseDir = path.join(root, "article");
|
||||
const tempDir = path.join(root, "tmp");
|
||||
await fs.mkdir(baseDir, { recursive: true });
|
||||
await fs.mkdir(tempDir, { recursive: true });
|
||||
await fs.writeFile(path.join(baseDir, "cover.png"), "png");
|
||||
|
||||
const resolved = await resolveContentImages(
|
||||
[
|
||||
{
|
||||
alt: "cover",
|
||||
originalPath: "cover.png",
|
||||
placeholder: "IMG_1",
|
||||
},
|
||||
],
|
||||
baseDir,
|
||||
tempDir,
|
||||
"test",
|
||||
);
|
||||
|
||||
assert.deepEqual(resolved, [
|
||||
{
|
||||
alt: "cover",
|
||||
originalPath: "cover.png",
|
||||
placeholder: "IMG_1",
|
||||
localPath: path.join(baseDir, "cover.png"),
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { initRenderer, renderMarkdown } from "./renderer.ts";
|
||||
|
||||
const render = (md: string) => {
|
||||
const r = initRenderer();
|
||||
return renderMarkdown(md, r).html;
|
||||
};
|
||||
|
||||
test("bold with inline code (no underscore)", () => {
|
||||
const html = render("**算出 `logits`,算出 `loss`。**");
|
||||
assert.match(html, /<code[^>]*>logits<\/code>/);
|
||||
assert.match(html, /<code[^>]*>loss<\/code>/);
|
||||
});
|
||||
|
||||
test("bold with inline code (contains underscore)", () => {
|
||||
const html = render("**变成 `input_ids`。**");
|
||||
assert.match(html, /<code[^>]*>input_ids<\/code>/);
|
||||
});
|
||||
|
||||
test("emphasis with inline code", () => {
|
||||
const html = render("*查看 `hidden_states`*");
|
||||
assert.match(html, /<code[^>]*>hidden_states<\/code>/);
|
||||
});
|
||||
|
||||
test("plain inline code (regression)", () => {
|
||||
const html = render("`lm_head`");
|
||||
assert.match(html, /<code[^>]*>lm_head<\/code>/);
|
||||
});
|
||||
|
||||
test("bold without code (regression)", () => {
|
||||
const html = render("**纯粗体文本**");
|
||||
assert.match(html, /<strong[^>]*>纯粗体文本<\/strong>/);
|
||||
assert.doesNotMatch(html, /<code/);
|
||||
});
|
||||
|
||||
test("bold with inline code containing backticks", () => {
|
||||
const html = render("**``a`b``**");
|
||||
assert.match(html, /<code[^>]*>a`b<\/code>/);
|
||||
});
|
||||
|
||||
test("emphasis with inline code containing backticks", () => {
|
||||
const html = render("*``a`b``*");
|
||||
assert.match(html, /<em[^>]*><code[^>]*>a`b<\/code><\/em>/);
|
||||
});
|
||||
|
||||
test("bold with inline code containing consecutive backticks", () => {
|
||||
const html = render("**```a``b```**");
|
||||
assert.match(html, /<code[^>]*>a``b<\/code>/);
|
||||
});
|
||||
|
||||
test("bold with inline code containing only backticks", () => {
|
||||
const html = render("**```` `` ````**");
|
||||
assert.match(html, /<code[^>]*>``<\/code>/);
|
||||
});
|
||||
|
||||
test("bold with inline code containing only spaces", () => {
|
||||
const oneSpace = render("**`` ``**");
|
||||
assert.match(oneSpace, /<code[^>]*> <\/code>/);
|
||||
|
||||
const twoSpaces = render("**`` ``**");
|
||||
assert.match(twoSpaces, /<code[^>]*> <\/code>/);
|
||||
});
|
||||
@@ -109,6 +109,13 @@ function parseFrontMatterAndContent(markdownText: string): ParseResult {
|
||||
}
|
||||
}
|
||||
|
||||
function wrapInlineCode(value: string): string {
|
||||
const runs = value.match(/`+/g);
|
||||
const fence = "`".repeat(Math.max(...(runs?.map((run) => run.length) ?? [0])) + 1);
|
||||
const padding = /^ *$/.test(value) ? "" : " ";
|
||||
return `${fence}${padding}${value}${padding}${fence}`;
|
||||
}
|
||||
|
||||
export function initRenderer(opts: IOpts = {}): RendererAPI {
|
||||
const footnotes: [number, string, string][] = [];
|
||||
let footnoteIndex = 0;
|
||||
@@ -369,6 +376,7 @@ function preprocessCjkEmphasis(markdown: string): string {
|
||||
const tree = processor.parse(markdown);
|
||||
const extractText = (node: any): string => {
|
||||
if (node.type === "text") return node.value;
|
||||
if (node.type === "inlineCode") return wrapInlineCode(node.value);
|
||||
if (node.children) return node.children.map(extractText).join("");
|
||||
return "";
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { ThemeName } from "./types.js";
|
||||
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
||||
export const THEME_DIR = path.resolve(SCRIPT_DIR, "themes");
|
||||
const FALLBACK_THEMES: ThemeName[] = ["default", "grace", "simple"];
|
||||
const THEMES_EXTENDING_DEFAULT = new Set<ThemeName>(["grace", "simple"]);
|
||||
|
||||
function stripOutputScope(cssContent: string): string {
|
||||
let css = cssContent;
|
||||
@@ -41,6 +42,7 @@ export function loadThemeCss(theme: ThemeName): {
|
||||
themeCss: string;
|
||||
} {
|
||||
const basePath = path.join(THEME_DIR, "base.css");
|
||||
const defaultThemePath = path.join(THEME_DIR, "default.css");
|
||||
const themePath = path.join(THEME_DIR, `${theme}.css`);
|
||||
|
||||
if (!fs.existsSync(basePath)) {
|
||||
@@ -51,9 +53,18 @@ export function loadThemeCss(theme: ThemeName): {
|
||||
throw new Error(`Missing theme CSS for "${theme}": ${themePath}`);
|
||||
}
|
||||
|
||||
const layeredThemeCss: string[] = [];
|
||||
if (theme !== "default" && THEMES_EXTENDING_DEFAULT.has(theme)) {
|
||||
if (!fs.existsSync(defaultThemePath)) {
|
||||
throw new Error(`Missing default theme CSS: ${defaultThemePath}`);
|
||||
}
|
||||
layeredThemeCss.push(fs.readFileSync(defaultThemePath, "utf-8"));
|
||||
}
|
||||
layeredThemeCss.push(fs.readFileSync(themePath, "utf-8"));
|
||||
|
||||
return {
|
||||
baseCss: fs.readFileSync(basePath, "utf-8"),
|
||||
themeCss: fs.readFileSync(themePath, "utf-8"),
|
||||
themeCss: layeredThemeCss.join("\n"),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -4,32 +4,108 @@
|
||||
"": {
|
||||
"name": "baoyu-post-to-wechat-scripts",
|
||||
"dependencies": {
|
||||
"@jsquash/webp": "^1.5.0",
|
||||
"baoyu-chrome-cdp": "file:./vendor/baoyu-chrome-cdp",
|
||||
"baoyu-md": "file:./vendor/baoyu-md",
|
||||
"jimp": "^1.6.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@jimp/core": ["@jimp/core@1.6.0", "", { "dependencies": { "@jimp/file-ops": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "await-to-js": "^3.0.0", "exif-parser": "^0.1.12", "file-type": "^16.0.0", "mime": "3" } }, "sha512-EQQlKU3s9QfdJqiSrZWNTxBs3rKXgO2W+GxNXDtwchF3a4IqxDheFX1ti+Env9hdJXDiYLp2jTRjlxhPthsk8w=="],
|
||||
|
||||
"@jimp/diff": ["@jimp/diff@1.6.0", "", { "dependencies": { "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "pixelmatch": "^5.3.0" } }, "sha512-+yUAQ5gvRC5D1WHYxjBHZI7JBRusGGSLf8AmPRPCenTzh4PA+wZ1xv2+cYqQwTfQHU5tXYOhA0xDytfHUf1Zyw=="],
|
||||
|
||||
"@jimp/file-ops": ["@jimp/file-ops@1.6.0", "", {}, "sha512-Dx/bVDmgnRe1AlniRpCKrGRm5YvGmUwbDzt+MAkgmLGf+jvBT75hmMEZ003n9HQI/aPnm/YKnXjg/hOpzNCpHQ=="],
|
||||
|
||||
"@jimp/js-bmp": ["@jimp/js-bmp@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "bmp-ts": "^1.0.9" } }, "sha512-FU6Q5PC/e3yzLyBDXupR3SnL3htU7S3KEs4e6rjDP6gNEOXRFsWs6YD3hXuXd50jd8ummy+q2WSwuGkr8wi+Gw=="],
|
||||
|
||||
"@jimp/js-gif": ["@jimp/js-gif@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "gifwrap": "^0.10.1", "omggif": "^1.0.10" } }, "sha512-N9CZPHOrJTsAUoWkWZstLPpwT5AwJ0wge+47+ix3++SdSL/H2QzyMqxbcDYNFe4MoI5MIhATfb0/dl/wmX221g=="],
|
||||
|
||||
"@jimp/js-jpeg": ["@jimp/js-jpeg@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "jpeg-js": "^0.4.4" } }, "sha512-6vgFDqeusblf5Pok6B2DUiMXplH8RhIKAryj1yn+007SIAQ0khM1Uptxmpku/0MfbClx2r7pnJv9gWpAEJdMVA=="],
|
||||
|
||||
"@jimp/js-png": ["@jimp/js-png@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "pngjs": "^7.0.0" } }, "sha512-AbQHScy3hDDgMRNfG0tPjL88AV6qKAILGReIa3ATpW5QFjBKpisvUaOqhzJ7Reic1oawx3Riyv152gaPfqsBVg=="],
|
||||
|
||||
"@jimp/js-tiff": ["@jimp/js-tiff@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "utif2": "^4.1.0" } }, "sha512-zhReR8/7KO+adijj3h0ZQUOiun3mXUv79zYEAKvE0O+rP7EhgtKvWJOZfRzdZSNv0Pu1rKtgM72qgtwe2tFvyw=="],
|
||||
|
||||
"@jimp/plugin-blit": ["@jimp/plugin-blit@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-M+uRWl1csi7qilnSK8uxK4RJMSuVeBiO1AY0+7APnfUbQNZm6hCe0CCFv1Iyw1D/Dhb8ph8fQgm5mwM0eSxgVA=="],
|
||||
|
||||
"@jimp/plugin-blur": ["@jimp/plugin-blur@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/utils": "1.6.0" } }, "sha512-zrM7iic1OTwUCb0g/rN5y+UnmdEsT3IfuCXCJJNs8SZzP0MkZ1eTvuwK9ZidCuMo4+J3xkzCidRwYXB5CyGZTw=="],
|
||||
|
||||
"@jimp/plugin-circle": ["@jimp/plugin-circle@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-xt1Gp+LtdMKAXfDp3HNaG30SPZW6AQ7dtAtTnoRKorRi+5yCJjKqXRgkewS5bvj8DEh87Ko1ydJfzqS3P2tdWw=="],
|
||||
|
||||
"@jimp/plugin-color": ["@jimp/plugin-color@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "tinycolor2": "^1.6.0", "zod": "^3.23.8" } }, "sha512-J5q8IVCpkBsxIXM+45XOXTrsyfblyMZg3a9eAo0P7VPH4+CrvyNQwaYatbAIamSIN1YzxmO3DkIZXzRjFSz1SA=="],
|
||||
|
||||
"@jimp/plugin-contain": ["@jimp/plugin-contain@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/plugin-blit": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-oN/n+Vdq/Qg9bB4yOBOxtY9IPAtEfES8J1n9Ddx+XhGBYT1/QTU/JYkGaAkIGoPnyYvmLEDqMz2SGihqlpqfzQ=="],
|
||||
|
||||
"@jimp/plugin-cover": ["@jimp/plugin-cover@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/plugin-crop": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-Iow0h6yqSC269YUJ8HC3Q/MpCi2V55sMlbkkTTx4zPvd8mWZlC0ykrNDeAy9IJegrQ7v5E99rJwmQu25lygKLA=="],
|
||||
|
||||
"@jimp/plugin-crop": ["@jimp/plugin-crop@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-KqZkEhvs+21USdySCUDI+GFa393eDIzbi1smBqkUPTE+pRwSWMAf01D5OC3ZWB+xZsNla93BDS9iCkLHA8wang=="],
|
||||
|
||||
"@jimp/plugin-displace": ["@jimp/plugin-displace@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-4Y10X9qwr5F+Bo5ME356XSACEF55485j5nGdiyJ9hYzjQP9nGgxNJaZ4SAOqpd+k5sFaIeD7SQ0Occ26uIng5Q=="],
|
||||
|
||||
"@jimp/plugin-dither": ["@jimp/plugin-dither@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0" } }, "sha512-600d1RxY0pKwgyU0tgMahLNKsqEcxGdbgXadCiVCoGd6V6glyCvkNrnnwC0n5aJ56Htkj88PToSdF88tNVZEEQ=="],
|
||||
|
||||
"@jimp/plugin-fisheye": ["@jimp/plugin-fisheye@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-E5QHKWSCBFtpgZarlmN3Q6+rTQxjirFqo44ohoTjzYVrDI6B6beXNnPIThJgPr0Y9GwfzgyarKvQuQuqCnnfbA=="],
|
||||
|
||||
"@jimp/plugin-flip": ["@jimp/plugin-flip@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-/+rJVDuBIVOgwoyVkBjUFHtP+wmW0r+r5OQ2GpatQofToPVbJw1DdYWXlwviSx7hvixTWLKVgRWQ5Dw862emDg=="],
|
||||
|
||||
"@jimp/plugin-hash": ["@jimp/plugin-hash@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/js-bmp": "1.6.0", "@jimp/js-jpeg": "1.6.0", "@jimp/js-png": "1.6.0", "@jimp/js-tiff": "1.6.0", "@jimp/plugin-color": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "any-base": "^1.1.0" } }, "sha512-wWzl0kTpDJgYVbZdajTf+4NBSKvmI3bRI8q6EH9CVeIHps9VWVsUvEyb7rpbcwVLWYuzDtP2R0lTT6WeBNQH9Q=="],
|
||||
|
||||
"@jimp/plugin-mask": ["@jimp/plugin-mask@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-Cwy7ExSJMZszvkad8NV8o/Z92X2kFUFM8mcDAhNVxU0Q6tA0op2UKRJY51eoK8r6eds/qak3FQkXakvNabdLnA=="],
|
||||
|
||||
"@jimp/plugin-print": ["@jimp/plugin-print@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/js-jpeg": "1.6.0", "@jimp/js-png": "1.6.0", "@jimp/plugin-blit": "1.6.0", "@jimp/types": "1.6.0", "parse-bmfont-ascii": "^1.0.6", "parse-bmfont-binary": "^1.0.6", "parse-bmfont-xml": "^1.1.6", "simple-xml-to-json": "^1.2.2", "zod": "^3.23.8" } }, "sha512-zarTIJi8fjoGMSI/M3Xh5yY9T65p03XJmPsuNet19K/Q7mwRU6EV2pfj+28++2PV2NJ+htDF5uecAlnGyxFN2A=="],
|
||||
|
||||
"@jimp/plugin-quantize": ["@jimp/plugin-quantize@1.6.0", "", { "dependencies": { "image-q": "^4.0.0", "zod": "^3.23.8" } }, "sha512-EmzZ/s9StYQwbpG6rUGBCisc3f64JIhSH+ncTJd+iFGtGo0YvSeMdAd+zqgiHpfZoOL54dNavZNjF4otK+mvlg=="],
|
||||
|
||||
"@jimp/plugin-resize": ["@jimp/plugin-resize@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-uSUD1mqXN9i1SGSz5ov3keRZ7S9L32/mAQG08wUwZiEi5FpbV0K8A8l1zkazAIZi9IJzLlTauRNU41Mi8IF9fA=="],
|
||||
|
||||
"@jimp/plugin-rotate": ["@jimp/plugin-rotate@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/plugin-crop": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-JagdjBLnUZGSG4xjCLkIpQOZZ3Mjbg8aGCCi4G69qR+OjNpOeGI7N2EQlfK/WE8BEHOW5vdjSyglNqcYbQBWRw=="],
|
||||
|
||||
"@jimp/plugin-threshold": ["@jimp/plugin-threshold@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/plugin-color": "1.6.0", "@jimp/plugin-hash": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-M59m5dzLoHOVWdM41O8z9SyySzcDn43xHseOH0HavjsfQsT56GGCC4QzU1banJidbUrePhzoEdS42uFE8Fei8w=="],
|
||||
|
||||
"@jimp/types": ["@jimp/types@1.6.0", "", { "dependencies": { "zod": "^3.23.8" } }, "sha512-7UfRsiKo5GZTAATxm2qQ7jqmUXP0DxTArztllTcYdyw6Xi5oT4RaoXynVtCD4UyLK5gJgkZJcwonoijrhYFKfg=="],
|
||||
|
||||
"@jimp/utils": ["@jimp/utils@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "tinycolor2": "^1.6.0" } }, "sha512-gqFTGEosKbOkYF/WFj26jMHOI5OH2jeP1MmC/zbK6BF6VJBf8rIC5898dPfSzZEbSA0wbbV5slbntWVc5PKLFA=="],
|
||||
|
||||
"@jsquash/webp": ["@jsquash/webp@1.5.0", "", { "dependencies": { "wasm-feature-detect": "^1.2.11" } }, "sha512-KggLoj2MnRSfIqTeKe1EmbljTX2vuV7mh79k89PCL1pyqiDULcPM1L47twxXt0hkb68F70bXiL31MxsuoZtKFw=="],
|
||||
|
||||
"@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="],
|
||||
|
||||
"@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="],
|
||||
|
||||
"@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="],
|
||||
|
||||
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
|
||||
|
||||
"@types/node": ["@types/node@16.9.1", "", {}, "sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g=="],
|
||||
|
||||
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
|
||||
|
||||
"abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
|
||||
|
||||
"ansi-colors": ["ansi-colors@4.1.3", "", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="],
|
||||
|
||||
"any-base": ["any-base@1.1.0", "", {}, "sha512-uMgjozySS8adZZYePpaWs8cxB9/kdzmpX6SgJZ+wbz1K5eYk5QMYDVJaZKhxyIHUdnnJkfR7SVgStgH7LkGUyg=="],
|
||||
|
||||
"argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
|
||||
|
||||
"await-to-js": ["await-to-js@3.0.0", "", {}, "sha512-zJAaP9zxTcvTHRlejau3ZOY4V7SRpiByf3/dxx2uyKxxor19tpmpV2QRsTKikckwhaPmr2dVpxxMr7jOCYVp5g=="],
|
||||
|
||||
"bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
|
||||
|
||||
"baoyu-chrome-cdp": ["baoyu-chrome-cdp@file:vendor/baoyu-chrome-cdp", {}],
|
||||
|
||||
"baoyu-md": ["baoyu-md@file:vendor/baoyu-md", { "dependencies": { "fflate": "^0.8.2", "front-matter": "^4.0.2", "highlight.js": "^11.11.1", "juice": "^11.0.1", "marked": "^15.0.6", "reading-time": "^1.5.0", "remark-cjk-friendly": "^1.1.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.5" } }],
|
||||
|
||||
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
|
||||
|
||||
"bmp-ts": ["bmp-ts@1.0.9", "", {}, "sha512-cTEHk2jLrPyi+12M3dhpEbnnPOsaZuq7C45ylbbQIiWgDFZq4UVYPEY5mlqjvsj/6gJv9qX5sa+ebDzLXT28Vw=="],
|
||||
|
||||
"boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="],
|
||||
|
||||
"buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="],
|
||||
|
||||
"character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="],
|
||||
|
||||
"cheerio": ["cheerio@1.0.0", "", { "dependencies": { "cheerio-select": "^2.1.0", "dom-serializer": "^2.0.0", "domhandler": "^5.0.3", "domutils": "^3.1.0", "encoding-sniffer": "^0.2.0", "htmlparser2": "^9.1.0", "parse5": "^7.1.2", "parse5-htmlparser2-tree-adapter": "^7.0.0", "parse5-parser-stream": "^7.1.2", "undici": "^6.19.5", "whatwg-mimetype": "^4.0.0" } }, "sha512-quS9HgjQpdaXOvsZz82Oz7uxtXiy6UIsIQcpBj7HRw2M63Skasm9qlDocAM7jNuaxdhpPU7c4kJN+gA5MCu4ww=="],
|
||||
@@ -66,22 +142,40 @@
|
||||
|
||||
"esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="],
|
||||
|
||||
"event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="],
|
||||
|
||||
"events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="],
|
||||
|
||||
"exif-parser": ["exif-parser@0.1.12", "", {}, "sha512-c2bQfLNbMzLPmzQuOr8fy0csy84WmwnER81W88DzTp9CYNPJ6yzOj2EZAh9pywYpqHnshVLHQJ8WzldAyfY+Iw=="],
|
||||
|
||||
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
|
||||
|
||||
"fflate": ["fflate@0.8.2", "", {}, "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="],
|
||||
|
||||
"file-type": ["file-type@16.5.4", "", { "dependencies": { "readable-web-to-node-stream": "^3.0.0", "strtok3": "^6.2.4", "token-types": "^4.1.1" } }, "sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw=="],
|
||||
|
||||
"front-matter": ["front-matter@4.0.2", "", { "dependencies": { "js-yaml": "^3.13.1" } }, "sha512-I8ZuJ/qG92NWX8i5x1Y8qyj3vizhXS31OxjKDu3LKP+7/qBgfIKValiZIEwoVoJKUHlhWtYrktkxV1XsX+pPlg=="],
|
||||
|
||||
"get-east-asian-width": ["get-east-asian-width@1.5.0", "", {}, "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA=="],
|
||||
|
||||
"gifwrap": ["gifwrap@0.10.1", "", { "dependencies": { "image-q": "^4.0.0", "omggif": "^1.0.10" } }, "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw=="],
|
||||
|
||||
"highlight.js": ["highlight.js@11.11.1", "", {}, "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w=="],
|
||||
|
||||
"htmlparser2": ["htmlparser2@9.1.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.1.0", "entities": "^4.5.0" } }, "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ=="],
|
||||
|
||||
"iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
|
||||
|
||||
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
|
||||
|
||||
"image-q": ["image-q@4.0.0", "", { "dependencies": { "@types/node": "16.9.1" } }, "sha512-PfJGVgIfKQJuq3s0tTDOKtztksibuUEbJQIYT3by6wctQo+Rdlh7ef4evJ5NCdxY4CfMbvFkocEwbl4BF8RlJw=="],
|
||||
|
||||
"is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
|
||||
|
||||
"jimp": ["jimp@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/diff": "1.6.0", "@jimp/js-bmp": "1.6.0", "@jimp/js-gif": "1.6.0", "@jimp/js-jpeg": "1.6.0", "@jimp/js-png": "1.6.0", "@jimp/js-tiff": "1.6.0", "@jimp/plugin-blit": "1.6.0", "@jimp/plugin-blur": "1.6.0", "@jimp/plugin-circle": "1.6.0", "@jimp/plugin-color": "1.6.0", "@jimp/plugin-contain": "1.6.0", "@jimp/plugin-cover": "1.6.0", "@jimp/plugin-crop": "1.6.0", "@jimp/plugin-displace": "1.6.0", "@jimp/plugin-dither": "1.6.0", "@jimp/plugin-fisheye": "1.6.0", "@jimp/plugin-flip": "1.6.0", "@jimp/plugin-hash": "1.6.0", "@jimp/plugin-mask": "1.6.0", "@jimp/plugin-print": "1.6.0", "@jimp/plugin-quantize": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/plugin-rotate": "1.6.0", "@jimp/plugin-threshold": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0" } }, "sha512-YcwCHw1kiqEeI5xRpDlPPBGL2EOpBKLwO4yIBJcXWHPj5PnA5urGq0jbyhM5KoNpypQ6VboSoxc9D8HyfvngSg=="],
|
||||
|
||||
"jpeg-js": ["jpeg-js@0.4.4", "", {}, "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg=="],
|
||||
|
||||
"js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="],
|
||||
|
||||
"juice": ["juice@11.1.1", "", { "dependencies": { "cheerio": "1.0.0", "commander": "^12.1.0", "entities": "^7.0.0", "mensch": "^0.3.4", "slick": "^1.12.2", "web-resource-inliner": "^8.0.0" }, "bin": { "juice": "bin/juice" } }, "sha512-4SBfZqKcc6DrIS+5b/WiGoWaZsdUPBH+e6SbRlNjJpaIRtfoBhYReAtobIEW6mcLeFFDXLBJMuZwkJLkBJjs2w=="],
|
||||
@@ -146,18 +240,40 @@
|
||||
|
||||
"micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="],
|
||||
|
||||
"mime": ["mime@2.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg=="],
|
||||
"mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="],
|
||||
|
||||
"omggif": ["omggif@1.0.10", "", {}, "sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw=="],
|
||||
|
||||
"pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
|
||||
|
||||
"parse-bmfont-ascii": ["parse-bmfont-ascii@1.0.6", "", {}, "sha512-U4RrVsUFCleIOBsIGYOMKjn9PavsGOXxbvYGtMOEfnId0SVNsgehXh1DxUdVPLoxd5mvcEtvmKs2Mmf0Mpa1ZA=="],
|
||||
|
||||
"parse-bmfont-binary": ["parse-bmfont-binary@1.0.6", "", {}, "sha512-GxmsRea0wdGdYthjuUeWTMWPqm2+FAd4GI8vCvhgJsFnoGhTrLhXDDupwTo7rXVAgaLIGoVHDZS9p/5XbSqeWA=="],
|
||||
|
||||
"parse-bmfont-xml": ["parse-bmfont-xml@1.1.6", "", { "dependencies": { "xml-parse-from-string": "^1.0.0", "xml2js": "^0.5.0" } }, "sha512-0cEliVMZEhrFDwMh4SxIyVJpqYoOWDJ9P895tFuS+XuNzI5UBmBk5U5O4KuJdTnZpSBI4LFA2+ZiJaiwfSwlMA=="],
|
||||
|
||||
"parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
|
||||
|
||||
"parse5-htmlparser2-tree-adapter": ["parse5-htmlparser2-tree-adapter@7.1.0", "", { "dependencies": { "domhandler": "^5.0.3", "parse5": "^7.0.0" } }, "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g=="],
|
||||
|
||||
"parse5-parser-stream": ["parse5-parser-stream@7.1.2", "", { "dependencies": { "parse5": "^7.0.0" } }, "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow=="],
|
||||
|
||||
"peek-readable": ["peek-readable@4.1.0", "", {}, "sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg=="],
|
||||
|
||||
"pixelmatch": ["pixelmatch@5.3.0", "", { "dependencies": { "pngjs": "^6.0.0" }, "bin": { "pixelmatch": "bin/pixelmatch" } }, "sha512-o8mkY4E/+LNUf6LzX96ht6k6CEDi65k9G2rjMtBe9Oo+VPKSvl+0GKHuH/AlG+GA5LPG/i5hrekkxUc3s2HU+Q=="],
|
||||
|
||||
"pngjs": ["pngjs@7.0.0", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="],
|
||||
|
||||
"process": ["process@0.11.10", "", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="],
|
||||
|
||||
"readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="],
|
||||
|
||||
"readable-web-to-node-stream": ["readable-web-to-node-stream@3.0.4", "", { "dependencies": { "readable-stream": "^4.7.0" } }, "sha512-9nX56alTf5bwXQ3ZDipHJhusu9NTQJ/CVPtb/XHAJCXihZeitfJvIRS4GqQ/mfIoOE3IelHMrpayVrosdHBuLw=="],
|
||||
|
||||
"reading-time": ["reading-time@1.5.0", "", {}, "sha512-onYyVhBNr4CmAxFsKS7bz+uTLRakypIe4R+5A824vBSkQy/hB3fZepoVEf8OVAxzLvK+H/jm9TzpI3ETSm64Kg=="],
|
||||
|
||||
"remark-cjk-friendly": ["remark-cjk-friendly@1.2.3", "", { "dependencies": { "micromark-extension-cjk-friendly": "1.2.3" }, "peerDependencies": { "@types/mdast": "^4.0.0", "unified": "^11.0.0" }, "optionalPeers": ["@types/mdast"] }, "sha512-UvAgxwlNk+l9Oqgl/9MWK2eWRS7zgBW/nXX9AthV7nd/3lNejF138E7Xbmk9Zs4WjTJGs721r7fAEc7tNFoH7g=="],
|
||||
@@ -166,12 +282,26 @@
|
||||
|
||||
"remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="],
|
||||
|
||||
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
|
||||
|
||||
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
||||
|
||||
"sax": ["sax@1.6.0", "", {}, "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA=="],
|
||||
|
||||
"simple-xml-to-json": ["simple-xml-to-json@1.2.4", "", {}, "sha512-3MY16e0ocMHL7N1ufpdObURGyX+lCo0T/A+y6VCwosLdH1HSda4QZl1Sdt/O+2qWp48WFi26XEp5rF0LoaL0Dg=="],
|
||||
|
||||
"slick": ["slick@1.12.2", "", {}, "sha512-4qdtOGcBjral6YIBCWJ0ljFSKNLz9KkhbWtuGvUyRowl1kxfuE1x/Z/aJcaiilpb3do9bl5K7/1h9XC5wWpY/A=="],
|
||||
|
||||
"sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="],
|
||||
|
||||
"string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
|
||||
|
||||
"strtok3": ["strtok3@6.3.0", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "peek-readable": "^4.1.0" } }, "sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw=="],
|
||||
|
||||
"tinycolor2": ["tinycolor2@1.6.0", "", {}, "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw=="],
|
||||
|
||||
"token-types": ["token-types@4.2.1", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ=="],
|
||||
|
||||
"trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="],
|
||||
|
||||
"undici": ["undici@6.24.0", "", {}, "sha512-lVLNosgqo5EkGqh5XUDhGfsMSoO8K0BAN0TyJLvwNRSl4xWGZlCVYsAIpa/OpA3TvmnM01GWcoKmc3ZWo5wKKA=="],
|
||||
@@ -186,18 +316,30 @@
|
||||
|
||||
"unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="],
|
||||
|
||||
"utif2": ["utif2@4.1.0", "", { "dependencies": { "pako": "^1.0.11" } }, "sha512-+oknB9FHrJ7oW7A2WZYajOcv4FcDR4CfoGB0dPNfxbi4GO05RRnFmt5oa23+9w32EanrYcSJWspUiJkLMs+37w=="],
|
||||
|
||||
"valid-data-url": ["valid-data-url@3.0.1", "", {}, "sha512-jOWVmzVceKlVVdwjNSenT4PbGghU0SBIizAev8ofZVgivk/TVHXSbNL8LP6M3spZvkR9/QolkyJavGSX5Cs0UA=="],
|
||||
|
||||
"vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
|
||||
|
||||
"vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
|
||||
|
||||
"wasm-feature-detect": ["wasm-feature-detect@1.8.0", "", {}, "sha512-zksaLKM2fVlnB5jQQDqKXXwYHLQUVH9es+5TOOHwGOVJOCeRBCiPjwSg+3tN2AdTCzjgli4jijCH290kXb/zWQ=="],
|
||||
|
||||
"web-resource-inliner": ["web-resource-inliner@8.0.0", "", { "dependencies": { "ansi-colors": "^4.1.1", "escape-goat": "^3.0.0", "htmlparser2": "^9.1.0", "mime": "^2.4.6", "valid-data-url": "^3.0.0" } }, "sha512-Ezr98sqXW/+OCGoUEXuOKVR+oVFlSdn1tIySEEJdiSAw4IjrW8hQkwARSSBJTSB5Us5dnytDgL0ZDliAYBhaNA=="],
|
||||
|
||||
"whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="],
|
||||
|
||||
"whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="],
|
||||
|
||||
"xml-parse-from-string": ["xml-parse-from-string@1.0.1", "", {}, "sha512-ErcKwJTF54uRzzNMXq2X5sMIy88zJvfN2DmdoQvy7PAFJ+tPRU6ydWuOKNMyfmOjdyBQTFREi60s0Y0SyI0G0g=="],
|
||||
|
||||
"xml2js": ["xml2js@0.5.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA=="],
|
||||
|
||||
"xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="],
|
||||
|
||||
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
||||
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
|
||||
|
||||
"dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
||||
@@ -205,5 +347,9 @@
|
||||
"htmlparser2/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
||||
|
||||
"parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
|
||||
|
||||
"pixelmatch/pngjs": ["pngjs@6.0.0", "", {}, "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg=="],
|
||||
|
||||
"web-resource-inliner/mime": ["mime@2.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg=="],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@jsquash/webp": "^1.5.0",
|
||||
"baoyu-chrome-cdp": "file:./vendor/baoyu-chrome-cdp",
|
||||
"baoyu-md": "file:./vendor/baoyu-md"
|
||||
"baoyu-md": "file:./vendor/baoyu-md",
|
||||
"jimp": "^1.6.0"
|
||||
}
|
||||
}
|
||||
|
||||
+307
@@ -0,0 +1,307 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import http from "node:http";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import test, { type TestContext } from "node:test";
|
||||
|
||||
import {
|
||||
discoverRunningChromeDebugPort,
|
||||
findChromeExecutable,
|
||||
findExistingChromeDebugPort,
|
||||
getFreePort,
|
||||
openPageSession,
|
||||
resolveSharedChromeProfileDir,
|
||||
waitForChromeDebugPort,
|
||||
} from "./index.ts";
|
||||
|
||||
function useEnv(
|
||||
t: TestContext,
|
||||
values: Record<string, string | null>,
|
||||
): void {
|
||||
const previous = new Map<string, string | undefined>();
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
previous.set(key, process.env[key]);
|
||||
if (value == null) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
t.after(() => {
|
||||
for (const [key, value] of previous.entries()) {
|
||||
if (value == null) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function makeTempDir(prefix: string): Promise<string> {
|
||||
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
async function startDebugServer(port: number): Promise<http.Server> {
|
||||
const server = http.createServer((req, res) => {
|
||||
if (req.url === "/json/version") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({
|
||||
webSocketDebuggerUrl: `ws://127.0.0.1:${port}/devtools/browser/demo`,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(port, "127.0.0.1", () => resolve());
|
||||
});
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
async function closeServer(server: http.Server): Promise<void> {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error) reject(error);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function shellPathForPlatform(): string | null {
|
||||
if (process.platform === "win32") return null;
|
||||
return "/bin/bash";
|
||||
}
|
||||
|
||||
async function startFakeChromiumProcess(port: number): Promise<ChildProcess | null> {
|
||||
const shell = shellPathForPlatform();
|
||||
if (!shell) return null;
|
||||
|
||||
const child = spawn(
|
||||
shell,
|
||||
[
|
||||
"-lc",
|
||||
`exec -a chromium-mock ${JSON.stringify(process.execPath)} -e 'setInterval(() => {}, 1000)' -- --remote-debugging-port=${port}`,
|
||||
],
|
||||
{ stdio: "ignore" },
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
return child;
|
||||
}
|
||||
|
||||
async function stopProcess(child: ChildProcess | null): Promise<void> {
|
||||
if (!child) return;
|
||||
if (child.exitCode !== null || child.signalCode !== null) return;
|
||||
|
||||
child.kill("SIGTERM");
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
|
||||
if (child.exitCode !== null || child.signalCode !== null) return;
|
||||
await new Promise((resolve) => child.once("exit", resolve));
|
||||
}
|
||||
|
||||
test("getFreePort honors a fixed environment override and otherwise allocates a TCP port", async (t) => {
|
||||
useEnv(t, { TEST_FIXED_PORT: "45678" });
|
||||
assert.equal(await getFreePort("TEST_FIXED_PORT"), 45678);
|
||||
|
||||
const dynamicPort = await getFreePort();
|
||||
assert.ok(Number.isInteger(dynamicPort));
|
||||
assert.ok(dynamicPort > 0);
|
||||
});
|
||||
|
||||
test("findChromeExecutable prefers env overrides and falls back to candidate paths", async (t) => {
|
||||
const root = await makeTempDir("baoyu-chrome-bin-");
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
|
||||
const envChrome = path.join(root, "env-chrome");
|
||||
const fallbackChrome = path.join(root, "fallback-chrome");
|
||||
await fs.writeFile(envChrome, "");
|
||||
await fs.writeFile(fallbackChrome, "");
|
||||
|
||||
useEnv(t, { BAOYU_CHROME_PATH: envChrome });
|
||||
assert.equal(
|
||||
findChromeExecutable({
|
||||
envNames: ["BAOYU_CHROME_PATH"],
|
||||
candidates: { default: [fallbackChrome] },
|
||||
}),
|
||||
envChrome,
|
||||
);
|
||||
|
||||
useEnv(t, { BAOYU_CHROME_PATH: null });
|
||||
assert.equal(
|
||||
findChromeExecutable({
|
||||
envNames: ["BAOYU_CHROME_PATH"],
|
||||
candidates: { default: [fallbackChrome] },
|
||||
}),
|
||||
fallbackChrome,
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveSharedChromeProfileDir supports env overrides, WSL paths, and default suffixes", (t) => {
|
||||
useEnv(t, { BAOYU_SHARED_PROFILE: "/tmp/custom-profile" });
|
||||
assert.equal(
|
||||
resolveSharedChromeProfileDir({
|
||||
envNames: ["BAOYU_SHARED_PROFILE"],
|
||||
appDataDirName: "demo-app",
|
||||
profileDirName: "demo-profile",
|
||||
}),
|
||||
path.resolve("/tmp/custom-profile"),
|
||||
);
|
||||
|
||||
useEnv(t, { BAOYU_SHARED_PROFILE: null });
|
||||
assert.equal(
|
||||
resolveSharedChromeProfileDir({
|
||||
wslWindowsHome: "/mnt/c/Users/demo",
|
||||
appDataDirName: "demo-app",
|
||||
profileDirName: "demo-profile",
|
||||
}),
|
||||
path.join("/mnt/c/Users/demo", ".local", "share", "demo-app", "demo-profile"),
|
||||
);
|
||||
|
||||
const fallback = resolveSharedChromeProfileDir({
|
||||
appDataDirName: "demo-app",
|
||||
profileDirName: "demo-profile",
|
||||
});
|
||||
assert.match(fallback, /demo-app[\\/]demo-profile$/);
|
||||
});
|
||||
|
||||
test("findExistingChromeDebugPort reads DevToolsActivePort and validates it against a live endpoint", async (t) => {
|
||||
const root = await makeTempDir("baoyu-cdp-profile-");
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
|
||||
const port = await getFreePort();
|
||||
const server = await startDebugServer(port);
|
||||
t.after(() => closeServer(server));
|
||||
|
||||
await fs.writeFile(path.join(root, "DevToolsActivePort"), `${port}\n/devtools/browser/demo\n`);
|
||||
|
||||
const found = await findExistingChromeDebugPort({ profileDir: root, timeoutMs: 1000 });
|
||||
assert.equal(found, port);
|
||||
});
|
||||
|
||||
test("discoverRunningChromeDebugPort reads DevToolsActivePort from the provided user-data dir", async (t) => {
|
||||
const root = await makeTempDir("baoyu-cdp-user-data-");
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
|
||||
const port = await getFreePort();
|
||||
const server = await startDebugServer(port);
|
||||
t.after(() => closeServer(server));
|
||||
|
||||
await fs.writeFile(path.join(root, "DevToolsActivePort"), `${port}\n/devtools/browser/demo\n`);
|
||||
|
||||
const found = await discoverRunningChromeDebugPort({
|
||||
userDataDirs: [root],
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
assert.deepEqual(found, {
|
||||
port,
|
||||
wsUrl: `ws://127.0.0.1:${port}/devtools/browser/demo`,
|
||||
});
|
||||
});
|
||||
|
||||
test("discoverRunningChromeDebugPort ignores unrelated debugging processes", async (t) => {
|
||||
if (process.platform === "win32") {
|
||||
t.skip("Process discovery fallback is not used on Windows.");
|
||||
return;
|
||||
}
|
||||
|
||||
const root = await makeTempDir("baoyu-cdp-user-data-");
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
|
||||
const port = await getFreePort();
|
||||
const server = await startDebugServer(port);
|
||||
t.after(() => closeServer(server));
|
||||
|
||||
const fakeChromium = await startFakeChromiumProcess(port);
|
||||
t.after(async () => { await stopProcess(fakeChromium); });
|
||||
|
||||
const found = await discoverRunningChromeDebugPort({
|
||||
userDataDirs: [root],
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
assert.equal(found, null);
|
||||
});
|
||||
|
||||
test("openPageSession reports whether it created a new target", async () => {
|
||||
const calls: string[] = [];
|
||||
const cdpExisting = {
|
||||
send: async <T>(method: string): Promise<T> => {
|
||||
calls.push(method);
|
||||
if (method === "Target.getTargets") {
|
||||
return {
|
||||
targetInfos: [{ targetId: "existing-target", type: "page", url: "https://gemini.google.com/app" }],
|
||||
} as T;
|
||||
}
|
||||
if (method === "Target.attachToTarget") return { sessionId: "session-existing" } as T;
|
||||
throw new Error(`Unexpected method: ${method}`);
|
||||
},
|
||||
};
|
||||
|
||||
const existing = await openPageSession({
|
||||
cdp: cdpExisting as never,
|
||||
reusing: false,
|
||||
url: "https://gemini.google.com/app",
|
||||
matchTarget: (target) => target.url.includes("gemini.google.com"),
|
||||
activateTarget: false,
|
||||
});
|
||||
|
||||
assert.deepEqual(existing, {
|
||||
sessionId: "session-existing",
|
||||
targetId: "existing-target",
|
||||
createdTarget: false,
|
||||
});
|
||||
assert.deepEqual(calls, ["Target.getTargets", "Target.attachToTarget"]);
|
||||
|
||||
const createCalls: string[] = [];
|
||||
const cdpCreated = {
|
||||
send: async <T>(method: string): Promise<T> => {
|
||||
createCalls.push(method);
|
||||
if (method === "Target.getTargets") return { targetInfos: [] } as T;
|
||||
if (method === "Target.createTarget") return { targetId: "created-target" } as T;
|
||||
if (method === "Target.attachToTarget") return { sessionId: "session-created" } as T;
|
||||
throw new Error(`Unexpected method: ${method}`);
|
||||
},
|
||||
};
|
||||
|
||||
const created = await openPageSession({
|
||||
cdp: cdpCreated as never,
|
||||
reusing: false,
|
||||
url: "https://gemini.google.com/app",
|
||||
matchTarget: (target) => target.url.includes("gemini.google.com"),
|
||||
activateTarget: false,
|
||||
});
|
||||
|
||||
assert.deepEqual(created, {
|
||||
sessionId: "session-created",
|
||||
targetId: "created-target",
|
||||
createdTarget: true,
|
||||
});
|
||||
assert.deepEqual(createCalls, ["Target.getTargets", "Target.createTarget", "Target.attachToTarget"]);
|
||||
});
|
||||
|
||||
test("waitForChromeDebugPort retries until the debug endpoint becomes available", async (t) => {
|
||||
const port = await getFreePort();
|
||||
|
||||
const serverPromise = (async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
const server = await startDebugServer(port);
|
||||
t.after(() => closeServer(server));
|
||||
})();
|
||||
|
||||
const websocketUrl = await waitForChromeDebugPort(port, 4000, {
|
||||
includeLastError: true,
|
||||
});
|
||||
await serverPromise;
|
||||
|
||||
assert.equal(websocketUrl, `ws://127.0.0.1:${port}/devtools/browser/demo`);
|
||||
});
|
||||
@@ -43,6 +43,19 @@ type FindExistingChromeDebugPortOptions = {
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
export type ChromeChannel = "stable" | "beta" | "canary" | "dev";
|
||||
|
||||
export type DiscoveredChrome = {
|
||||
port: number;
|
||||
wsUrl: string;
|
||||
};
|
||||
|
||||
type DiscoverRunningChromeOptions = {
|
||||
channels?: ChromeChannel[];
|
||||
userDataDirs?: string[];
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
type LaunchChromeOptions = {
|
||||
chromePath: string;
|
||||
profileDir: string;
|
||||
@@ -73,6 +86,7 @@ type OpenPageSessionOptions = {
|
||||
export type PageSession = {
|
||||
sessionId: string;
|
||||
targetId: string;
|
||||
createdTarget: boolean;
|
||||
};
|
||||
|
||||
export function sleep(ms: number): Promise<void> {
|
||||
@@ -173,16 +187,32 @@ async function isDebugPortReady(port: number, timeoutMs = 3_000): Promise<boolea
|
||||
}
|
||||
}
|
||||
|
||||
function isPortListening(port: number, timeoutMs = 3_000): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const socket = new net.Socket();
|
||||
const timer = setTimeout(() => { socket.destroy(); resolve(false); }, timeoutMs);
|
||||
socket.once("connect", () => { clearTimeout(timer); socket.destroy(); resolve(true); });
|
||||
socket.once("error", () => { clearTimeout(timer); resolve(false); });
|
||||
socket.connect(port, "127.0.0.1");
|
||||
});
|
||||
}
|
||||
|
||||
function parseDevToolsActivePort(filePath: string): { port: number; wsPath: string } | null {
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, "utf-8");
|
||||
const lines = content.split(/\r?\n/);
|
||||
const port = Number.parseInt(lines[0]?.trim() ?? "", 10);
|
||||
const wsPath = lines[1]?.trim();
|
||||
if (port > 0 && wsPath) return { port, wsPath };
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function findExistingChromeDebugPort(options: FindExistingChromeDebugPortOptions): Promise<number | null> {
|
||||
const timeoutMs = options.timeoutMs ?? 3_000;
|
||||
const portFile = path.join(options.profileDir, "DevToolsActivePort");
|
||||
const parsed = parseDevToolsActivePort(path.join(options.profileDir, "DevToolsActivePort"));
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(portFile, "utf-8");
|
||||
const [portLine] = content.split(/\r?\n/);
|
||||
const port = Number.parseInt(portLine?.trim() ?? "", 10);
|
||||
if (port > 0 && await isDebugPortReady(port, timeoutMs)) return port;
|
||||
} catch {}
|
||||
if (parsed && parsed.port > 0 && await isDebugPortReady(parsed.port, timeoutMs)) return parsed.port;
|
||||
|
||||
if (process.platform === "win32") return null;
|
||||
|
||||
@@ -204,6 +234,88 @@ export async function findExistingChromeDebugPort(options: FindExistingChromeDeb
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getDefaultChromeUserDataDirs(channels: ChromeChannel[] = ["stable"]): string[] {
|
||||
const home = os.homedir();
|
||||
const dirs: string[] = [];
|
||||
|
||||
const channelDirs: Record<string, { darwin: string; linux: string; win32: string }> = {
|
||||
stable: {
|
||||
darwin: path.join(home, "Library", "Application Support", "Google", "Chrome"),
|
||||
linux: path.join(home, ".config", "google-chrome"),
|
||||
win32: path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Google", "Chrome", "User Data"),
|
||||
},
|
||||
beta: {
|
||||
darwin: path.join(home, "Library", "Application Support", "Google", "Chrome Beta"),
|
||||
linux: path.join(home, ".config", "google-chrome-beta"),
|
||||
win32: path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Google", "Chrome Beta", "User Data"),
|
||||
},
|
||||
canary: {
|
||||
darwin: path.join(home, "Library", "Application Support", "Google", "Chrome Canary"),
|
||||
linux: path.join(home, ".config", "google-chrome-canary"),
|
||||
win32: path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Google", "Chrome SxS", "User Data"),
|
||||
},
|
||||
dev: {
|
||||
darwin: path.join(home, "Library", "Application Support", "Google", "Chrome Dev"),
|
||||
linux: path.join(home, ".config", "google-chrome-dev"),
|
||||
win32: path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Google", "Chrome Dev", "User Data"),
|
||||
},
|
||||
};
|
||||
|
||||
const platform = process.platform === "darwin" ? "darwin" : process.platform === "win32" ? "win32" : "linux";
|
||||
|
||||
for (const ch of channels) {
|
||||
const entry = channelDirs[ch];
|
||||
if (entry) dirs.push(entry[platform]);
|
||||
}
|
||||
|
||||
return dirs;
|
||||
}
|
||||
|
||||
// Best-effort reuse of an already-running local CDP session discovered from
|
||||
// known Chrome user-data dirs. This is distinct from Chrome DevTools MCP's
|
||||
// prompt-based --autoConnect flow.
|
||||
export async function discoverRunningChromeDebugPort(options: DiscoverRunningChromeOptions = {}): Promise<DiscoveredChrome | null> {
|
||||
const channels = options.channels ?? ["stable", "beta", "canary", "dev"];
|
||||
const timeoutMs = options.timeoutMs ?? 3_000;
|
||||
|
||||
const userDataDirs = (options.userDataDirs ?? getDefaultChromeUserDataDirs(channels))
|
||||
.map((dir) => path.resolve(dir));
|
||||
for (const dir of userDataDirs) {
|
||||
const parsed = parseDevToolsActivePort(path.join(dir, "DevToolsActivePort"));
|
||||
if (!parsed) continue;
|
||||
if (await isPortListening(parsed.port, timeoutMs)) {
|
||||
return { port: parsed.port, wsUrl: `ws://127.0.0.1:${parsed.port}${parsed.wsPath}` };
|
||||
}
|
||||
}
|
||||
|
||||
if (process.platform !== "win32") {
|
||||
try {
|
||||
const result = spawnSync("ps", ["aux"], { encoding: "utf-8", timeout: 5_000 });
|
||||
if (result.status === 0 && result.stdout) {
|
||||
const lines = result.stdout
|
||||
.split("\n")
|
||||
.filter((line) =>
|
||||
line.includes("--remote-debugging-port=") &&
|
||||
userDataDirs.some((dir) => line.includes(dir))
|
||||
);
|
||||
|
||||
for (const line of lines) {
|
||||
const portMatch = line.match(/--remote-debugging-port=(\d+)/);
|
||||
const port = Number.parseInt(portMatch?.[1] ?? "", 10);
|
||||
if (port > 0 && await isDebugPortReady(port, timeoutMs)) {
|
||||
try {
|
||||
const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(`http://127.0.0.1:${port}/json/version`, { timeoutMs });
|
||||
if (version.webSocketDebuggerUrl) return { port, wsUrl: version.webSocketDebuggerUrl };
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function waitForChromeDebugPort(
|
||||
port: number,
|
||||
timeoutMs: number,
|
||||
@@ -376,10 +488,12 @@ export function killChrome(chrome: ChildProcess): void {
|
||||
|
||||
export async function openPageSession(options: OpenPageSessionOptions): Promise<PageSession> {
|
||||
let targetId: string;
|
||||
let createdTarget = false;
|
||||
|
||||
if (options.reusing) {
|
||||
const created = await options.cdp.send<{ targetId: string }>("Target.createTarget", { url: options.url });
|
||||
targetId = created.targetId;
|
||||
createdTarget = true;
|
||||
} else {
|
||||
const targets = await options.cdp.send<{ targetInfos: ChromeTargetInfo[] }>("Target.getTargets");
|
||||
const existing = targets.targetInfos.find(options.matchTarget);
|
||||
@@ -388,6 +502,7 @@ export async function openPageSession(options: OpenPageSessionOptions): Promise<
|
||||
} else {
|
||||
const created = await options.cdp.send<{ targetId: string }>("Target.createTarget", { url: options.url });
|
||||
targetId = created.targetId;
|
||||
createdTarget = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -404,5 +519,5 @@ export async function openPageSession(options: OpenPageSessionOptions): Promise<
|
||||
if (options.enableDom) await options.cdp.send("DOM.enable", {}, { sessionId });
|
||||
if (options.enableNetwork) await options.cdp.send("Network.enable", {}, { sessionId });
|
||||
|
||||
return { sessionId, targetId };
|
||||
return { sessionId, targetId, createdTarget };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
extractSummaryFromBody,
|
||||
extractTitleFromMarkdown,
|
||||
parseFrontmatter,
|
||||
pickFirstString,
|
||||
serializeFrontmatter,
|
||||
stripWrappingQuotes,
|
||||
toFrontmatterString,
|
||||
} from "./content.ts";
|
||||
|
||||
test("parseFrontmatter extracts YAML fields and strips wrapping quotes", () => {
|
||||
const input = `---
|
||||
title: "Hello World"
|
||||
author: ‘Baoyu’
|
||||
summary: plain text
|
||||
---
|
||||
# Heading
|
||||
|
||||
Body`;
|
||||
|
||||
const result = parseFrontmatter(input);
|
||||
|
||||
assert.deepEqual(result.frontmatter, {
|
||||
title: "Hello World",
|
||||
author: "Baoyu",
|
||||
summary: "plain text",
|
||||
});
|
||||
assert.match(result.body, /^# Heading/);
|
||||
});
|
||||
|
||||
test("parseFrontmatter returns original content when no frontmatter exists", () => {
|
||||
const input = "# No frontmatter";
|
||||
assert.deepEqual(parseFrontmatter(input), {
|
||||
frontmatter: {},
|
||||
body: input,
|
||||
});
|
||||
});
|
||||
|
||||
test("serializeFrontmatter renders YAML only when fields exist", () => {
|
||||
assert.equal(serializeFrontmatter({}), "");
|
||||
assert.equal(
|
||||
serializeFrontmatter({ title: "Hello", author: "Baoyu" }),
|
||||
"---\ntitle: Hello\nauthor: Baoyu\n---\n",
|
||||
);
|
||||
});
|
||||
|
||||
test("quote and frontmatter string helpers normalize mixed scalar values", () => {
|
||||
assert.equal(stripWrappingQuotes(`" quoted "`), "quoted");
|
||||
assert.equal(stripWrappingQuotes("“ 中文标题 ”"), "中文标题");
|
||||
assert.equal(stripWrappingQuotes("plain"), "plain");
|
||||
|
||||
assert.equal(toFrontmatterString("'hello'"), "hello");
|
||||
assert.equal(toFrontmatterString(42), "42");
|
||||
assert.equal(toFrontmatterString(false), "false");
|
||||
assert.equal(toFrontmatterString({}), undefined);
|
||||
|
||||
assert.equal(
|
||||
pickFirstString({ summary: 123, title: "" }, ["title", "summary"]),
|
||||
"123",
|
||||
);
|
||||
});
|
||||
|
||||
test("markdown title and summary extraction skip non-body content and clean formatting", () => {
|
||||
const markdown = `
|
||||

|
||||
## “My Title”
|
||||
|
||||
Body paragraph
|
||||
`;
|
||||
assert.equal(extractTitleFromMarkdown(markdown), "My Title");
|
||||
|
||||
const summary = extractSummaryFromBody(
|
||||
`
|
||||
# Heading
|
||||
> quote
|
||||
- list
|
||||
1. ordered
|
||||
\`\`\`
|
||||
code
|
||||
\`\`\`
|
||||
This is **the first paragraph** with [a link](https://example.com) and \`inline code\` that should be summarized cleanly.
|
||||
`,
|
||||
70,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
summary,
|
||||
"This is the first paragraph with a link and inline code that should...",
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import test, { type TestContext } from "node:test";
|
||||
|
||||
import { COLOR_PRESETS, FONT_FAMILY_MAP } from "./constants.ts";
|
||||
import {
|
||||
buildMarkdownDocumentMeta,
|
||||
formatTimestamp,
|
||||
renderMarkdownDocument,
|
||||
resolveColorToken,
|
||||
resolveFontFamilyToken,
|
||||
resolveMarkdownStyle,
|
||||
resolveRenderOptions,
|
||||
} from "./document.ts";
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, `\\$&`);
|
||||
}
|
||||
|
||||
function findInlineStyle(html: string, tagName: string, text: string): string {
|
||||
const pattern = new RegExp(
|
||||
`<${tagName}[^>]*style="([^"]*)"[^>]*>${escapeRegExp(text)}</${tagName}>`,
|
||||
);
|
||||
const match = html.match(pattern);
|
||||
assert.ok(match, `Expected inline style for <${tagName}>${text}</${tagName}>`);
|
||||
return match![1]!;
|
||||
}
|
||||
|
||||
function useCwd(t: TestContext, cwd: string): void {
|
||||
const previous = process.cwd();
|
||||
process.chdir(cwd);
|
||||
t.after(() => {
|
||||
process.chdir(previous);
|
||||
});
|
||||
}
|
||||
|
||||
async function makeTempDir(prefix: string): Promise<string> {
|
||||
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
test("document token resolvers map known presets and allow passthrough values", () => {
|
||||
assert.equal(resolveColorToken("green"), COLOR_PRESETS.green);
|
||||
assert.equal(resolveColorToken("#123456"), "#123456");
|
||||
assert.equal(resolveColorToken(), undefined);
|
||||
|
||||
assert.equal(resolveFontFamilyToken("mono"), FONT_FAMILY_MAP.mono);
|
||||
assert.equal(resolveFontFamilyToken("Custom Font"), "Custom Font");
|
||||
assert.equal(resolveFontFamilyToken(), undefined);
|
||||
});
|
||||
|
||||
test("formatTimestamp uses compact sortable datetime output", () => {
|
||||
const date = new Date("2026-03-13T21:04:05.000Z");
|
||||
const pad = (value: number) => String(value).padStart(2, "0");
|
||||
const expected = `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(
|
||||
date.getDate(),
|
||||
)}${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`;
|
||||
|
||||
assert.equal(formatTimestamp(date), expected);
|
||||
});
|
||||
|
||||
test("buildMarkdownDocumentMeta prefers frontmatter and falls back to markdown title and summary", () => {
|
||||
const metaFromYaml = buildMarkdownDocumentMeta(
|
||||
"# Markdown Title\n\nBody summary paragraph that should be ignored.",
|
||||
{
|
||||
title: `" YAML Title "`,
|
||||
author: "'Baoyu'",
|
||||
summary: `" YAML Summary "`,
|
||||
},
|
||||
"fallback",
|
||||
);
|
||||
|
||||
assert.deepEqual(metaFromYaml, {
|
||||
title: "YAML Title",
|
||||
author: "Baoyu",
|
||||
description: "YAML Summary",
|
||||
});
|
||||
|
||||
const metaFromMarkdown = buildMarkdownDocumentMeta(
|
||||
`## “Markdown Title”\n\nThis is the first body paragraph that should become the summary because it is long enough.`,
|
||||
{},
|
||||
"fallback",
|
||||
);
|
||||
|
||||
assert.equal(metaFromMarkdown.title, "Markdown Title");
|
||||
assert.match(metaFromMarkdown.description ?? "", /^This is the first body paragraph/);
|
||||
});
|
||||
|
||||
test("resolveMarkdownStyle merges theme defaults with explicit overrides", () => {
|
||||
const style = resolveMarkdownStyle({
|
||||
theme: "modern",
|
||||
primaryColor: "#112233",
|
||||
fontFamily: "Custom Sans",
|
||||
});
|
||||
|
||||
assert.equal(style.primaryColor, "#112233");
|
||||
assert.equal(style.fontFamily, "Custom Sans");
|
||||
assert.equal(style.fontSize, "15px");
|
||||
assert.equal(style.containerBg, "rgba(250, 249, 245, 1)");
|
||||
});
|
||||
|
||||
test("resolveRenderOptions loads workspace EXTEND settings and lets explicit options win", async (t) => {
|
||||
const root = await makeTempDir("baoyu-md-render-options-");
|
||||
useCwd(t, root);
|
||||
|
||||
const extendPath = path.join(
|
||||
root,
|
||||
".baoyu-skills",
|
||||
"baoyu-markdown-to-html",
|
||||
"EXTEND.md",
|
||||
);
|
||||
await fs.mkdir(path.dirname(extendPath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
extendPath,
|
||||
`---
|
||||
default_theme: modern
|
||||
default_color: green
|
||||
default_font_family: mono
|
||||
default_font_size: 17
|
||||
default_code_theme: nord
|
||||
mac_code_block: false
|
||||
show_line_number: true
|
||||
cite: true
|
||||
count: true
|
||||
legend: title-alt
|
||||
keep_title: true
|
||||
---
|
||||
`,
|
||||
);
|
||||
|
||||
const fromExtend = resolveRenderOptions();
|
||||
assert.equal(fromExtend.theme, "modern");
|
||||
assert.equal(fromExtend.primaryColor, COLOR_PRESETS.green);
|
||||
assert.equal(fromExtend.fontFamily, FONT_FAMILY_MAP.mono);
|
||||
assert.equal(fromExtend.fontSize, "17px");
|
||||
assert.equal(fromExtend.codeTheme, "nord");
|
||||
assert.equal(fromExtend.isMacCodeBlock, false);
|
||||
assert.equal(fromExtend.isShowLineNumber, true);
|
||||
assert.equal(fromExtend.citeStatus, true);
|
||||
assert.equal(fromExtend.countStatus, true);
|
||||
assert.equal(fromExtend.legend, "title-alt");
|
||||
assert.equal(fromExtend.keepTitle, true);
|
||||
|
||||
const explicit = resolveRenderOptions({
|
||||
theme: "simple",
|
||||
fontSize: "18px",
|
||||
keepTitle: false,
|
||||
});
|
||||
assert.equal(explicit.theme, "simple");
|
||||
assert.equal(explicit.fontSize, "18px");
|
||||
assert.equal(explicit.keepTitle, false);
|
||||
});
|
||||
|
||||
test("renderMarkdownDocument layers default rules into grace theme before CSS inlining", async () => {
|
||||
const { html } = await renderMarkdownDocument(
|
||||
`## Section\n\nParagraph with **bold** text.`,
|
||||
{ keepTitle: true, theme: "grace" },
|
||||
);
|
||||
|
||||
const h2Style = findInlineStyle(html, "h2", "Section");
|
||||
assert.match(h2Style, /background: #92617E/);
|
||||
assert.match(h2Style, /box-shadow: 0 4px 6px rgba\(0, 0, 0, 0\.1\)/);
|
||||
|
||||
const pMatch = html.match(/<p[^>]*style="([^"]*)"[^>]*>/);
|
||||
assert.ok(pMatch, "Expected inline style on <p> tag");
|
||||
assert.match(pMatch![1]!, /color:/);
|
||||
|
||||
const strongPattern = /<strong[^>]*style="([^"]*)"[^>]*>bold<\/strong>/;
|
||||
const strongMatch = html.match(strongPattern);
|
||||
assert.ok(strongMatch, "Expected inline style for <strong>bold</strong>");
|
||||
assert.match(strongMatch![1]!, /font-weight:/);
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { DEFAULT_STYLE } from "./constants.ts";
|
||||
import {
|
||||
buildCss,
|
||||
buildHtmlDocument,
|
||||
modifyHtmlStructure,
|
||||
normalizeCssText,
|
||||
normalizeInlineCss,
|
||||
removeFirstHeading,
|
||||
} from "./html-builder.ts";
|
||||
|
||||
test("buildCss injects style variables and concatenates base and theme CSS", () => {
|
||||
const css = buildCss("body { color: red; }", ".theme { color: blue; }");
|
||||
|
||||
assert.match(css, /--md-primary-color: #0F4C81;/);
|
||||
assert.match(css, /body \{ color: red; \}/);
|
||||
assert.match(css, /\.theme \{ color: blue; \}/);
|
||||
});
|
||||
|
||||
test("buildHtmlDocument includes optional meta tags and code theme CSS", () => {
|
||||
const html = buildHtmlDocument(
|
||||
{
|
||||
title: "Doc",
|
||||
author: "Baoyu",
|
||||
description: "Summary",
|
||||
},
|
||||
"body { color: red; }",
|
||||
"<article>Hello</article>",
|
||||
".hljs { color: blue; }",
|
||||
);
|
||||
|
||||
assert.match(html, /<title>Doc<\/title>/);
|
||||
assert.match(html, /meta name="author" content="Baoyu"/);
|
||||
assert.match(html, /meta name="description" content="Summary"/);
|
||||
assert.match(html, /<style>body \{ color: red; \}<\/style>/);
|
||||
assert.match(html, /<style>\.hljs \{ color: blue; \}<\/style>/);
|
||||
assert.match(html, /<article>Hello<\/article>/);
|
||||
});
|
||||
|
||||
test("normalizeCssText and normalizeInlineCss replace variables and strip declarations", () => {
|
||||
const rawCss = `
|
||||
:root { --md-primary-color: #000; --md-font-size: 12px; --foreground: 0 0% 5%; }
|
||||
.box { color: var(--md-primary-color); font-size: var(--md-font-size); background: hsl(var(--foreground)); }
|
||||
`;
|
||||
|
||||
const normalizedCss = normalizeCssText(rawCss, DEFAULT_STYLE);
|
||||
assert.match(normalizedCss, /color: #0F4C81/);
|
||||
assert.match(normalizedCss, /font-size: 16px/);
|
||||
assert.match(normalizedCss, /background: #3f3f3f/);
|
||||
assert.doesNotMatch(normalizedCss, /--md-primary-color/);
|
||||
|
||||
const normalizedHtml = normalizeInlineCss(
|
||||
`<style>${rawCss}</style><div style="color: var(--md-primary-color)"></div>`,
|
||||
DEFAULT_STYLE,
|
||||
);
|
||||
assert.match(normalizedHtml, /color: #0F4C81/);
|
||||
assert.doesNotMatch(normalizedHtml, /var\(--md-primary-color\)/);
|
||||
});
|
||||
|
||||
test("normalizeInlineCss removes quoted custom property values without leaving fragments behind", () => {
|
||||
const normalizedHtml = normalizeInlineCss(
|
||||
`<html style="--md-font-family: Menlo, Monaco, 'Courier New', monospace; color: var(--md-primary-color)"></html>`,
|
||||
DEFAULT_STYLE,
|
||||
);
|
||||
|
||||
assert.match(normalizedHtml, /style=" color: #0F4C81"/);
|
||||
assert.doesNotMatch(normalizedHtml, /Courier New/);
|
||||
assert.doesNotMatch(normalizedHtml, /--md-font-family/);
|
||||
});
|
||||
|
||||
test("HTML structure helpers hoist nested lists and remove the first heading", () => {
|
||||
const nestedList = `<ul><li>Parent<ul><li>Child</li></ul></li></ul>`;
|
||||
assert.equal(
|
||||
modifyHtmlStructure(nestedList),
|
||||
`<ul><li>Parent</li><ul><li>Child</li></ul></ul>`,
|
||||
);
|
||||
|
||||
const html = `<h1>Title</h1><p>Intro</p><h2>Sub</h2>`;
|
||||
assert.equal(removeFirstHeading(html), `<p>Intro</p><h2>Sub</h2>`);
|
||||
});
|
||||
@@ -100,13 +100,13 @@ export function normalizeCssText(cssText: string, style: StyleConfig = DEFAULT_S
|
||||
.replace(/var\(--md-accent-color\)/g, style.accentColor)
|
||||
.replace(/var\(--md-container-bg\)/g, style.containerBg)
|
||||
.replace(/hsl\(var\(--foreground\)\)/g, "#3f3f3f")
|
||||
.replace(/--md-primary-color:\s*[^;"']+;?/g, "")
|
||||
.replace(/--md-font-family:\s*[^;"']+;?/g, "")
|
||||
.replace(/--md-font-size:\s*[^;"']+;?/g, "")
|
||||
.replace(/--blockquote-background:\s*[^;"']+;?/g, "")
|
||||
.replace(/--md-accent-color:\s*[^;"']+;?/g, "")
|
||||
.replace(/--md-container-bg:\s*[^;"']+;?/g, "")
|
||||
.replace(/--foreground:\s*[^;"']+;?/g, "");
|
||||
.replace(/--md-primary-color:\s*[^;]+;?/g, "")
|
||||
.replace(/--md-font-family:\s*[^;]+;?/g, "")
|
||||
.replace(/--md-font-size:\s*[^;]+;?/g, "")
|
||||
.replace(/--blockquote-background:\s*[^;]+;?/g, "")
|
||||
.replace(/--md-accent-color:\s*[^;]+;?/g, "")
|
||||
.replace(/--md-container-bg:\s*[^;]+;?/g, "")
|
||||
.replace(/--foreground:\s*[^;]+;?/g, "");
|
||||
}
|
||||
|
||||
export function normalizeInlineCss(html: string, style: StyleConfig = DEFAULT_STYLE): string {
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
getImageExtension,
|
||||
replaceMarkdownImagesWithPlaceholders,
|
||||
resolveContentImages,
|
||||
resolveImagePath,
|
||||
} from "./images.ts";
|
||||
|
||||
async function makeTempDir(prefix: string): Promise<string> {
|
||||
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
test("replaceMarkdownImagesWithPlaceholders rewrites markdown and tracks image metadata", () => {
|
||||
const result = replaceMarkdownImagesWithPlaceholders(
|
||||
`\n\nText\n\n`,
|
||||
"IMG_",
|
||||
);
|
||||
|
||||
assert.equal(result.markdown, `IMG_1\n\nText\n\nIMG_2`);
|
||||
assert.deepEqual(result.images, [
|
||||
{ alt: "cover", originalPath: "images/cover.png", placeholder: "IMG_1" },
|
||||
{ alt: "diagram", originalPath: "images/diagram.webp", placeholder: "IMG_2" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("image extension and local fallback resolution handle common path variants", async (t) => {
|
||||
assert.equal(getImageExtension("https://example.com/a.jpeg?x=1"), "jpeg");
|
||||
assert.equal(getImageExtension("/tmp/figure"), "png");
|
||||
|
||||
const root = await makeTempDir("baoyu-md-images-");
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
|
||||
const baseDir = path.join(root, "article");
|
||||
const tempDir = path.join(root, "tmp");
|
||||
await fs.mkdir(baseDir, { recursive: true });
|
||||
await fs.mkdir(tempDir, { recursive: true });
|
||||
await fs.writeFile(path.join(baseDir, "figure.webp"), "webp");
|
||||
|
||||
const resolved = await resolveImagePath("figure.png", baseDir, tempDir, "test");
|
||||
assert.equal(resolved, path.join(baseDir, "figure.webp"));
|
||||
});
|
||||
|
||||
test("resolveContentImages resolves image placeholders against the content directory", async (t) => {
|
||||
const root = await makeTempDir("baoyu-md-content-images-");
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
|
||||
const baseDir = path.join(root, "article");
|
||||
const tempDir = path.join(root, "tmp");
|
||||
await fs.mkdir(baseDir, { recursive: true });
|
||||
await fs.mkdir(tempDir, { recursive: true });
|
||||
await fs.writeFile(path.join(baseDir, "cover.png"), "png");
|
||||
|
||||
const resolved = await resolveContentImages(
|
||||
[
|
||||
{
|
||||
alt: "cover",
|
||||
originalPath: "cover.png",
|
||||
placeholder: "IMG_1",
|
||||
},
|
||||
],
|
||||
baseDir,
|
||||
tempDir,
|
||||
"test",
|
||||
);
|
||||
|
||||
assert.deepEqual(resolved, [
|
||||
{
|
||||
alt: "cover",
|
||||
originalPath: "cover.png",
|
||||
placeholder: "IMG_1",
|
||||
localPath: path.join(baseDir, "cover.png"),
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { initRenderer, renderMarkdown } from "./renderer.ts";
|
||||
|
||||
const render = (md: string) => {
|
||||
const r = initRenderer();
|
||||
return renderMarkdown(md, r).html;
|
||||
};
|
||||
|
||||
test("bold with inline code (no underscore)", () => {
|
||||
const html = render("**算出 `logits`,算出 `loss`。**");
|
||||
assert.match(html, /<code[^>]*>logits<\/code>/);
|
||||
assert.match(html, /<code[^>]*>loss<\/code>/);
|
||||
});
|
||||
|
||||
test("bold with inline code (contains underscore)", () => {
|
||||
const html = render("**变成 `input_ids`。**");
|
||||
assert.match(html, /<code[^>]*>input_ids<\/code>/);
|
||||
});
|
||||
|
||||
test("emphasis with inline code", () => {
|
||||
const html = render("*查看 `hidden_states`*");
|
||||
assert.match(html, /<code[^>]*>hidden_states<\/code>/);
|
||||
});
|
||||
|
||||
test("plain inline code (regression)", () => {
|
||||
const html = render("`lm_head`");
|
||||
assert.match(html, /<code[^>]*>lm_head<\/code>/);
|
||||
});
|
||||
|
||||
test("bold without code (regression)", () => {
|
||||
const html = render("**纯粗体文本**");
|
||||
assert.match(html, /<strong[^>]*>纯粗体文本<\/strong>/);
|
||||
assert.doesNotMatch(html, /<code/);
|
||||
});
|
||||
|
||||
test("bold with inline code containing backticks", () => {
|
||||
const html = render("**``a`b``**");
|
||||
assert.match(html, /<code[^>]*>a`b<\/code>/);
|
||||
});
|
||||
|
||||
test("emphasis with inline code containing backticks", () => {
|
||||
const html = render("*``a`b``*");
|
||||
assert.match(html, /<em[^>]*><code[^>]*>a`b<\/code><\/em>/);
|
||||
});
|
||||
|
||||
test("bold with inline code containing consecutive backticks", () => {
|
||||
const html = render("**```a``b```**");
|
||||
assert.match(html, /<code[^>]*>a``b<\/code>/);
|
||||
});
|
||||
|
||||
test("bold with inline code containing only backticks", () => {
|
||||
const html = render("**```` `` ````**");
|
||||
assert.match(html, /<code[^>]*>``<\/code>/);
|
||||
});
|
||||
|
||||
test("bold with inline code containing only spaces", () => {
|
||||
const oneSpace = render("**`` ``**");
|
||||
assert.match(oneSpace, /<code[^>]*> <\/code>/);
|
||||
|
||||
const twoSpaces = render("**`` ``**");
|
||||
assert.match(twoSpaces, /<code[^>]*> <\/code>/);
|
||||
});
|
||||
@@ -109,6 +109,13 @@ function parseFrontMatterAndContent(markdownText: string): ParseResult {
|
||||
}
|
||||
}
|
||||
|
||||
function wrapInlineCode(value: string): string {
|
||||
const runs = value.match(/`+/g);
|
||||
const fence = "`".repeat(Math.max(...(runs?.map((run) => run.length) ?? [0])) + 1);
|
||||
const padding = /^ *$/.test(value) ? "" : " ";
|
||||
return `${fence}${padding}${value}${padding}${fence}`;
|
||||
}
|
||||
|
||||
export function initRenderer(opts: IOpts = {}): RendererAPI {
|
||||
const footnotes: [number, string, string][] = [];
|
||||
let footnoteIndex = 0;
|
||||
@@ -369,6 +376,7 @@ function preprocessCjkEmphasis(markdown: string): string {
|
||||
const tree = processor.parse(markdown);
|
||||
const extractText = (node: any): string => {
|
||||
if (node.type === "text") return node.value;
|
||||
if (node.type === "inlineCode") return wrapInlineCode(node.value);
|
||||
if (node.children) return node.children.map(extractText).join("");
|
||||
return "";
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { ThemeName } from "./types.js";
|
||||
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
||||
export const THEME_DIR = path.resolve(SCRIPT_DIR, "themes");
|
||||
const FALLBACK_THEMES: ThemeName[] = ["default", "grace", "simple"];
|
||||
const THEMES_EXTENDING_DEFAULT = new Set<ThemeName>(["grace", "simple"]);
|
||||
|
||||
function stripOutputScope(cssContent: string): string {
|
||||
let css = cssContent;
|
||||
@@ -41,6 +42,7 @@ export function loadThemeCss(theme: ThemeName): {
|
||||
themeCss: string;
|
||||
} {
|
||||
const basePath = path.join(THEME_DIR, "base.css");
|
||||
const defaultThemePath = path.join(THEME_DIR, "default.css");
|
||||
const themePath = path.join(THEME_DIR, `${theme}.css`);
|
||||
|
||||
if (!fs.existsSync(basePath)) {
|
||||
@@ -51,9 +53,18 @@ export function loadThemeCss(theme: ThemeName): {
|
||||
throw new Error(`Missing theme CSS for "${theme}": ${themePath}`);
|
||||
}
|
||||
|
||||
const layeredThemeCss: string[] = [];
|
||||
if (theme !== "default" && THEMES_EXTENDING_DEFAULT.has(theme)) {
|
||||
if (!fs.existsSync(defaultThemePath)) {
|
||||
throw new Error(`Missing default theme CSS: ${defaultThemePath}`);
|
||||
}
|
||||
layeredThemeCss.push(fs.readFileSync(defaultThemePath, "utf-8"));
|
||||
}
|
||||
layeredThemeCss.push(fs.readFileSync(themePath, "utf-8"));
|
||||
|
||||
return {
|
||||
baseCss: fs.readFileSync(basePath, "utf-8"),
|
||||
themeCss: fs.readFileSync(themePath, "utf-8"),
|
||||
themeCss: layeredThemeCss.join("\n"),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,11 @@ import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { loadWechatExtendConfig, resolveAccount, loadCredentials } from "./wechat-extend-config.ts";
|
||||
import {
|
||||
type WechatUploadAsset,
|
||||
prepareWechatBodyImageUpload,
|
||||
needsWechatBodyImageProcessing,
|
||||
} from "./wechat-image-processor.ts";
|
||||
|
||||
interface AccessTokenResponse {
|
||||
access_token?: string;
|
||||
@@ -52,10 +57,10 @@ interface ArticleOptions {
|
||||
}
|
||||
|
||||
const TOKEN_URL = "https://api.weixin.qq.com/cgi-bin/token";
|
||||
const UPLOAD_URL = "https://api.weixin.qq.com/cgi-bin/material/add_material";
|
||||
const UPLOAD_BODY_IMG_URL = "https://api.weixin.qq.com/cgi-bin/media/uploadimg";
|
||||
const UPLOAD_MATERIAL_URL = "https://api.weixin.qq.com/cgi-bin/material/add_material";
|
||||
const DRAFT_URL = "https://api.weixin.qq.com/cgi-bin/draft/add";
|
||||
|
||||
|
||||
async function fetchAccessToken(appId: string, appSecret: string): Promise<string> {
|
||||
const url = `${TOKEN_URL}?grant_type=client_credential&appid=${appId}&secret=${appSecret}`;
|
||||
const res = await fetch(url);
|
||||
@@ -72,14 +77,20 @@ async function fetchAccessToken(appId: string, appSecret: string): Promise<strin
|
||||
return data.access_token;
|
||||
}
|
||||
|
||||
async function uploadImage(
|
||||
function toHttpsUrl(url: string | undefined): string {
|
||||
if (!url) return "";
|
||||
return url.startsWith("http://") ? url.replace(/^http:\/\//i, "https://") : url;
|
||||
}
|
||||
|
||||
async function loadUploadAsset(
|
||||
imagePath: string,
|
||||
accessToken: string,
|
||||
baseDir?: string
|
||||
): Promise<UploadResponse> {
|
||||
baseDir?: string,
|
||||
): Promise<WechatUploadAsset> {
|
||||
let fileBuffer: Buffer;
|
||||
let filename: string;
|
||||
let contentType: string;
|
||||
let fileSize = 0;
|
||||
let fileExt = "";
|
||||
|
||||
if (imagePath.startsWith("http://") || imagePath.startsWith("https://")) {
|
||||
const response = await fetch(imagePath);
|
||||
@@ -91,8 +102,10 @@ async function uploadImage(
|
||||
throw new Error(`Remote image is empty: ${imagePath}`);
|
||||
}
|
||||
fileBuffer = Buffer.from(buffer);
|
||||
fileSize = buffer.byteLength;
|
||||
const urlPath = imagePath.split("?")[0];
|
||||
filename = path.basename(urlPath) || "image.jpg";
|
||||
fileExt = path.extname(filename).toLowerCase();
|
||||
contentType = response.headers.get("content-type") || "image/jpeg";
|
||||
} else {
|
||||
const resolvedPath = path.isAbsolute(imagePath)
|
||||
@@ -106,19 +119,85 @@ async function uploadImage(
|
||||
if (stats.size === 0) {
|
||||
throw new Error(`Local image is empty: ${resolvedPath}`);
|
||||
}
|
||||
fileSize = stats.size;
|
||||
fileBuffer = fs.readFileSync(resolvedPath);
|
||||
filename = path.basename(resolvedPath);
|
||||
const ext = path.extname(filename).toLowerCase();
|
||||
fileExt = path.extname(filename).toLowerCase();
|
||||
const mimeTypes: Record<string, string> = {
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
".bmp": "image/bmp",
|
||||
".tiff": "image/tiff",
|
||||
".tif": "image/tiff",
|
||||
".svg": "image/svg+xml",
|
||||
".ico": "image/x-icon",
|
||||
};
|
||||
contentType = mimeTypes[ext] || "image/jpeg";
|
||||
contentType = mimeTypes[fileExt] || "image/jpeg";
|
||||
}
|
||||
|
||||
return {
|
||||
buffer: fileBuffer,
|
||||
filename,
|
||||
contentType,
|
||||
fileExt,
|
||||
fileSize,
|
||||
};
|
||||
}
|
||||
|
||||
async function uploadImage(
|
||||
imagePath: string,
|
||||
accessToken: string,
|
||||
baseDir?: string,
|
||||
uploadType: "body" | "material" = "body"
|
||||
): Promise<UploadResponse> {
|
||||
const asset = await loadUploadAsset(imagePath, baseDir);
|
||||
let uploadAsset = asset;
|
||||
|
||||
if (uploadType === "body" && needsWechatBodyImageProcessing(asset)) {
|
||||
const prepared = await prepareWechatBodyImageUpload(asset);
|
||||
uploadAsset = {
|
||||
...asset,
|
||||
buffer: prepared.buffer,
|
||||
filename: prepared.filename,
|
||||
contentType: prepared.contentType,
|
||||
fileExt: path.extname(prepared.filename).toLowerCase(),
|
||||
fileSize: prepared.buffer.length,
|
||||
};
|
||||
const note = prepared.processingNotes.join(", ");
|
||||
console.error(`[wechat-api] Processed ${asset.filename} for body upload: ${note}`);
|
||||
}
|
||||
|
||||
const result = await uploadToWechat(
|
||||
uploadAsset.buffer,
|
||||
uploadAsset.filename,
|
||||
uploadAsset.contentType,
|
||||
accessToken,
|
||||
uploadType,
|
||||
);
|
||||
|
||||
// media/uploadimg 接口只返回 URL,material/add_material 返回 media_id
|
||||
if (uploadType === "body") {
|
||||
return {
|
||||
url: toHttpsUrl(result.url),
|
||||
media_id: "",
|
||||
} as UploadResponse;
|
||||
} else {
|
||||
result.url = toHttpsUrl(result.url);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// 实际的微信上传函数
|
||||
async function uploadToWechat(
|
||||
fileBuffer: Buffer,
|
||||
filename: string,
|
||||
contentType: string,
|
||||
accessToken: string,
|
||||
uploadType: "body" | "material"
|
||||
): Promise<UploadResponse> {
|
||||
const boundary = `----WebKitFormBoundary${Date.now().toString(16)}`;
|
||||
const header = [
|
||||
`--${boundary}`,
|
||||
@@ -133,7 +212,8 @@ async function uploadImage(
|
||||
const footerBuffer = Buffer.from(footer, "utf-8");
|
||||
const body = Buffer.concat([headerBuffer, fileBuffer, footerBuffer]);
|
||||
|
||||
const url = `${UPLOAD_URL}?access_token=${accessToken}&type=image`;
|
||||
const uploadUrl = uploadType === "body" ? UPLOAD_BODY_IMG_URL : UPLOAD_MATERIAL_URL;
|
||||
const url = `${uploadUrl}?type=image&access_token=${accessToken}`;
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
@@ -147,10 +227,6 @@ async function uploadImage(
|
||||
throw new Error(`Upload failed ${data.errcode}: ${data.errmsg}`);
|
||||
}
|
||||
|
||||
if (data.url?.startsWith("http://")) {
|
||||
data.url = data.url.replace(/^http:\/\//i, "https://");
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -159,17 +235,19 @@ async function uploadImagesInHtml(
|
||||
accessToken: string,
|
||||
baseDir: string,
|
||||
contentImages: ImageInfo[] = [],
|
||||
): Promise<{ html: string; firstMediaId: string; allMediaIds: string[] }> {
|
||||
articleType: ArticleType = "news",
|
||||
collectNewsCoverFallback: boolean = false,
|
||||
): Promise<{ html: string; firstCoverMediaId: string; imageMediaIds: string[] }> {
|
||||
const imgRegex = /<img[^>]*\ssrc=["']([^"']+)["'][^>]*>/gi;
|
||||
const matches = [...html.matchAll(imgRegex)];
|
||||
|
||||
if (matches.length === 0 && contentImages.length === 0) {
|
||||
return { html, firstMediaId: "", allMediaIds: [] };
|
||||
return { html, firstCoverMediaId: "", imageMediaIds: [] };
|
||||
}
|
||||
|
||||
let firstMediaId = "";
|
||||
let firstCoverMediaId = "";
|
||||
let updatedHtml = html;
|
||||
const allMediaIds: string[] = [];
|
||||
const imageMediaIds: string[] = [];
|
||||
const uploadedBySource = new Map<string, UploadResponse>();
|
||||
|
||||
for (const match of matches) {
|
||||
@@ -177,8 +255,13 @@ async function uploadImagesInHtml(
|
||||
if (!src) continue;
|
||||
|
||||
if (src.startsWith("https://mmbiz.qpic.cn")) {
|
||||
if (!firstMediaId) {
|
||||
firstMediaId = src;
|
||||
if (collectNewsCoverFallback && !firstCoverMediaId) {
|
||||
try {
|
||||
const coverResp = await uploadImage(src, accessToken, baseDir, "material");
|
||||
firstCoverMediaId = coverResp.media_id;
|
||||
} catch (err) {
|
||||
console.error(`[wechat-api] Failed to reuse existing WeChat image as cover: ${src}`, err);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -186,20 +269,31 @@ async function uploadImagesInHtml(
|
||||
const localPathMatch = fullTag.match(/data-local-path=["']([^"']+)["']/);
|
||||
const imagePath = localPathMatch ? localPathMatch[1]! : src;
|
||||
|
||||
console.error(`[wechat-api] Uploading image: ${imagePath}`);
|
||||
console.error(`[wechat-api] Uploading body image: ${imagePath}`);
|
||||
try {
|
||||
let resp = uploadedBySource.get(imagePath);
|
||||
if (!resp) {
|
||||
resp = await uploadImage(imagePath, accessToken, baseDir);
|
||||
// 正文图片使用 media/uploadimg 接口获取 URL
|
||||
resp = await uploadImage(imagePath, accessToken, baseDir, "body");
|
||||
uploadedBySource.set(imagePath, resp);
|
||||
}
|
||||
const newTag = fullTag
|
||||
.replace(/\ssrc=["'][^"']+["']/, ` src="${resp.url}"`)
|
||||
.replace(/\sdata-local-path=["'][^"']+["']/, "");
|
||||
updatedHtml = updatedHtml.replace(fullTag, newTag);
|
||||
allMediaIds.push(resp.media_id);
|
||||
if (!firstMediaId) {
|
||||
firstMediaId = resp.media_id;
|
||||
const shouldUploadMaterial = articleType === "newspic" || (collectNewsCoverFallback && !firstCoverMediaId);
|
||||
if (shouldUploadMaterial) {
|
||||
let materialResp = uploadedBySource.get(`${imagePath}:material`);
|
||||
if (!materialResp) {
|
||||
materialResp = await uploadImage(imagePath, accessToken, baseDir, "material");
|
||||
uploadedBySource.set(`${imagePath}:material`, materialResp);
|
||||
}
|
||||
if (articleType === "newspic" && materialResp.media_id) {
|
||||
imageMediaIds.push(materialResp.media_id);
|
||||
}
|
||||
if (collectNewsCoverFallback && !firstCoverMediaId && materialResp.media_id) {
|
||||
firstCoverMediaId = materialResp.media_id;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[wechat-api] Failed to upload ${imagePath}:`, err);
|
||||
@@ -210,27 +304,38 @@ async function uploadImagesInHtml(
|
||||
if (!updatedHtml.includes(image.placeholder)) continue;
|
||||
|
||||
const imagePath = image.localPath || image.originalPath;
|
||||
console.error(`[wechat-api] Uploading placeholder image: ${imagePath}`);
|
||||
console.error(`[wechat-api] Uploading body image: ${imagePath}`);
|
||||
|
||||
try {
|
||||
let resp = uploadedBySource.get(imagePath);
|
||||
if (!resp) {
|
||||
resp = await uploadImage(imagePath, accessToken, baseDir);
|
||||
// 正文图片使用 media/uploadimg 接口获取 URL
|
||||
resp = await uploadImage(imagePath, accessToken, baseDir, "body");
|
||||
uploadedBySource.set(imagePath, resp);
|
||||
}
|
||||
|
||||
const replacementTag = `<img src="${resp.url}" style="display: block; width: 100%; margin: 1.5em auto;">`;
|
||||
updatedHtml = replaceAllPlaceholders(updatedHtml, image.placeholder, replacementTag);
|
||||
allMediaIds.push(resp.media_id);
|
||||
if (!firstMediaId) {
|
||||
firstMediaId = resp.media_id;
|
||||
const shouldUploadMaterial = articleType === "newspic" || (collectNewsCoverFallback && !firstCoverMediaId);
|
||||
if (shouldUploadMaterial) {
|
||||
let materialResp = uploadedBySource.get(`${imagePath}:material`);
|
||||
if (!materialResp) {
|
||||
materialResp = await uploadImage(imagePath, accessToken, baseDir, "material");
|
||||
uploadedBySource.set(`${imagePath}:material`, materialResp);
|
||||
}
|
||||
if (articleType === "newspic" && materialResp.media_id) {
|
||||
imageMediaIds.push(materialResp.media_id);
|
||||
}
|
||||
if (collectNewsCoverFallback && !firstCoverMediaId && materialResp.media_id) {
|
||||
firstCoverMediaId = materialResp.media_id;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[wechat-api] Failed to upload placeholder ${image.placeholder}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
return { html: updatedHtml, firstMediaId, allMediaIds };
|
||||
return { html: updatedHtml, firstCoverMediaId, imageMediaIds };
|
||||
}
|
||||
|
||||
async function publishToDraft(
|
||||
@@ -345,7 +450,7 @@ function renderMarkdownWithPlaceholders(
|
||||
|
||||
function replaceAllPlaceholders(html: string, placeholder: string, replacement: string): string {
|
||||
const escapedPlaceholder = placeholder.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
return html.replace(new RegExp(escapedPlaceholder, "g"), replacement);
|
||||
return html.replace(new RegExp(escapedPlaceholder + "(?!\\d)", "g"), replacement);
|
||||
}
|
||||
|
||||
function extractHtmlContent(htmlPath: string): string {
|
||||
@@ -589,19 +694,13 @@ async function main(): Promise<void> {
|
||||
}
|
||||
|
||||
const creds = loadCredentials(resolved);
|
||||
for (const skippedSource of creds.skippedSources) {
|
||||
console.error(`[wechat-api] Skipped incomplete credential source: ${skippedSource}`);
|
||||
}
|
||||
console.error(`[wechat-api] Credentials source: ${creds.source}`);
|
||||
console.error("[wechat-api] Fetching access token...");
|
||||
const accessToken = await fetchAccessToken(creds.appId, creds.appSecret);
|
||||
|
||||
console.error("[wechat-api] Uploading images...");
|
||||
const { html: processedHtml, firstMediaId, allMediaIds } = await uploadImagesInHtml(
|
||||
htmlContent,
|
||||
accessToken,
|
||||
baseDir,
|
||||
contentImages,
|
||||
);
|
||||
htmlContent = processedHtml;
|
||||
|
||||
let thumbMediaId = "";
|
||||
const rawCoverPath = args.cover ||
|
||||
frontmatter.coverImage ||
|
||||
frontmatter.featureImage ||
|
||||
@@ -610,19 +709,31 @@ async function main(): Promise<void> {
|
||||
const coverPath = rawCoverPath && !path.isAbsolute(rawCoverPath) && args.cover
|
||||
? path.resolve(process.cwd(), rawCoverPath)
|
||||
: rawCoverPath;
|
||||
const needNewsCoverFallback = args.articleType === "news" && !coverPath;
|
||||
|
||||
console.error("[wechat-api] Uploading body images...");
|
||||
const { html: processedHtml, firstCoverMediaId, imageMediaIds } = await uploadImagesInHtml(
|
||||
htmlContent,
|
||||
accessToken,
|
||||
baseDir,
|
||||
contentImages,
|
||||
args.articleType,
|
||||
needNewsCoverFallback,
|
||||
);
|
||||
htmlContent = processedHtml;
|
||||
|
||||
let thumbMediaId = "";
|
||||
|
||||
if (coverPath) {
|
||||
console.error(`[wechat-api] Uploading cover: ${coverPath}`);
|
||||
const coverResp = await uploadImage(coverPath, accessToken, baseDir);
|
||||
// 封面图片使用 material/add_material 接口
|
||||
const coverResp = await uploadImage(coverPath, accessToken, baseDir, "material");
|
||||
thumbMediaId = coverResp.media_id;
|
||||
} else if (firstMediaId) {
|
||||
if (firstMediaId.startsWith("https://")) {
|
||||
console.error(`[wechat-api] Uploading first image as cover: ${firstMediaId}`);
|
||||
const coverResp = await uploadImage(firstMediaId, accessToken, baseDir);
|
||||
thumbMediaId = coverResp.media_id;
|
||||
} else {
|
||||
thumbMediaId = firstMediaId;
|
||||
}
|
||||
console.error(`[wechat-api] Cover uploaded successfully, media_id: ${thumbMediaId}`);
|
||||
} else if (firstCoverMediaId && args.articleType === "news") {
|
||||
// news 类型没有封面时,使用第一张正文图的 media_id 作为封面(兜底逻辑)
|
||||
thumbMediaId = firstCoverMediaId;
|
||||
console.error(`[wechat-api] Using first body image as cover (fallback), media_id: ${thumbMediaId}`);
|
||||
}
|
||||
|
||||
if (args.articleType === "news" && !thumbMediaId) {
|
||||
@@ -630,7 +741,7 @@ async function main(): Promise<void> {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (args.articleType === "newspic" && allMediaIds.length === 0) {
|
||||
if (args.articleType === "newspic" && imageMediaIds.length === 0) {
|
||||
console.error("Error: newspic requires at least one image in content.");
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -643,7 +754,7 @@ async function main(): Promise<void> {
|
||||
content: htmlContent,
|
||||
thumbMediaId,
|
||||
articleType: args.articleType,
|
||||
imageMediaIds: args.articleType === "newspic" ? allMediaIds : undefined,
|
||||
imageMediaIds: args.articleType === "newspic" ? imageMediaIds : undefined,
|
||||
needOpenComment: resolved.need_open_comment,
|
||||
onlyFansCanComment: resolved.only_fans_can_comment,
|
||||
}, accessToken);
|
||||
|
||||
@@ -51,32 +51,42 @@ async function waitForElement(session: ChromeSession, selector: string, timeoutM
|
||||
return false;
|
||||
}
|
||||
|
||||
async function clickMenuByText(session: ChromeSession, text: string): Promise<void> {
|
||||
async function clickMenuByText(session: ChromeSession, text: string, maxRetries = 5): Promise<void> {
|
||||
console.log(`[wechat] Clicking "${text}" menu...`);
|
||||
const posResult = await session.cdp.send<{ result: { value: string } }>('Runtime.evaluate', {
|
||||
expression: `
|
||||
(function() {
|
||||
const items = document.querySelectorAll('.new-creation__menu .new-creation__menu-item');
|
||||
for (const item of items) {
|
||||
const title = item.querySelector('.new-creation__menu-title');
|
||||
if (title && title.textContent?.trim() === '${text}') {
|
||||
item.scrollIntoView({ block: 'center' });
|
||||
const rect = item.getBoundingClientRect();
|
||||
return JSON.stringify({ x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 });
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
const posResult = await session.cdp.send<{ result: { value: string } }>('Runtime.evaluate', {
|
||||
expression: `
|
||||
(function() {
|
||||
const items = document.querySelectorAll('.new-creation__menu .new-creation__menu-item');
|
||||
for (const item of items) {
|
||||
const title = item.querySelector('.new-creation__menu-title');
|
||||
if (title && title.textContent?.trim() === '${text}') {
|
||||
item.scrollIntoView({ block: 'center' });
|
||||
const rect = item.getBoundingClientRect();
|
||||
return JSON.stringify({ x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 });
|
||||
}
|
||||
}
|
||||
}
|
||||
return 'null';
|
||||
})()
|
||||
`,
|
||||
returnByValue: true,
|
||||
}, { sessionId: session.sessionId });
|
||||
return 'null';
|
||||
})()
|
||||
`,
|
||||
returnByValue: true,
|
||||
}, { sessionId: session.sessionId });
|
||||
|
||||
if (posResult.result.value === 'null') throw new Error(`Menu "${text}" not found`);
|
||||
const pos = JSON.parse(posResult.result.value);
|
||||
if (posResult.result.value !== 'null') {
|
||||
const pos = JSON.parse(posResult.result.value);
|
||||
await session.cdp.send('Input.dispatchMouseEvent', { type: 'mousePressed', x: pos.x, y: pos.y, button: 'left', clickCount: 1 }, { sessionId: session.sessionId });
|
||||
await sleep(100);
|
||||
await session.cdp.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: pos.x, y: pos.y, button: 'left', clickCount: 1 }, { sessionId: session.sessionId });
|
||||
return;
|
||||
}
|
||||
|
||||
await session.cdp.send('Input.dispatchMouseEvent', { type: 'mousePressed', x: pos.x, y: pos.y, button: 'left', clickCount: 1 }, { sessionId: session.sessionId });
|
||||
await sleep(100);
|
||||
await session.cdp.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: pos.x, y: pos.y, button: 'left', clickCount: 1 }, { sessionId: session.sessionId });
|
||||
if (attempt < maxRetries) {
|
||||
const delay = Math.min(1000 * attempt, 3000);
|
||||
console.log(`[wechat] Menu "${text}" not found, retrying in ${delay}ms (${attempt}/${maxRetries})...`);
|
||||
await sleep(delay);
|
||||
}
|
||||
}
|
||||
throw new Error(`Menu "${text}" not found after ${maxRetries} attempts`);
|
||||
}
|
||||
|
||||
async function copyImageToClipboard(imagePath: string): Promise<void> {
|
||||
@@ -495,10 +505,10 @@ export async function postArticle(options: ArticleOptions): Promise<void> {
|
||||
if (!loggedIn) throw new Error('Login timeout');
|
||||
}
|
||||
console.log('[wechat] Logged in.');
|
||||
await sleep(2000);
|
||||
await sleep(5000);
|
||||
|
||||
// Wait for menu to be ready
|
||||
const menuReady = await waitForElement(session, '.new-creation__menu', 20_000);
|
||||
const menuReady = await waitForElement(session, '.new-creation__menu', 40_000);
|
||||
if (!menuReady) throw new Error('Home page menu did not load');
|
||||
|
||||
const targets = await cdp.send<{ targetInfos: Array<{ targetId: string; url: string; type: string }> }>('Target.getTargets');
|
||||
@@ -517,16 +527,21 @@ export async function postArticle(options: ArticleOptions): Promise<void> {
|
||||
await cdp.send('Runtime.enable', {}, { sessionId });
|
||||
await cdp.send('DOM.enable', {}, { sessionId });
|
||||
|
||||
await sleep(3000);
|
||||
// Wait for editor elements to fully load
|
||||
console.log('[wechat] Waiting for editor to load...');
|
||||
const editorLoaded = await waitForElement(session, '#title', 30_000);
|
||||
if (!editorLoaded) throw new Error('Editor did not load (#title not found)');
|
||||
await waitForElement(session, '.ProseMirror', 15_000);
|
||||
await sleep(2000);
|
||||
|
||||
if (effectiveTitle) {
|
||||
console.log('[wechat] Filling title...');
|
||||
await evaluate(session, `document.querySelector('#title').value = ${JSON.stringify(effectiveTitle)}; document.querySelector('#title').dispatchEvent(new Event('input', { bubbles: true }));`);
|
||||
await evaluate(session, `(function() { const el = document.querySelector('#title'); el.focus(); el.value = ${JSON.stringify(effectiveTitle)}; el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); })()`);
|
||||
}
|
||||
|
||||
if (effectiveAuthor) {
|
||||
console.log('[wechat] Filling author...');
|
||||
await evaluate(session, `document.querySelector('#author').value = ${JSON.stringify(effectiveAuthor)}; document.querySelector('#author').dispatchEvent(new Event('input', { bubbles: true }));`);
|
||||
await evaluate(session, `(function() { const el = document.querySelector('#author'); el.focus(); el.value = ${JSON.stringify(effectiveAuthor)}; el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); })()`);
|
||||
}
|
||||
|
||||
await sleep(500);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user